diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e7c42c26..a923336c 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -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 }) => { diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 3ec17649..9b2f25eb 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -110,6 +110,7 @@ export const API_PATHS = { delete: "Vendor/Delete", dropdown: "Vendor/Dropdown", facets: "Vendor/facets", + deactivationImpact: "deactivation-impact", }, calendar: { getEventsForCalendar: "Calendar/GetEventsForCalendar", diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 69739ea5..9f74d2af 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -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({ 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"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index a20abdcb..18d72a6d 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -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 = { + 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({ 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({ + @@ -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"} diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx index f88d0505..06fe52b3 100644 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -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 = { + Phone: "Phone", + Email: "Email", + Text: "Text", +}; + export function VendorFormFields({ control, errors, @@ -72,8 +92,46 @@ export function VendorFormFields({ } + render={({ field }) => ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.phone)} + helperText={errors.phone?.message} + fullWidth + /> + )} /> + + + Preferred Contact + + ( + { + if (value) field.onChange(value); + }} + aria-label="Preferred contact" + > + {VENDOR_PREFERRED_CONTACT_VALUES.map((option) => ( + + {PREFERRED_CONTACT_LABELS[option]} + + ))} + + )} + /> + @@ -88,44 +146,54 @@ export function VendorFormFields({ control={control} name="companyName" render={({ field }) => ( - (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); + ( + (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) => ( - { + 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) => ( + + )} /> )} /> @@ -134,7 +202,19 @@ export function VendorFormFields({ } + render={({ field }) => ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message} + fullWidth + /> + )} /> ({ 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() { - diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 62430d7d..fa905238 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -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(null); + const [deleteError, setDeleteError] = useState(null); const [detailTarget, setDetailTarget] = useState(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 ( - + openDetail(row, "view")} + onKeyDown={handleRowKeyDown(row)} + > {row.companyName || "—"} @@ -336,7 +384,11 @@ export default function VendorsListPage() { {row.phone ? ( - + event.stopPropagation()} + > {row.phone} ) : ( @@ -345,7 +397,11 @@ export default function VendorsListPage() { {row.email ? ( - + event.stopPropagation()} + > {row.email} ) : ( @@ -365,7 +421,7 @@ export default function VendorsListPage() { openDetail(row, "view")} + onClick={stopAnd(() => openDetail(row, "view"))} > @@ -381,7 +437,7 @@ export default function VendorsListPage() { openDetail(row, "edit")} + onClick={stopAnd(() => openDetail(row, "edit"))} > @@ -391,7 +447,7 @@ export default function VendorsListPage() { setDeleteTarget(row)} + onClick={stopAnd(() => openDeactivation(row))} > @@ -458,23 +514,92 @@ export default function VendorsListPage() { tradeOptions={tradeOptions} /> - setDeleteTarget(null)}> + Deactivate Vendor - - Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit - history will be preserved. - + + + Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit + history will be preserved. + + + {isLoadingImpact && ( + + + + Checking linked work orders... + + + )} + + {impactError && !isLoadingImpact && ( + + Could not verify linked work orders. Close and try again. + + )} + + {deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && ( + + This vendor cannot be deactivated because it still has open work orders. + + )} + + {deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && ( + + + Open work orders ({deactivationImpact.openWorkOrders.length}) + + + {deactivationImpact.openWorkOrders.map((wo) => ( + + {wo.scheduledDate} + + ) : undefined + } + > + + + {wo.workOrderNumber + ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` + : wo.workOrderTitle || `Work order ${wo.workOrderId}`} + + {wo.status && ( + + {wo.status} + + )} + + + ))} + + + )} + + {deleteError && {deleteError}} + - diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index f375e86c..4f001f04 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -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 => { + const data = await apiGet( + `${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`, + ); + return mapVendorDeactivationImpact(handleApiResponse(data)); + }, + getPortalToken: async (id: string | number): Promise => { const data = await apiGet(`${API_PATHS.rest.vendors}/${id}/portal-token`); return mapVendorPortalToken(handleApiResponse(data)); diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 569fba52..5cfec245 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -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(["Phone", "Email", "Text"]); + function asRecord(raw: unknown): Record { return typeof raw === "object" && raw !== null ? (raw as Record) : {}; } @@ -39,6 +44,41 @@ function readId(record: Record): string | number { return ""; } +function readCompanyId(record: Record): 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, + ...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, ...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 { const item = asRecord(vendor); + const companyName = readString(item, "companyName", "CompanyName"); const payload: Record = { - 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 { 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) : [], + }; +} diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 3425730f..52d8edf3 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -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; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index ffc67faa..6c67847c 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -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[]; +} diff --git a/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts new file mode 100644 index 00000000..7c59c970 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts @@ -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 { + 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; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index bc65dfe2..4548791b 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -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, diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx index eb197115..24d4c790 100644 --- a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx @@ -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 ( <> + {String(companyId ?? "")} @@ -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(, { + 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(, { + 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(, { + 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(, { + 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(); + }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 1db80de8..995dd5dc 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -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(, { 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(, { 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(); + }); }); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 554ffcc6..e368c3dc 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -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"); + }); }); diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index 2c53d784..aee36aaa 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -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); + }); });