mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 10:12:05 +00:00
feat(vendors): complete core vendor workflows
This commit is contained in:
parent
20237f8142
commit
bbbb51aed5
17 changed files with 824 additions and 116 deletions
89
e2e/vendors/vendors.spec.ts
vendored
89
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -13,6 +13,7 @@ const TEST_USER = {
|
||||||
const activeVendors = [
|
const activeVendors = [
|
||||||
{
|
{
|
||||||
Id: 1,
|
Id: 1,
|
||||||
|
CompanyId: 101,
|
||||||
CompanyName: "Gateway Plumbing",
|
CompanyName: "Gateway Plumbing",
|
||||||
ContactName: "Adam Whyte",
|
ContactName: "Adam Whyte",
|
||||||
Email: "dispatch@gateway.test",
|
Email: "dispatch@gateway.test",
|
||||||
|
|
@ -27,9 +28,11 @@ const activeVendors = [
|
||||||
Notes: "Preferred after-hours vendor",
|
Notes: "Preferred after-hours vendor",
|
||||||
TotalJobs: 72,
|
TotalJobs: 72,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
PreferredContact: "Email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Id: 2,
|
Id: 2,
|
||||||
|
CompanyId: 102,
|
||||||
CompanyName: "Metro HVAC",
|
CompanyName: "Metro HVAC",
|
||||||
ContactName: "Maya Chen",
|
ContactName: "Maya Chen",
|
||||||
Email: "maya@metro.test",
|
Email: "maya@metro.test",
|
||||||
|
|
@ -75,7 +78,10 @@ async function captureVisual(page: Page, path: string) {
|
||||||
await page.screenshot({ path, fullPage: true });
|
await page.screenshot({ path, fullPage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) {
|
async function mockVendorApi(
|
||||||
|
page: Page,
|
||||||
|
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
||||||
|
) {
|
||||||
const state: MockState = { listUrls: [] };
|
const state: MockState = { listUrls: [] };
|
||||||
|
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
@ -92,6 +98,7 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
fulfillJson(route, {
|
fulfillJson(route, {
|
||||||
data: {
|
data: {
|
||||||
companies: activeVendors.map((vendor) => ({
|
companies: activeVendors.map((vendor) => ({
|
||||||
|
companyId: vendor.CompanyId,
|
||||||
name: vendor.CompanyName,
|
name: vendor.CompanyName,
|
||||||
companyPhone: vendor.CompanyPhone,
|
companyPhone: vendor.CompanyPhone,
|
||||||
email: vendor.Email,
|
email: vendor.Email,
|
||||||
|
|
@ -143,6 +150,27 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||||
|
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
vendorId,
|
||||||
|
canDeactivate: !options.deactivationBlocked,
|
||||||
|
openWorkOrders: options.deactivationBlocked
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
workOrderTitle: "Emergency boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2026-07-24T14:00:00Z",
|
||||||
|
dispatchId: 801,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||||
const id = route.request().url().split("/").pop() ?? "";
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
if (route.request().method() === "PUT") {
|
if (route.request().method() === "PUT") {
|
||||||
|
|
@ -153,6 +181,14 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
|
if (options.deleteConflict) {
|
||||||
|
await fulfillJson(
|
||||||
|
route,
|
||||||
|
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.deletedId = id;
|
state.deletedId = id;
|
||||||
await fulfillJson(route, { status: "200" });
|
await fulfillJson(route, { status: "200" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -248,15 +284,13 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||||
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
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.getByLabel("Technician Name").fill("New Technician");
|
||||||
await page.getByRole("combobox", { name: "Company" }).click();
|
await page.getByRole("combobox", { name: "Company" }).click();
|
||||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
await expect(page.getByLabel("Company Phone")).toHaveValue("(314) 555-0100");
|
||||||
await expect(page.getByLabel("Email")).toHaveValue("dispatch@gateway.test");
|
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
|
||||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
||||||
|
|
@ -265,12 +299,19 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByRole("button", { name: "Add trade" }).click();
|
await page.getByRole("button", { name: "Add trade" }).click();
|
||||||
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
|
await page.getByRole("button", { name: "Text" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||||
expect(state.createdBody).toMatchObject({
|
expect(state.createdBody).toMatchObject({
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
|
companyId: 101,
|
||||||
contactName: "New Technician",
|
contactName: "New Technician",
|
||||||
companyPhone: "314-555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
|
preferredContact: "Text",
|
||||||
tradeSpecialties: "HVAC, Plumbing",
|
tradeSpecialties: "HVAC, Plumbing",
|
||||||
notes: "Created in browser E2E",
|
notes: "Created in browser E2E",
|
||||||
});
|
});
|
||||||
|
|
@ -308,11 +349,45 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(addButton).toBeFocused();
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
await page.getByLabel("Technician Name").fill("Independent Tech");
|
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
|
||||||
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
||||||
|
expect(state.createdBody?.companyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||||
|
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||||
|
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
expect(blockedState.deletedId).toBeUndefined();
|
||||||
|
|
||||||
|
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await page.unrouteAll({ behavior: "wait" });
|
||||||
|
|
||||||
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /^Deactivate$/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
/open work orders|conflict/i,
|
||||||
|
);
|
||||||
|
expect(racedState.deletedId).toBeUndefined();
|
||||||
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export const API_PATHS = {
|
||||||
delete: "Vendor/Delete",
|
delete: "Vendor/Delete",
|
||||||
dropdown: "Vendor/Dropdown",
|
dropdown: "Vendor/Dropdown",
|
||||||
facets: "Vendor/facets",
|
facets: "Vendor/facets",
|
||||||
|
deactivationImpact: "deactivation-impact",
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -40,6 +40,8 @@ const defaultValues: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||||
|
|
@ -52,11 +54,15 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -67,13 +73,16 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
|
@ -136,7 +145,7 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
disabled={createVendor.isPending}
|
disabled={createVendor.isPending || !isFormValid}
|
||||||
>
|
>
|
||||||
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, useWatch, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
|
@ -24,10 +24,16 @@ import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/ve
|
||||||
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 { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import type { Vendor, VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 420;
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text Message",
|
||||||
|
};
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) return "—";
|
if (parts.length === 0) return "—";
|
||||||
|
|
@ -59,6 +65,8 @@ function toFormValues(vendor: Vendor): VendorFormValues {
|
||||||
zip: vendor.zip ?? "",
|
zip: vendor.zip ?? "",
|
||||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||||
isActive: vendor.isActive,
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,10 +106,11 @@ export function VendorDetailDrawer({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
mode: "onTouched",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
contactName: "",
|
contactName: "",
|
||||||
|
|
@ -117,8 +126,12 @@ export function VendorDetailDrawer({
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && detail) {
|
if (open && detail) {
|
||||||
|
|
@ -135,13 +148,16 @@ export function VendorDetailDrawer({
|
||||||
|
|
||||||
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
|
@ -268,6 +284,10 @@ export function VendorDetailDrawer({
|
||||||
<DetailSection title="Technician">
|
<DetailSection title="Technician">
|
||||||
<DetailField label="Technician Name" value={detail.contactName} />
|
<DetailField label="Technician Name" value={detail.contactName} />
|
||||||
<DetailField label="Technician Phone" value={detail.phone} />
|
<DetailField label="Technician Phone" value={detail.phone} />
|
||||||
|
<DetailField
|
||||||
|
label="Preferred Contact"
|
||||||
|
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||||
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Company">
|
<DetailSection title="Company">
|
||||||
|
|
@ -417,7 +437,7 @@ export function VendorDetailDrawer({
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="vendor-edit-form"
|
form="vendor-edit-form"
|
||||||
disabled={updateVendor.isPending}
|
disabled={updateVendor.isPending || !isFormValid}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,15 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorFacetCompany,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
interface VendorFormFieldsProps {
|
interface VendorFormFieldsProps {
|
||||||
|
|
@ -38,6 +44,20 @@ function joinTrades(trades: string[]): string {
|
||||||
return trades.join(", ");
|
return trades.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPhoneMask(input: string): string {
|
||||||
|
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||||
|
if (digits.length === 0) return "";
|
||||||
|
if (digits.length < 4) return `(${digits}`;
|
||||||
|
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text",
|
||||||
|
};
|
||||||
|
|
||||||
export function VendorFormFields({
|
export function VendorFormFields({
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
|
|
@ -72,8 +92,46 @@ export function VendorFormFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="phone"
|
name="phone"
|
||||||
render={({ field }) => <TextField {...field} label="Technician Phone" fullWidth />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={formatPhoneMask(field.value ?? "")}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.phone)}
|
||||||
|
helperText={errors.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Box>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 0.5 }}>
|
||||||
|
Preferred Contact
|
||||||
|
</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="preferredContact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={field.value ?? "Phone"}
|
||||||
|
onChange={(_event, value: VendorPreferredContact | null) => {
|
||||||
|
if (value) field.onChange(value);
|
||||||
|
}}
|
||||||
|
aria-label="Preferred contact"
|
||||||
|
>
|
||||||
|
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
|
||||||
|
<ToggleButton key={option} value={option} aria-label={option}>
|
||||||
|
{PREFERRED_CONTACT_LABELS[option]}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -88,44 +146,54 @@ export function VendorFormFields({
|
||||||
control={control}
|
control={control}
|
||||||
name="companyName"
|
name="companyName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Controller
|
||||||
freeSolo
|
control={control}
|
||||||
options={companies}
|
name="companyId"
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
render={({ field: companyIdField }) => (
|
||||||
isOptionEqualToValue={(option, value) =>
|
<Autocomplete
|
||||||
typeof option === "string" || typeof value === "string"
|
freeSolo
|
||||||
? option === value
|
options={companies}
|
||||||
: option.name === value.name
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
}
|
isOptionEqualToValue={(option, value) =>
|
||||||
value={field.value ?? ""}
|
typeof option === "string" || typeof value === "string"
|
||||||
inputValue={field.value ?? ""}
|
? option === value
|
||||||
onInputChange={(_event, value, reason) => {
|
: option.name === value.name
|
||||||
if (reason === "input") {
|
|
||||||
field.onChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(_event, value, reason) => {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
field.onChange(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value) {
|
|
||||||
field.onChange(value.name);
|
|
||||||
if (reason === "selectOption") {
|
|
||||||
onCompanySelected(value);
|
|
||||||
}
|
}
|
||||||
} else {
|
value={field.value ?? ""}
|
||||||
field.onChange("");
|
inputValue={field.value ?? ""}
|
||||||
}
|
onInputChange={(_event, value, reason) => {
|
||||||
}}
|
if (reason === "input") {
|
||||||
renderInput={(params) => (
|
field.onChange(value);
|
||||||
<TextField
|
companyIdField.onChange(null);
|
||||||
{...params}
|
}
|
||||||
label="Company"
|
}}
|
||||||
required
|
onChange={(_event, value, reason) => {
|
||||||
placeholder="Select or type a company"
|
if (typeof value === "string") {
|
||||||
error={Boolean(errors.companyName)}
|
field.onChange(value);
|
||||||
helperText={errors.companyName?.message}
|
companyIdField.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
field.onChange(value.name);
|
||||||
|
companyIdField.onChange(value.companyId);
|
||||||
|
if (reason === "selectOption") {
|
||||||
|
onCompanySelected(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange("");
|
||||||
|
companyIdField.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Company"
|
||||||
|
required
|
||||||
|
placeholder="Select or type a company"
|
||||||
|
error={Boolean(errors.companyName)}
|
||||||
|
helperText={errors.companyName?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -134,7 +202,19 @@ export function VendorFormFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="companyPhone"
|
name="companyPhone"
|
||||||
render={({ field }) => <TextField {...field} label="Company Phone" fullWidth />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={formatPhoneMask(field.value ?? "")}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.companyPhone)}
|
||||||
|
helperText={errors.companyPhone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm, useWatch } 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 {
|
||||||
|
|
@ -38,6 +38,8 @@ const defaultValues: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VendorFormPage() {
|
export default function VendorFormPage() {
|
||||||
|
|
@ -56,11 +58,15 @@ export default function VendorFormPage() {
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (vendor && isEdit) {
|
if (vendor && isEdit) {
|
||||||
|
|
@ -79,19 +85,24 @@ export default function VendorFormPage() {
|
||||||
zip: vendor.zip,
|
zip: vendor.zip,
|
||||||
tradeSpecialties: vendor.tradeSpecialties,
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
isActive: vendor.isActive,
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [vendor, isEdit, reset]);
|
}, [vendor, isEdit, reset]);
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||||
|
|
@ -158,7 +169,7 @@ export default function VendorFormPage() {
|
||||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
155
src/app/(protected)/vendors/index.tsx
vendored
155
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||||
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 FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
|
|
@ -38,6 +40,7 @@ import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
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 { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
|
@ -100,11 +103,17 @@ export default function VendorsListPage() {
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
const {
|
||||||
|
data: deactivationImpact,
|
||||||
|
isLoading: isLoadingImpact,
|
||||||
|
error: impactError,
|
||||||
|
} = useVendorDeactivationImpact(deleteTarget?.id);
|
||||||
const { data: facets } = useVendorFacets(isActive);
|
const { data: facets } = useVendorFacets(isActive);
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
||||||
|
|
@ -169,9 +178,39 @@ export default function VendorsListPage() {
|
||||||
setDetailMode("view");
|
setDetailMode("view");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeactivation = (row: VendorListItem) => {
|
||||||
|
setDeleteTarget(row);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeactivation = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
setDeleteError(null);
|
||||||
|
deleteVendor.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setDeleteError(error.message || "Failed to deactivate vendor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAnd = (handler: () => void) => (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(row, "view");
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLoadingRow = isLoading;
|
const showLoadingRow = isLoading;
|
||||||
|
|
@ -304,7 +343,16 @@ export default function VendorsListPage() {
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
return (
|
return (
|
||||||
<TableRow key={String(row.id)} hover>
|
<TableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
hover
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Open vendor details for ${row.companyName}`}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={() => openDetail(row, "view")}
|
||||||
|
onKeyDown={handleRowKeyDown(row)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -336,7 +384,11 @@ export default function VendorsListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.phone ? (
|
{row.phone ? (
|
||||||
<Link href={`tel:${row.phone}`} underline="hover">
|
<Link
|
||||||
|
href={`tel:${row.phone}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
{row.phone}
|
{row.phone}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -345,7 +397,11 @@ export default function VendorsListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.email ? (
|
{row.email ? (
|
||||||
<Link href={`mailto:${row.email}`} underline="hover">
|
<Link
|
||||||
|
href={`mailto:${row.email}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
{row.email}
|
{row.email}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -365,7 +421,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`View vendor ${row.companyName}`}
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
onClick={() => openDetail(row, "view")}
|
onClick={stopAnd(() => openDetail(row, "view"))}
|
||||||
>
|
>
|
||||||
<VisibilityOutlinedIcon fontSize="small" />
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -381,7 +437,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`Edit vendor ${row.companyName}`}
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
onClick={() => openDetail(row, "edit")}
|
onClick={stopAnd(() => openDetail(row, "edit"))}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -391,7 +447,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Deactivate vendor"
|
aria-label="Deactivate vendor"
|
||||||
onClick={() => setDeleteTarget(row)}
|
onClick={stopAnd(() => openDeactivation(row))}
|
||||||
>
|
>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -458,23 +514,92 @@ export default function VendorsListPage() {
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
<Dialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
|
||||||
|
>
|
||||||
<DialogTitle>Deactivate Vendor</DialogTitle>
|
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<Stack spacing={2}>
|
||||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
<DialogContentText>
|
||||||
history will be preserved.
|
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||||
</DialogContentText>
|
history will be preserved.
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
{isLoadingImpact && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Checking linked work orders...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{impactError && !isLoadingImpact && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Could not verify linked work orders. Close and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && (
|
||||||
|
<Alert severity="error">
|
||||||
|
This vendor cannot be deactivated because it still has open work orders.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Open work orders ({deactivationImpact.openWorkOrders.length})
|
||||||
|
</Typography>
|
||||||
|
<List dense sx={{ mt: 0.5 }}>
|
||||||
|
{deactivationImpact.openWorkOrders.map((wo) => (
|
||||||
|
<ListItem
|
||||||
|
key={String(wo.workOrderId)}
|
||||||
|
sx={{ px: 0, py: 0.25 }}
|
||||||
|
secondaryAction={
|
||||||
|
wo.scheduledDate ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.scheduledDate}
|
||||||
|
</Typography>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{wo.workOrderNumber
|
||||||
|
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||||
|
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||||
|
</Typography>
|
||||||
|
{wo.status && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.status}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteError && <Alert severity="error">{deleteError}</Alert>}
|
||||||
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="error"
|
color="error"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteVendor.isPending}
|
disabled={
|
||||||
|
deleteVendor.isPending ||
|
||||||
|
isLoadingImpact ||
|
||||||
|
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
||||||
|
Boolean(impactError)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
9
src/domain/vendors/api/vendors-api.ts
vendored
9
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -3,6 +3,7 @@ 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,
|
||||||
|
mapVendorDeactivationImpact,
|
||||||
mapVendorFacets,
|
mapVendorFacets,
|
||||||
mapVendorListItems,
|
mapVendorListItems,
|
||||||
mapVendorPortalToken,
|
mapVendorPortalToken,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
VendorDeactivationImpact,
|
||||||
VendorFacets,
|
VendorFacets,
|
||||||
VendorPortalToken,
|
VendorPortalToken,
|
||||||
VendorsListParams,
|
VendorsListParams,
|
||||||
|
|
@ -102,6 +104,13 @@ export const vendorsApi = {
|
||||||
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
|
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
|
||||||
|
const data = await apiGet<unknown>(
|
||||||
|
`${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`,
|
||||||
|
);
|
||||||
|
return mapVendorDeactivationImpact(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
||||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
||||||
return mapVendorPortalToken(handleApiResponse(data));
|
return mapVendorPortalToken(handleApiResponse(data));
|
||||||
|
|
|
||||||
113
src/domain/vendors/mappers/vendor-mapper.ts
vendored
113
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -1,13 +1,18 @@
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
VendorDeactivationImpact,
|
||||||
VendorFacetCompany,
|
VendorFacetCompany,
|
||||||
VendorFacetJobBucket,
|
VendorFacetJobBucket,
|
||||||
VendorFacetLocation,
|
VendorFacetLocation,
|
||||||
VendorFacets,
|
VendorFacets,
|
||||||
VendorListItem,
|
VendorListItem,
|
||||||
|
VendorOpenWorkOrderSummary,
|
||||||
VendorPortalToken,
|
VendorPortalToken,
|
||||||
|
VendorPreferredContact,
|
||||||
} from "@/domain/vendors/types/vendor";
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
|
||||||
|
|
||||||
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>) : {};
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +44,41 @@ function readId(record: Record<string, unknown>): string | number {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCompanyId(record: Record<string, unknown>): string | number | null {
|
||||||
|
const value = record.companyId ?? record.CompanyId;
|
||||||
|
if (typeof value === "string") return value.trim() === "" ? null : value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreferredContact(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): VendorPreferredContact {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
|
||||||
|
return normalized as VendorPreferredContact;
|
||||||
|
}
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (lowered === "phone" || lowered === "call") return "Phone";
|
||||||
|
if (lowered === "email" || lowered === "e-mail") return "Email";
|
||||||
|
if (lowered === "text" || lowered === "sms") return "Text";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Phone";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCanonicalPhone(value: unknown): string {
|
||||||
|
const digits = String(value ?? "")
|
||||||
|
.replace(/\D+/g, "")
|
||||||
|
.slice(0, 10);
|
||||||
|
if (digits.length !== 10) return "";
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
|
|
@ -51,11 +91,12 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
companyId: readCompanyId(item),
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
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: toCanonicalPhone(item.phone ?? item.Phone),
|
||||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
notes: readString(item, "notes", "Notes"),
|
notes: readString(item, "notes", "Notes"),
|
||||||
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||||
|
|
@ -63,6 +104,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
state: readString(item, "state", "State"),
|
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"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,11 +116,12 @@ export function mapVendor(raw: unknown): Vendor {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
id: readId(item) || undefined,
|
id: readId(item) || undefined,
|
||||||
|
companyId: readCompanyId(item),
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
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: toCanonicalPhone(item.phone ?? item.Phone),
|
||||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
notes: readString(item, "notes", "Notes"),
|
notes: readString(item, "notes", "Notes"),
|
||||||
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||||
|
|
@ -88,25 +131,29 @@ export function mapVendor(raw: unknown): Vendor {
|
||||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
isActive: readBool(item, "isActive", "IsActive"),
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
const item = asRecord(vendor);
|
const item = asRecord(vendor);
|
||||||
|
const companyName = readString(item, "companyName", "CompanyName");
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
companyName: item.companyName ?? "",
|
companyId: readCompanyId(item),
|
||||||
contactName: item.contactName ?? "",
|
companyName,
|
||||||
email: item.email ?? "",
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
phone: item.phone ?? "",
|
email: readString(item, "email", "Email"),
|
||||||
companyPhone: item.companyPhone ?? "",
|
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||||
googleMapsUrl: item.googleMapsUrl ?? "",
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
notes: item.notes ?? "",
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
address: item.address ?? "",
|
notes: readString(item, "notes", "Notes"),
|
||||||
city: item.city ?? "",
|
address: readString(item, "address", "Address"),
|
||||||
state: item.state ?? "",
|
city: readString(item, "city", "City"),
|
||||||
zip: item.zip ?? "",
|
state: readString(item, "state", "State"),
|
||||||
tradeSpecialties: item.tradeSpecialties ?? "",
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
isActive: item.isActive ?? true,
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
const id = item.id ?? item.Id;
|
const id = item.id ?? item.Id;
|
||||||
if (typeof id === "string" || typeof id === "number") {
|
if (typeof id === "string" || typeof id === "number") {
|
||||||
|
|
@ -118,8 +165,9 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
|
companyId: readCompanyId(item),
|
||||||
name: readString(item, "name", "Name"),
|
name: readString(item, "name", "Name"),
|
||||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
address: readString(item, "address", "Address"),
|
address: readString(item, "address", "Address"),
|
||||||
city: readString(item, "city", "City"),
|
city: readString(item, "city", "City"),
|
||||||
|
|
@ -182,3 +230,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
||||||
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
|
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapOpenWorkOrder(raw: unknown): VendorOpenWorkOrderSummary {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const workOrderId = item.workOrderId ?? item.WorkOrderId;
|
||||||
|
const dispatchId = item.dispatchId ?? item.DispatchId;
|
||||||
|
return {
|
||||||
|
workOrderId:
|
||||||
|
typeof workOrderId === "string" || typeof workOrderId === "number" ? workOrderId : "",
|
||||||
|
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
|
||||||
|
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
|
||||||
|
dispatchId:
|
||||||
|
typeof dispatchId === "string" || typeof dispatchId === "number" ? dispatchId : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorDeactivationImpact(raw: unknown): VendorDeactivationImpact {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const openWorkOrdersRaw = item.openWorkOrders ?? item.OpenWorkOrders;
|
||||||
|
const rawId = item.vendorId ?? item.VendorId ?? item.id ?? item.Id;
|
||||||
|
const vendorId: string | number =
|
||||||
|
typeof rawId === "string" || typeof rawId === "number" ? rawId : "";
|
||||||
|
return {
|
||||||
|
vendorId,
|
||||||
|
canDeactivate: readBool(item, "canDeactivate", "CanDeactivate"),
|
||||||
|
openWorkOrders: Array.isArray(openWorkOrdersRaw) ? openWorkOrdersRaw.map(mapOpenWorkOrder) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
19
src/domain/vendors/schemas/vendor-schema.ts
vendored
19
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -1,4 +1,8 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const httpsUrl = z
|
const httpsUrl = z
|
||||||
.string()
|
.string()
|
||||||
|
|
@ -8,12 +12,19 @@ const httpsUrl = z
|
||||||
"Google Maps URL must use HTTPS",
|
"Google Maps URL must use HTTPS",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canonicalPhone = z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(value) => value === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(value),
|
||||||
|
"Enter a 10-digit phone number",
|
||||||
|
);
|
||||||
|
|
||||||
export const vendorSchema = z.object({
|
export const vendorSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company is required"),
|
companyName: z.string().min(1, "Company is required"),
|
||||||
contactName: z.string().min(1, "Technician name is required"),
|
contactName: z.string().min(1, "Technician name is required"),
|
||||||
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
||||||
phone: z.string().optional(),
|
phone: canonicalPhone.optional(),
|
||||||
companyPhone: z.string().optional(),
|
companyPhone: canonicalPhone.optional(),
|
||||||
googleMapsUrl: httpsUrl.optional(),
|
googleMapsUrl: httpsUrl.optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
totalJobs: z.number().optional(),
|
totalJobs: z.number().optional(),
|
||||||
|
|
@ -23,6 +34,10 @@ export const vendorSchema = z.object({
|
||||||
zip: z.string().optional(),
|
zip: z.string().optional(),
|
||||||
tradeSpecialties: z.string().optional(),
|
tradeSpecialties: z.string().optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
|
companyId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
preferredContact: z
|
||||||
|
.enum(VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]])
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
||||||
|
|
|
||||||
24
src/domain/vendors/types/vendor.ts
vendored
24
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -1,5 +1,10 @@
|
||||||
|
export type VendorPreferredContact = "Phone" | "Email" | "Text";
|
||||||
|
|
||||||
|
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
|
||||||
|
|
||||||
export interface VendorListItem {
|
export interface VendorListItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
companyId: string | number | null;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -12,10 +17,12 @@ export interface VendorListItem {
|
||||||
state: string;
|
state: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Vendor {
|
export interface Vendor {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
|
companyId: string | number | null;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -30,6 +37,7 @@ export interface Vendor {
|
||||||
zip: string;
|
zip: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorsListParams {
|
export interface VendorsListParams {
|
||||||
|
|
@ -51,6 +59,7 @@ export interface VendorsListResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorFacetCompany {
|
export interface VendorFacetCompany {
|
||||||
|
companyId: string | number | null;
|
||||||
name: string;
|
name: string;
|
||||||
companyPhone: string;
|
companyPhone: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -85,3 +94,18 @@ export interface VendorPortalToken {
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorOpenWorkOrderSummary {
|
||||||
|
workOrderId: string | number;
|
||||||
|
workOrderNumber: string;
|
||||||
|
workOrderTitle: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
dispatchId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorDeactivationImpact {
|
||||||
|
vendorId: string | number;
|
||||||
|
canDeactivate: boolean;
|
||||||
|
openWorkOrders: VendorOpenWorkOrderSummary[];
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
vendored
Normal file
22
src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import type { VendorDeactivationImpact } from "@/domain/vendors/types/vendor";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useVendorDeactivationImpact(
|
||||||
|
id: string | number | undefined,
|
||||||
|
): UseQueryResult<VendorDeactivationImpact, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.vendors.deactivationImpact(id ?? ""),
|
||||||
|
queryFn: () => vendorsApi.getDeactivationImpact(requireParam(id)),
|
||||||
|
enabled: id !== undefined && id !== null && id !== "",
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireParam(id: string | number | undefined): string | number {
|
||||||
|
if (id === undefined || id === null || id === "") {
|
||||||
|
throw new Error("Vendor id is required");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,8 @@ export const queryKeys = {
|
||||||
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
|
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
|
||||||
portalToken: (vendorId: string | number) =>
|
portalToken: (vendorId: string | number) =>
|
||||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||||
|
deactivationImpact: (vendorId: string | number) =>
|
||||||
|
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
all: ["calendar"] as const,
|
all: ["calendar"] as const,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm, type FieldErrors } from "react-hook-form";
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
@ -10,6 +10,7 @@ import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const companies: VendorFacetCompany[] = [
|
const companies: VendorFacetCompany[] = [
|
||||||
{
|
{
|
||||||
|
companyId: "co-1",
|
||||||
name: "Gateway Plumbing",
|
name: "Gateway Plumbing",
|
||||||
companyPhone: "(314) 555-0198",
|
companyPhone: "(314) 555-0198",
|
||||||
email: "dispatch@gatewayplumbing.com",
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
|
@ -36,6 +37,8 @@ const emptyForm: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
function Harness({
|
function Harness({
|
||||||
|
|
@ -52,6 +55,7 @@ function Harness({
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
|
setValue("companyId", company.companyId);
|
||||||
setValue("companyPhone", company.companyPhone);
|
setValue("companyPhone", company.companyPhone);
|
||||||
setValue("email", company.email);
|
setValue("email", company.email);
|
||||||
setValue("address", company.address);
|
setValue("address", company.address);
|
||||||
|
|
@ -61,6 +65,8 @@ function Harness({
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("googleMapsUrl", company.googleMapsUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const companyId = useWatch({ control, name: "companyId" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VendorFormFields
|
<VendorFormFields
|
||||||
|
|
@ -70,6 +76,7 @@ function Harness({
|
||||||
tradeOptions={[]}
|
tradeOptions={[]}
|
||||||
onCompanySelected={onCompanySelected}
|
onCompanySelected={onCompanySelected}
|
||||||
/>
|
/>
|
||||||
|
<output data-testid="company-id">{String(companyId ?? "")}</output>
|
||||||
<button type="button" onClick={() => reset(resetTarget)}>
|
<button type="button" onClick={() => reset(resetTarget)}>
|
||||||
reset-form
|
reset-form
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -78,6 +85,7 @@ function Harness({
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
||||||
|
const companyIdOutput = () => screen.getByTestId("company-id");
|
||||||
|
|
||||||
describe("VendorFormFields company autocomplete state", () => {
|
describe("VendorFormFields company autocomplete state", () => {
|
||||||
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
||||||
|
|
@ -148,4 +156,50 @@ describe("VendorFormFields company autocomplete state", () => {
|
||||||
expect(companyField()).toHaveValue("Gateway Plumbing");
|
expect(companyField()).toHaveValue("Gateway Plumbing");
|
||||||
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "Gate");
|
||||||
|
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("co-1");
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "!");
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps companyId null for free-typed company text that matches no option", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "Independent Vendor LLC");
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
|
||||||
|
await userEvent.type(phone, "3145550198");
|
||||||
|
expect(phone).toHaveValue("(314) 555-0198");
|
||||||
|
|
||||||
|
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
|
||||||
|
await userEvent.type(companyPhone, "3145550100");
|
||||||
|
expect(companyPhone).toHaveValue("(314) 555-0100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a preferred contact selector with Phone, Email, and Text options", () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,22 @@ import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const useVendorsList = vi.fn();
|
const useVendorsList = vi.fn();
|
||||||
|
const useVendorDetail = vi.fn();
|
||||||
|
const useVendorDeactivationImpact = vi.fn();
|
||||||
const mutate = vi.fn();
|
const mutate = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
||||||
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
|
||||||
|
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
|
||||||
|
useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
|
||||||
useDeleteVendor: () => ({ mutate, isPending: false }),
|
useDeleteVendor: () => ({ mutate, isPending: false }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -21,6 +31,7 @@ vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
|
||||||
const activeVendor = {
|
const activeVendor = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
companyId: "co-1",
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
email: "dispatch@gatewayplumbing.com",
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
|
@ -29,6 +40,7 @@ const activeVendor = {
|
||||||
state: "MO",
|
state: "MO",
|
||||||
tradeSpecialties: "Backflow Preventers",
|
tradeSpecialties: "Backflow Preventers",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
preferredContact: "Email",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inactiveVendor = {
|
const inactiveVendor = {
|
||||||
|
|
@ -47,8 +59,18 @@ function result(items: (typeof activeVendor)[], totalCount: number) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupDefaults() {
|
||||||
|
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("VendorsListPage", () => {
|
describe("VendorsListPage", () => {
|
||||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||||
|
setupDefaults();
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||||
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
|
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
|
||||||
|
|
@ -67,6 +89,7 @@ describe("VendorsListPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||||
|
setupDefaults();
|
||||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||||
...activeVendor,
|
...activeVendor,
|
||||||
id: 100 + index,
|
id: 100 + index,
|
||||||
|
|
@ -83,4 +106,58 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null });
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
const row = screen.getByRole("button", {
|
||||||
|
name: "Open vendor details for Gateway Plumbing",
|
||||||
|
});
|
||||||
|
row.focus();
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks deactivation when the preflight reports open work orders", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
vendorId: 1,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
90
src/test/domain/vendors/api/vendors-api.test.ts
vendored
90
src/test/domain/vendors/api/vendors-api.test.ts
vendored
|
|
@ -4,12 +4,13 @@ import { API_PATHS } from "@/api/api-paths";
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
const apiPost = vi.fn();
|
const apiPost = vi.fn();
|
||||||
const apiPut = vi.fn();
|
const apiPut = vi.fn();
|
||||||
|
const apiDelete = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
apiDelete: vi.fn(),
|
apiDelete: (...args: unknown[]) => apiDelete(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
|
@ -19,15 +20,18 @@ describe("vendorsApi.getList", () => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
apiPost.mockReset();
|
apiPost.mockReset();
|
||||||
apiPut.mockReset();
|
apiPut.mockReset();
|
||||||
|
apiDelete.mockReset();
|
||||||
apiGet.mockResolvedValue({
|
apiGet.mockResolvedValue({
|
||||||
Data: [
|
Data: [
|
||||||
{
|
{
|
||||||
Id: 7,
|
Id: 7,
|
||||||
|
CompanyId: "co-1",
|
||||||
CompanyName: "Gateway Plumbing",
|
CompanyName: "Gateway Plumbing",
|
||||||
ContactName: "Adam Whyte",
|
ContactName: "Adam Whyte",
|
||||||
City: "St. Louis",
|
City: "St. Louis",
|
||||||
State: "MO",
|
State: "MO",
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
|
PreferredContact: "Email",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
TotalCount: 1,
|
TotalCount: 1,
|
||||||
|
|
@ -40,7 +44,13 @@ describe("vendorsApi.getList", () => {
|
||||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
||||||
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
||||||
});
|
});
|
||||||
expect(result.items[0]).toMatchObject({ city: "St. Louis", state: "MO", isActive: false });
|
expect(result.items[0]).toMatchObject({
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
isActive: false,
|
||||||
|
companyId: "co-1",
|
||||||
|
preferredContact: "Email",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards all selected facets as repeated indexed query parameters", async () => {
|
it("forwards all selected facets as repeated indexed query parameters", async () => {
|
||||||
|
|
@ -91,7 +101,7 @@ describe("vendorsApi.getList", () => {
|
||||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
|
||||||
expect(facets.companies[0]).toMatchObject({
|
expect(facets.companies[0]).toMatchObject({
|
||||||
name: "Gateway Plumbing",
|
name: "Gateway Plumbing",
|
||||||
companyPhone: "314-555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
googleMapsUrl: "https://maps.google.com/gateway",
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
});
|
});
|
||||||
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
|
expect(facets.locations[0]?.label).toBe("St. Louis, MO");
|
||||||
|
|
@ -116,6 +126,7 @@ describe("vendorsApi.getList", () => {
|
||||||
|
|
||||||
it("sends all editable prototype fields when creating and updating", async () => {
|
it("sends all editable prototype fields when creating and updating", async () => {
|
||||||
const values = {
|
const values = {
|
||||||
|
companyId: "co-1",
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
phone: "314-555-0198",
|
phone: "314-555-0198",
|
||||||
|
|
@ -130,6 +141,7 @@ describe("vendorsApi.getList", () => {
|
||||||
notes: "Preferred after-hours vendor",
|
notes: "Preferred after-hours vendor",
|
||||||
totalJobs: 72,
|
totalJobs: 72,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
preferredContact: "Text",
|
||||||
};
|
};
|
||||||
apiPost.mockResolvedValueOnce({ data: values });
|
apiPost.mockResolvedValueOnce({ data: values });
|
||||||
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
||||||
|
|
@ -139,7 +151,14 @@ describe("vendorsApi.getList", () => {
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenCalledWith(
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
API_PATHS.rest.vendors,
|
API_PATHS.rest.vendors,
|
||||||
expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes }),
|
expect.objectContaining({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
notes: values.notes,
|
||||||
|
companyId: "co-1",
|
||||||
|
preferredContact: "Text",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(apiPut).toHaveBeenCalledWith(
|
expect(apiPut).toHaveBeenCalledWith(
|
||||||
`${API_PATHS.rest.vendors}/7`,
|
`${API_PATHS.rest.vendors}/7`,
|
||||||
|
|
@ -148,4 +167,67 @@ describe("vendorsApi.getList", () => {
|
||||||
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends a null companyId when free-text company has no company link", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ data: {} });
|
||||||
|
await vendorsApi.create({
|
||||||
|
companyName: "Independent Vendor LLC",
|
||||||
|
contactName: "Indy Tech",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.rest.vendors,
|
||||||
|
expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the deactivation-impact payload including linked work orders", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
vendorId: 7,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2025-01-02",
|
||||||
|
dispatchId: 501,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const impact = await vendorsApi.getDeactivationImpact(7);
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(
|
||||||
|
`${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`,
|
||||||
|
);
|
||||||
|
expect(impact).toEqual({
|
||||||
|
vendorId: 7,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2025-01-02",
|
||||||
|
dispatchId: 501,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => {
|
||||||
|
apiDelete.mockResolvedValueOnce(undefined);
|
||||||
|
await vendorsApi.delete(7);
|
||||||
|
expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`);
|
||||||
|
|
||||||
|
const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } });
|
||||||
|
apiDelete.mockRejectedValueOnce(conflict);
|
||||||
|
await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const validVendor = {
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
email: "dispatch@gateway.test",
|
email: "dispatch@gateway.test",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
googleMapsUrl: "https://maps.google.com/gateway",
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,4 +34,28 @@ describe("vendorSchema", () => {
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts empty phones and canonical (XXX) XXX-XXXX phones, rejecting partials", () => {
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(false);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a nullable companyId and a known preferred contact value", () => {
|
||||||
|
expect(
|
||||||
|
vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown preferred contact value", () => {
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue