Merge pull request #50 from Sea-Haven-Industries/codex/vendor-core-completion-frontend

Complete vendor directory and work-order vendor editing
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:24:29 -03:00 • committed by GitHub
commit afb8ce67aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1269 additions and 112 deletions

View file

@ -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.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 }) => {

View file

@ -0,0 +1,90 @@
import { expect, test, type Route } from "@playwright/test";
const TEST_USER = {
token: "work-order-vendor-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
test("edits only the vendor assigned to the current work order", async ({ page }) => {
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
Id: 501,
InternalWONumber: "WO-501",
WorkerOrderNumber: "CUSTOMER-501",
WorkerOrderTitle: "Emergency boiler repair",
Status: "Dispatched",
Comments: [],
AuditLog: [],
Dispatches: [
{
Id: 801,
DispatchNumber: "D-801",
VendorId: 7,
VendorName: "Gateway Plumbing",
VendorEmail: "dispatch@gateway.test",
Status: "Sent",
DispatchedAt: "2026-07-23T12:00:00Z",
},
],
}),
);
await page.route("**/api/vendors/7/work-order-update", async (route) => {
updateRequest = {
url: route.request().url(),
body: route.request().postDataJSON() as Record<string, unknown>,
};
await fulfillJson(route, { data: { id: 7 } });
});
await page.route("**/api/vendors/7", (route) =>
fulfillJson(route, {
data: {
Id: 7,
CompanyName: "Gateway Plumbing",
ContactName: "Adam Whyte",
PreferredContact: "Email",
Phone: "3145550198",
Email: "dispatch@gateway.test",
Notes: "Preferred after-hours vendor",
IsActive: true,
},
}),
);
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
await page.goto("/workorders/501");
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
await page.getByRole("button", { name: "Edit vendor" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
await page.getByLabel("Technician name").fill("Taylor Tech");
await page.getByLabel("Preferred contact").click();
await page.getByRole("option", { name: "Text" }).click();
await page.getByLabel("Phone").fill("3145550111");
await page.getByLabel("Email").fill("taylor@example.com");
await page.getByLabel("Notes").fill("Text before arrival");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
expect(updateRequest?.body).toEqual({
workOrderId: 501,
contactName: "Taylor Tech",
preferredContact: "Text",
phone: "(314) 555-0111",
email: "taylor@example.com",
notes: "Text before arrival",
});
});

View file

@ -110,6 +110,7 @@ export const API_PATHS = {
delete: "Vendor/Delete",
dropdown: "Vendor/Dropdown",
facets: "Vendor/facets",
deactivationImpact: "deactivation-impact",
},
calendar: {
getEventsForCalendar: "Calendar/GetEventsForCalendar",

View file

@ -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>

View file

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

View file

@ -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={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={field.value ?? ""}
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
fullWidth
/>
)}
/>
<Controller
control={control}

View file

@ -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>

View file

@ -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 &quot;{deleteTarget?.companyName}&quot;? Existing work-order and audit
history will be preserved.
</DialogContentText>
<Stack spacing={2}>
<DialogContentText>
Deactivate &quot;{deleteTarget?.companyName}&quot;? Existing work-order and audit
history will be preserved.
</DialogContentText>
{isLoadingImpact && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Checking linked work orders...
</Typography>
</Stack>
)}
{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>

View file

@ -10,6 +10,10 @@ import {
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
Menu,
MenuItem,
@ -36,8 +40,11 @@ import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useUpdateWorkOrderVendor,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
const STATUS_COLORS: Record<
@ -53,6 +60,31 @@ const STATUS_COLORS: Record<
type ActivityTab = "customer" | "internal" | "audit";
interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
preferredContact: VendorPreferredContact;
phone: string;
email: string;
notes: string;
}
function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
@ -167,6 +199,7 @@ export default function WorkOrderViewPage() {
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
@ -174,6 +207,9 @@ export default function WorkOrderViewPage() {
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
@ -200,6 +236,74 @@ export default function WorkOrderViewPage() {
);
};
const openVendorEditor = async (
event: React.MouseEvent,
vendorId: string | number,
vendorName: string,
) => {
event.stopPropagation();
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
preferredContact: "Phone",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
preferredContact: vendor.preferredContact,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
preferredContact: vendorEditor.preferredContact,
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
void refetch();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
@ -426,6 +530,7 @@ export default function WorkOrderViewPage() {
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -465,6 +570,17 @@ export default function WorkOrderViewPage() {
"—"
)}
</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
);
})}
@ -550,6 +666,115 @@ export default function WorkOrderViewPage() {
onUpdated={() => void refetch()}
/>
)}
<Dialog
open={vendorEditor !== null}
onClose={() => {
if (!vendorMutation.isPending) setVendorEditor(null);
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
{vendorEditor?.vendorName}
</Typography>
{vendorEditorError && <Alert severity="error">{vendorEditorError}</Alert>}
{vendorEditorLoading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />
</Box>
) : (
<>
<TextField
label="Technician name"
value={vendorEditor?.contactName ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
select
label="Preferred contact"
value={vendorEditor?.preferredContact ?? "Phone"}
onChange={(event) =>
setVendorEditor((current) =>
current
? {
...current,
preferredContact: event.target.value as VendorPreferredContact,
}
: current,
)
}
>
<MenuItem value="Phone">Phone</MenuItem>
<MenuItem value="Email">Email</MenuItem>
<MenuItem value="Text">Text</MenuItem>
</TextField>
<TextField
label="Phone"
value={vendorEditor?.phone ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current
? { ...current, phone: formatPhoneMask(event.target.value) }
: current,
)
}
error={Boolean(
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
)}
helperText="Use (XXX) XXX-XXXX"
/>
<TextField
label="Email"
type="email"
value={vendorEditor?.email ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={vendorEditor?.notes ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={saveVendorEditor}
disabled={
!vendorEditor ||
vendorEditorLoading ||
vendorMutation.isPending ||
!isVendorEditorValid(vendorEditor)
}
>
{vendorMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

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

View file

@ -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,51 @@ 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 readNumericCompanyId(record: Record<string, unknown>): number | null {
const value = readCompanyId(record);
if (typeof value === "number") return value;
if (typeof value !== "string" || value.trim() === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 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 original = String(value ?? "");
let digits = original.replace(/\D+/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
digits = digits.slice(1);
}
if (digits.length !== 10) return original;
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,6 +101,7 @@ 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"),
@ -63,6 +114,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,6 +126,7 @@ 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"),
@ -88,25 +141,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: readNumericCompanyId(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,6 +175,7 @@ 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"),
email: readString(item, "email", "Email"),
@ -182,3 +240,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) : [],
};
}

View file

@ -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,18 @@ const httpsUrl = z
"Google Maps URL must use HTTPS",
);
const northAmericanPhone = z.string().refine((value) => {
if (value === "") return true;
const digits = value.replace(/\D+/g, "");
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
}, "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: northAmericanPhone.optional(),
companyPhone: northAmericanPhone.optional(),
googleMapsUrl: httpsUrl.optional(),
notes: z.string().optional(),
totalJobs: z.number().optional(),
@ -23,6 +33,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>;

View file

@ -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[];
}

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

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
@ -37,6 +37,7 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
WorkOrderVendorUpdatePayload,
VendorDropdownItem,
WorkOrderComment,
WorkOrderDetail,
@ -294,6 +295,13 @@ export const workOrdersApi = {
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
},
updateVendorFromWorkOrder: async (
vendorId: string | number,
payload: WorkOrderVendorUpdatePayload,
): Promise<void> => {
await apiPut<unknown>(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload);
},
addDispatchComment: async (
dispatchId: string | number,
text: string,

View file

@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
return {
id: readId(item),
vendorId: readId({ id: item.vendorId ?? item.VendorId }),
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
vendorName: readString(item, "vendorName", "VendorName"),
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
status: readString(item, "status", "Status"),
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),

View file

@ -1,13 +1,24 @@
export interface WorkOrderDispatchSummary {
id: string | number;
vendorId: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export interface WorkOrderVendorUpdatePayload {
workOrderId: number;
contactName?: string;
preferredContact: "Phone" | "Email" | "Text";
phone?: string;
email?: string;
notes?: string;
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;

View file

@ -7,6 +7,7 @@ export type {
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
WorkOrderVendorUpdatePayload,
} from "./dispatch";
export type {

View file

@ -11,6 +11,7 @@ import type {
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderVendorUpdatePayload,
} from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput {
userName: string;
}
interface UpdateWorkOrderVendorInput {
vendorId: string | number;
payload: WorkOrderVendorUpdatePayload;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
@ -89,6 +95,26 @@ export function useUpdateWorkOrder(
});
}
export function useUpdateWorkOrderVendor(
workOrderId: string | number,
): UseMutationResult<void, Error, UpdateWorkOrderVendorInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ vendorId, payload }) =>
workOrdersApi.updateVendorFromWorkOrder(vendorId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor contact updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update vendor contact");
},
});
}
export function useAddWorkOrderComment(
workOrderId: string | number,
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {

View file

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

View file

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

View file

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

View file

@ -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 () => {
@ -116,9 +126,10 @@ describe("vendorsApi.getList", () => {
it("sends all editable prototype fields when creating and updating", async () => {
const values = {
companyId: "42",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
phone: "314-555-0198",
phone: "+1 314-555-0198",
companyPhone: "314-555-0100",
email: "dispatch@gateway.test",
address: "1 Market St",
@ -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: 42,
preferredContact: "Text",
phone: "(314) 555-0198",
companyPhone: "(314) 555-0100",
}),
);
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
@ -148,4 +167,103 @@ 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("does not send a non-numeric company id to the integer backend contract", async () => {
apiPost.mockResolvedValueOnce({ data: {} });
await vendorsApi.create({
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
});
expect(apiPost).toHaveBeenCalledWith(
API_PATHS.rest.vendors,
expect.objectContaining({ companyId: null }),
);
});
it("preserves a legacy phone on read and unrelated write", async () => {
apiGet.mockResolvedValueOnce({
data: {
id: 7,
companyName: "Legacy Vendor",
contactName: "Taylor",
phone: "555-1234 ext 9",
},
});
apiPut.mockResolvedValueOnce({ data: {} });
const vendor = await vendorsApi.getById(7);
expect(vendor.phone).toBe("555-1234 ext 9");
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
expect(apiPut).toHaveBeenCalledWith(
`${API_PATHS.rest.vendors}/7`,
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
);
});
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");
});
});

View file

@ -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,29 @@ describe("vendorSchema", () => {
}).success,
).toBe(false);
});
it("accepts canonicalizable North American phones and rejects 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(true);
expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true);
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);
});
});

View file

@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
const apiGet = vi.fn();
const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiPost: vi.fn(),
apiDelete: vi.fn(),
apiPostNoContent: vi.fn(),
@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
describe("workOrdersApi.getById", () => {
beforeEach(() => {
apiGet.mockReset();
apiPut.mockReset();
apiGet.mockResolvedValue({
Id: 10,
title: "Test WO",
@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => {
});
});
it("updates only the assigned vendor through the work-order-scoped endpoint", async () => {
apiPut.mockResolvedValueOnce(undefined);
const payload = {
workOrderId: 10,
contactName: "Taylor Tech",
preferredContact: "Text" as const,
phone: "(314) 555-0199",
email: "taylor@example.com",
notes: "Text before arrival",
};
await workOrdersApi.updateVendorFromWorkOrder(7, payload);
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload);
});
it("calls GetWorkorderById with id query param, not path suffix", async () => {
await workOrdersApi.getById(10);

View file

@ -1,5 +1,26 @@
import { describe, expect, it } from "vitest";
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
import {
mapUpliftRequest,
mapWorkOrderDispatchSummary,
} from "@/domain/work-orders/mappers/work-order-mapper";
describe("mapWorkOrderDispatchSummary", () => {
it("preserves the assigned vendor identity needed by inline editing", () => {
expect(
mapWorkOrderDispatchSummary({
Id: 21,
VendorId: 7,
VendorName: "Gateway Plumbing",
VendorEmail: "dispatch@gateway.test",
}),
).toMatchObject({
id: 21,
vendorId: 7,
vendorName: "Gateway Plumbing",
vendorEmail: "dispatch@gateway.test",
});
});
});
describe("mapUpliftRequest", () => {
it("maps decided uplift fields from PascalCase payload", () => {