mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 13:53:12 +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 = [
|
||||
{
|
||||
Id: 1,
|
||||
CompanyId: 101,
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
Email: "dispatch@gateway.test",
|
||||
|
|
@ -27,9 +28,11 @@ const activeVendors = [
|
|||
Notes: "Preferred after-hours vendor",
|
||||
TotalJobs: 72,
|
||||
IsActive: true,
|
||||
PreferredContact: "Email",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CompanyId: 102,
|
||||
CompanyName: "Metro HVAC",
|
||||
ContactName: "Maya Chen",
|
||||
Email: "maya@metro.test",
|
||||
|
|
@ -75,7 +78,10 @@ async function captureVisual(page: Page, path: string) {
|
|||
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: [] };
|
||||
|
||||
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, {
|
||||
data: {
|
||||
companies: activeVendors.map((vendor) => ({
|
||||
companyId: vendor.CompanyId,
|
||||
name: vendor.CompanyName,
|
||||
companyPhone: vendor.CompanyPhone,
|
||||
email: vendor.Email,
|
||||
|
|
@ -143,6 +150,27 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
|||
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) => {
|
||||
const id = route.request().url().split("/").pop() ?? "";
|
||||
if (route.request().method() === "PUT") {
|
||||
|
|
@ -153,6 +181,14 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
|||
return;
|
||||
}
|
||||
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;
|
||||
await fulfillJson(route, { status: "200" });
|
||||
return;
|
||||
|
|
@ -248,15 +284,13 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
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 expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||
|
||||
await page.getByLabel("Technician Name").fill("New Technician");
|
||||
await page.getByRole("combobox", { name: "Company" }).click();
|
||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||
await expect(page.getByLabel("Email")).toHaveValue("dispatch@gateway.test");
|
||||
await expect(page.getByLabel("Company Phone")).toHaveValue("(314) 555-0100");
|
||||
await expect(page.getByRole("textbox", { name: "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");
|
||||
|
|
@ -265,12 +299,19 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
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: "Text" }).click();
|
||||
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
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",
|
||||
companyId: 101,
|
||||
contactName: "New Technician",
|
||||
companyPhone: "314-555-0100",
|
||||
companyPhone: "(314) 555-0100",
|
||||
preferredContact: "Text",
|
||||
tradeSpecialties: "HVAC, Plumbing",
|
||||
notes: "Created in browser E2E",
|
||||
});
|
||||
|
|
@ -308,11 +349,45 @@ test.describe("Vendor directory prototype parity", () => {
|
|||
await expect(addButton).toBeFocused();
|
||||
|
||||
await addButton.click();
|
||||
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||
await 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");
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export const API_PATHS = {
|
|||
delete: "Vendor/Delete",
|
||||
dropdown: "Vendor/Dropdown",
|
||||
facets: "Vendor/facets",
|
||||
deactivationImpact: "deactivation-impact",
|
||||
},
|
||||
calendar: {
|
||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
Alert,
|
||||
|
|
@ -40,6 +40,8 @@ const defaultValues: VendorFormValues = {
|
|||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||
|
|
@ -52,11 +54,15 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
|||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -67,13 +73,16 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
|||
|
||||
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 options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const onSubmit = (values: VendorFormValues) => {
|
||||
|
|
@ -136,7 +145,7 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
|||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createVendor.isPending}
|
||||
disabled={createVendor.isPending || !isFormValid}
|
||||
>
|
||||
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 CloseIcon from "@mui/icons-material/Close";
|
||||
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 { 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";
|
||||
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||
|
||||
const DRAWER_WIDTH = 420;
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text Message",
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "—";
|
||||
|
|
@ -59,6 +65,8 @@ function toFormValues(vendor: Vendor): VendorFormValues {
|
|||
zip: vendor.zip ?? "",
|
||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -98,10 +106,11 @@ export function VendorDetailDrawer({
|
|||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
mode: "onTouched",
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
|
|
@ -117,8 +126,12 @@ export function VendorDetailDrawer({
|
|||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
},
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && detail) {
|
||||
|
|
@ -135,13 +148,16 @@ export function VendorDetailDrawer({
|
|||
|
||||
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 options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
|
|
@ -268,6 +284,10 @@ export function VendorDetailDrawer({
|
|||
<DetailSection title="Technician">
|
||||
<DetailField label="Technician Name" value={detail.contactName} />
|
||||
<DetailField label="Technician Phone" value={detail.phone} />
|
||||
<DetailField
|
||||
label="Preferred Contact"
|
||||
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Company">
|
||||
|
|
@ -417,7 +437,7 @@ export function VendorDetailDrawer({
|
|||
variant="contained"
|
||||
type="submit"
|
||||
form="vendor-edit-form"
|
||||
disabled={updateVendor.isPending}
|
||||
disabled={updateVendor.isPending || !isFormValid}
|
||||
fullWidth
|
||||
>
|
||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,15 @@ import {
|
|||
FormLabel,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} 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";
|
||||
|
||||
interface VendorFormFieldsProps {
|
||||
|
|
@ -38,6 +44,20 @@ function joinTrades(trades: string[]): string {
|
|||
return trades.join(", ");
|
||||
}
|
||||
|
||||
function formatPhoneMask(input: string): string {
|
||||
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||
if (digits.length === 0) return "";
|
||||
if (digits.length < 4) return `(${digits}`;
|
||||
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||
Phone: "Phone",
|
||||
Email: "Email",
|
||||
Text: "Text",
|
||||
};
|
||||
|
||||
export function VendorFormFields({
|
||||
control,
|
||||
errors,
|
||||
|
|
@ -72,8 +92,46 @@ export function VendorFormFields({
|
|||
<Controller
|
||||
control={control}
|
||||
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>
|
||||
</Box>
|
||||
|
||||
|
|
@ -88,44 +146,54 @@ export function VendorFormFields({
|
|||
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={field.value ?? ""}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
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);
|
||||
<Controller
|
||||
control={control}
|
||||
name="companyId"
|
||||
render={({ field: companyIdField }) => (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={companies}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
typeof option === "string" || typeof value === "string"
|
||||
? option === value
|
||||
: option.name === value.name
|
||||
}
|
||||
} else {
|
||||
field.onChange("");
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Select or type a company"
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
value={field.value ?? ""}
|
||||
inputValue={field.value ?? ""}
|
||||
onInputChange={(_event, value, reason) => {
|
||||
if (reason === "input") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
onChange={(_event, value, reason) => {
|
||||
if (typeof value === "string") {
|
||||
field.onChange(value);
|
||||
companyIdField.onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
field.onChange(value.name);
|
||||
companyIdField.onChange(value.companyId);
|
||||
if (reason === "selectOption") {
|
||||
onCompanySelected(value);
|
||||
}
|
||||
} else {
|
||||
field.onChange("");
|
||||
companyIdField.onChange(null);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Company"
|
||||
required
|
||||
placeholder="Select or type a company"
|
||||
error={Boolean(errors.companyName)}
|
||||
helperText={errors.companyName?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -134,7 +202,19 @@ export function VendorFormFields({
|
|||
<Controller
|
||||
control={control}
|
||||
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
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
|
|
@ -38,6 +38,8 @@ const defaultValues: VendorFormValues = {
|
|||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
export default function VendorFormPage() {
|
||||
|
|
@ -56,11 +58,15 @@ export default function VendorFormPage() {
|
|||
reset,
|
||||
setValue,
|
||||
control,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<VendorFormValues>({
|
||||
resolver: zodResolver(vendorSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
const currentValues = useWatch({ control });
|
||||
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||
|
||||
useEffect(() => {
|
||||
if (vendor && isEdit) {
|
||||
|
|
@ -79,19 +85,24 @@ export default function VendorFormPage() {
|
|||
zip: vendor.zip,
|
||||
tradeSpecialties: vendor.tradeSpecialties,
|
||||
isActive: vendor.isActive,
|
||||
companyId: vendor.companyId ?? null,
|
||||
preferredContact: vendor.preferredContact ?? "Phone",
|
||||
});
|
||||
}
|
||||
}, [vendor, isEdit, 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 options = { shouldDirty: true, shouldValidate: true } as const;
|
||||
setValue("companyId", company.companyId, options);
|
||||
setValue("companyPhone", company.companyPhone, options);
|
||||
setValue("email", company.email, options);
|
||||
setValue("address", company.address, options);
|
||||
setValue("city", company.city, options);
|
||||
setValue("state", company.state, options);
|
||||
setValue("zip", company.zip, options);
|
||||
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||
void trigger();
|
||||
};
|
||||
|
||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||
|
|
@ -158,7 +169,7 @@ export default function VendorFormPage() {
|
|||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</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 EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
|
|
@ -17,6 +17,8 @@ import {
|
|||
DialogTitle,
|
||||
IconButton,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Tab,
|
||||
|
|
@ -38,6 +40,7 @@ import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
|||
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
|
@ -100,11 +103,17 @@ export default function VendorsListPage() {
|
|||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const deleteVendor = useDeleteVendor();
|
||||
const {
|
||||
data: deactivationImpact,
|
||||
isLoading: isLoadingImpact,
|
||||
error: impactError,
|
||||
} = useVendorDeactivationImpact(deleteTarget?.id);
|
||||
const { data: facets } = useVendorFacets(isActive);
|
||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
||||
|
|
@ -169,9 +178,39 @@ export default function VendorsListPage() {
|
|||
setDetailMode("view");
|
||||
};
|
||||
|
||||
const openDeactivation = (row: VendorListItem) => {
|
||||
setDeleteTarget(row);
|
||||
setDeleteError(null);
|
||||
};
|
||||
|
||||
const closeDeactivation = () => {
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
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;
|
||||
|
|
@ -304,7 +343,16 @@ export default function VendorsListPage() {
|
|||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||
return (
|
||||
<TableRow key={String(row.id)} hover>
|
||||
<TableRow
|
||||
key={String(row.id)}
|
||||
hover
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Open vendor details for ${row.companyName}`}
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => openDetail(row, "view")}
|
||||
onKeyDown={handleRowKeyDown(row)}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||
</TableCell>
|
||||
|
|
@ -336,7 +384,11 @@ export default function VendorsListPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{row.phone ? (
|
||||
<Link href={`tel:${row.phone}`} underline="hover">
|
||||
<Link
|
||||
href={`tel:${row.phone}`}
|
||||
underline="hover"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{row.phone}
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -345,7 +397,11 @@ export default function VendorsListPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{row.email ? (
|
||||
<Link href={`mailto:${row.email}`} underline="hover">
|
||||
<Link
|
||||
href={`mailto:${row.email}`}
|
||||
underline="hover"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{row.email}
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -365,7 +421,7 @@ export default function VendorsListPage() {
|
|||
<IconButton
|
||||
size="small"
|
||||
aria-label={`View vendor ${row.companyName}`}
|
||||
onClick={() => openDetail(row, "view")}
|
||||
onClick={stopAnd(() => openDetail(row, "view"))}
|
||||
>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
|
@ -381,7 +437,7 @@ export default function VendorsListPage() {
|
|||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Edit vendor ${row.companyName}`}
|
||||
onClick={() => openDetail(row, "edit")}
|
||||
onClick={stopAnd(() => openDetail(row, "edit"))}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
|
@ -391,7 +447,7 @@ export default function VendorsListPage() {
|
|||
<IconButton
|
||||
size="small"
|
||||
aria-label="Deactivate vendor"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
onClick={stopAnd(() => openDeactivation(row))}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
|
@ -458,23 +514,92 @@ export default function VendorsListPage() {
|
|||
tradeOptions={tradeOptions}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<Dialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
|
||||
>
|
||||
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||
history will be preserved.
|
||||
</DialogContentText>
|
||||
<Stack spacing={2}>
|
||||
<DialogContentText>
|
||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||
history will be preserved.
|
||||
</DialogContentText>
|
||||
|
||||
{isLoadingImpact && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Checking linked work orders...
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
||||
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteVendor.isPending}
|
||||
disabled={
|
||||
deleteVendor.isPending ||
|
||||
isLoadingImpact ||
|
||||
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
||||
Boolean(impactError)
|
||||
}
|
||||
>
|
||||
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||
</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 {
|
||||
mapVendor,
|
||||
mapVendorDeactivationImpact,
|
||||
mapVendorFacets,
|
||||
mapVendorListItems,
|
||||
mapVendorPortalToken,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||
import type {
|
||||
Vendor,
|
||||
VendorDeactivationImpact,
|
||||
VendorFacets,
|
||||
VendorPortalToken,
|
||||
VendorsListParams,
|
||||
|
|
@ -102,6 +104,13 @@ export const vendorsApi = {
|
|||
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> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
||||
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 {
|
||||
Vendor,
|
||||
VendorDeactivationImpact,
|
||||
VendorFacetCompany,
|
||||
VendorFacetJobBucket,
|
||||
VendorFacetLocation,
|
||||
VendorFacets,
|
||||
VendorListItem,
|
||||
VendorOpenWorkOrderSummary,
|
||||
VendorPortalToken,
|
||||
VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
|
@ -39,6 +44,41 @@ function readId(record: Record<string, unknown>): string | number {
|
|||
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 {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
|
|
@ -51,11 +91,12 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
|||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
companyId: readCompanyId(item),
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||
notes: readString(item, "notes", "Notes"),
|
||||
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||
|
|
@ -63,6 +104,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
|||
state: readString(item, "state", "State"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +116,12 @@ export function mapVendor(raw: unknown): Vendor {
|
|||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item) || undefined,
|
||||
companyId: readCompanyId(item),
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||
notes: readString(item, "notes", "Notes"),
|
||||
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||
|
|
@ -88,25 +131,29 @@ export function mapVendor(raw: unknown): Vendor {
|
|||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||
const item = asRecord(vendor);
|
||||
const companyName = readString(item, "companyName", "CompanyName");
|
||||
const payload: Record<string, unknown> = {
|
||||
companyName: item.companyName ?? "",
|
||||
contactName: item.contactName ?? "",
|
||||
email: item.email ?? "",
|
||||
phone: item.phone ?? "",
|
||||
companyPhone: item.companyPhone ?? "",
|
||||
googleMapsUrl: item.googleMapsUrl ?? "",
|
||||
notes: item.notes ?? "",
|
||||
address: item.address ?? "",
|
||||
city: item.city ?? "",
|
||||
state: item.state ?? "",
|
||||
zip: item.zip ?? "",
|
||||
tradeSpecialties: item.tradeSpecialties ?? "",
|
||||
isActive: item.isActive ?? true,
|
||||
companyId: readCompanyId(item),
|
||||
companyName,
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||
notes: readString(item, "notes", "Notes"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||
};
|
||||
const id = item.id ?? item.Id;
|
||||
if (typeof id === "string" || typeof id === "number") {
|
||||
|
|
@ -118,8 +165,9 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
|||
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
companyId: readCompanyId(item),
|
||||
name: readString(item, "name", "Name"),
|
||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||
email: readString(item, "email", "Email"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
|
|
@ -182,3 +230,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
|||
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 {
|
||||
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||
type VendorPreferredContact,
|
||||
} from "@/domain/vendors/types/vendor";
|
||||
|
||||
const httpsUrl = z
|
||||
.string()
|
||||
|
|
@ -8,12 +12,19 @@ const httpsUrl = z
|
|||
"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({
|
||||
companyName: z.string().min(1, "Company is required"),
|
||||
contactName: z.string().min(1, "Technician name is required"),
|
||||
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
||||
phone: z.string().optional(),
|
||||
companyPhone: z.string().optional(),
|
||||
phone: canonicalPhone.optional(),
|
||||
companyPhone: canonicalPhone.optional(),
|
||||
googleMapsUrl: httpsUrl.optional(),
|
||||
notes: z.string().optional(),
|
||||
totalJobs: z.number().optional(),
|
||||
|
|
@ -23,6 +34,10 @@ export const vendorSchema = z.object({
|
|||
zip: z.string().optional(),
|
||||
tradeSpecialties: z.string().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>;
|
||||
|
|
|
|||
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 {
|
||||
id: string | number;
|
||||
companyId: string | number | null;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
|
|
@ -12,10 +17,12 @@ export interface VendorListItem {
|
|||
state: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
preferredContact: VendorPreferredContact;
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
id?: string | number;
|
||||
companyId: string | number | null;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
|
|
@ -30,6 +37,7 @@ export interface Vendor {
|
|||
zip: string;
|
||||
tradeSpecialties: string;
|
||||
isActive: boolean;
|
||||
preferredContact: VendorPreferredContact;
|
||||
}
|
||||
|
||||
export interface VendorsListParams {
|
||||
|
|
@ -51,6 +59,7 @@ export interface VendorsListResult {
|
|||
}
|
||||
|
||||
export interface VendorFacetCompany {
|
||||
companyId: string | number | null;
|
||||
name: string;
|
||||
companyPhone: string;
|
||||
email: string;
|
||||
|
|
@ -85,3 +94,18 @@ export interface VendorPortalToken {
|
|||
expiresAt: 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,
|
||||
portalToken: (vendorId: string | number) =>
|
||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||
deactivationImpact: (vendorId: string | number) =>
|
||||
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
|
||||
},
|
||||
calendar: {
|
||||
all: ["calendar"] as const,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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 { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
|
|
@ -10,6 +10,7 @@ import { renderWithProviders } from "@/test/test-utils";
|
|||
|
||||
const companies: VendorFacetCompany[] = [
|
||||
{
|
||||
companyId: "co-1",
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "(314) 555-0198",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
|
|
@ -36,6 +37,8 @@ const emptyForm: VendorFormValues = {
|
|||
zip: "",
|
||||
tradeSpecialties: "",
|
||||
isActive: true,
|
||||
companyId: null,
|
||||
preferredContact: "Phone",
|
||||
};
|
||||
|
||||
function Harness({
|
||||
|
|
@ -52,6 +55,7 @@ function Harness({
|
|||
|
||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||
if (!company) return;
|
||||
setValue("companyId", company.companyId);
|
||||
setValue("companyPhone", company.companyPhone);
|
||||
setValue("email", company.email);
|
||||
setValue("address", company.address);
|
||||
|
|
@ -61,6 +65,8 @@ function Harness({
|
|||
setValue("googleMapsUrl", company.googleMapsUrl);
|
||||
};
|
||||
|
||||
const companyId = useWatch({ control, name: "companyId" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<VendorFormFields
|
||||
|
|
@ -70,6 +76,7 @@ function Harness({
|
|||
tradeOptions={[]}
|
||||
onCompanySelected={onCompanySelected}
|
||||
/>
|
||||
<output data-testid="company-id">{String(companyId ?? "")}</output>
|
||||
<button type="button" onClick={() => reset(resetTarget)}>
|
||||
reset-form
|
||||
</button>
|
||||
|
|
@ -78,6 +85,7 @@ function Harness({
|
|||
}
|
||||
|
||||
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
||||
const companyIdOutput = () => screen.getByTestId("company-id");
|
||||
|
||||
describe("VendorFormFields company autocomplete state", () => {
|
||||
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
||||
|
|
@ -148,4 +156,50 @@ describe("VendorFormFields company autocomplete state", () => {
|
|||
expect(companyField()).toHaveValue("Gateway Plumbing");
|
||||
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
||||
});
|
||||
|
||||
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
|
||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
await userEvent.type(companyField(), "Gate");
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
|
||||
expect(companyIdOutput()).toHaveTextContent("co-1");
|
||||
|
||||
await userEvent.type(companyField(), "!");
|
||||
expect(companyIdOutput()).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("keeps companyId null for free-typed company text that matches no option", async () => {
|
||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
await userEvent.type(companyField(), "Independent Vendor LLC");
|
||||
expect(companyIdOutput()).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
|
||||
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
|
||||
await userEvent.type(phone, "3145550198");
|
||||
expect(phone).toHaveValue("(314) 555-0198");
|
||||
|
||||
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
|
||||
await userEvent.type(companyPhone, "3145550100");
|
||||
expect(companyPhone).toHaveValue("(314) 555-0100");
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
const useVendorsList = vi.fn();
|
||||
const useVendorDetail = vi.fn();
|
||||
const useVendorDeactivationImpact = 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-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", () => ({
|
||||
useDeleteVendor: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
|
|
@ -21,6 +31,7 @@ vi.mock("@/hooks/use-debounce", () => ({
|
|||
|
||||
const activeVendor = {
|
||||
id: 1,
|
||||
companyId: "co-1",
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
email: "dispatch@gatewayplumbing.com",
|
||||
|
|
@ -29,6 +40,7 @@ const activeVendor = {
|
|||
state: "MO",
|
||||
tradeSpecialties: "Backflow Preventers",
|
||||
isActive: true,
|
||||
preferredContact: "Email",
|
||||
};
|
||||
|
||||
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", () => {
|
||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||
setupDefaults();
|
||||
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);
|
||||
|
|
@ -67,6 +89,7 @@ describe("VendorsListPage", () => {
|
|||
});
|
||||
|
||||
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||
setupDefaults();
|
||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||
...activeVendor,
|
||||
id: 100 + index,
|
||||
|
|
@ -83,4 +106,58 @@ describe("VendorsListPage", () => {
|
|||
expect(screen.getByText(/25 active technicians/)).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 apiPost = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||
apiDelete: vi.fn(),
|
||||
apiDelete: (...args: unknown[]) => apiDelete(...args),
|
||||
}));
|
||||
|
||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||
|
|
@ -19,15 +20,18 @@ describe("vendorsApi.getList", () => {
|
|||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPut.mockReset();
|
||||
apiDelete.mockReset();
|
||||
apiGet.mockResolvedValue({
|
||||
Data: [
|
||||
{
|
||||
Id: 7,
|
||||
CompanyId: "co-1",
|
||||
CompanyName: "Gateway Plumbing",
|
||||
ContactName: "Adam Whyte",
|
||||
City: "St. Louis",
|
||||
State: "MO",
|
||||
IsActive: false,
|
||||
PreferredContact: "Email",
|
||||
},
|
||||
],
|
||||
TotalCount: 1,
|
||||
|
|
@ -40,7 +44,13 @@ describe("vendorsApi.getList", () => {
|
|||
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 });
|
||||
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 () => {
|
||||
|
|
@ -91,7 +101,7 @@ describe("vendorsApi.getList", () => {
|
|||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} });
|
||||
expect(facets.companies[0]).toMatchObject({
|
||||
name: "Gateway Plumbing",
|
||||
companyPhone: "314-555-0100",
|
||||
companyPhone: "(314) 555-0100",
|
||||
googleMapsUrl: "https://maps.google.com/gateway",
|
||||
});
|
||||
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 () => {
|
||||
const values = {
|
||||
companyId: "co-1",
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
phone: "314-555-0198",
|
||||
|
|
@ -130,6 +141,7 @@ describe("vendorsApi.getList", () => {
|
|||
notes: "Preferred after-hours vendor",
|
||||
totalJobs: 72,
|
||||
isActive: true,
|
||||
preferredContact: "Text",
|
||||
};
|
||||
apiPost.mockResolvedValueOnce({ data: values });
|
||||
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
||||
|
|
@ -139,7 +151,14 @@ describe("vendorsApi.getList", () => {
|
|||
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
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(
|
||||
`${API_PATHS.rest.vendors}/7`,
|
||||
|
|
@ -148,4 +167,67 @@ describe("vendorsApi.getList", () => {
|
|||
expect(apiPost.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",
|
||||
contactName: "Adam Whyte",
|
||||
email: "dispatch@gateway.test",
|
||||
phone: "(314) 555-0198",
|
||||
googleMapsUrl: "https://maps.google.com/gateway",
|
||||
};
|
||||
|
||||
|
|
@ -33,4 +34,28 @@ describe("vendorSchema", () => {
|
|||
}).success,
|
||||
).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