diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e7c42c26..29df8921 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.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/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts new file mode 100644 index 00000000..95639b66 --- /dev/null +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -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 } | 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, + }; + 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", + }); +}); 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..a680d383 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/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index a282040c..610148a2 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -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("customer"); const [commentText, setCommentText] = useState(""); @@ -174,6 +207,9 @@ export default function WorkOrderViewPage() { const [dispatchDetailId, setDispatchDetailId] = useState(null); const [statusAnchor, setStatusAnchor] = useState(null); const [assignAnchor, setAssignAnchor] = useState(null); + const [vendorEditor, setVendorEditor] = useState(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 ( @@ -426,6 +530,7 @@ export default function WorkOrderViewPage() { Status Date Wait + Actions @@ -465,6 +570,17 @@ export default function WorkOrderViewPage() { "—" )} + + + ); })} @@ -550,6 +666,115 @@ export default function WorkOrderViewPage() { onUpdated={() => void refetch()} /> )} + { + if (!vendorMutation.isPending) setVendorEditor(null); + }} + fullWidth + maxWidth="sm" + > + Edit assigned vendor + + + + {vendorEditor?.vendorName} + + {vendorEditorError && {vendorEditorError}} + {vendorEditorLoading ? ( + + + + ) : ( + <> + + setVendorEditor((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + setVendorEditor((current) => + current + ? { + ...current, + preferredContact: event.target.value as VendorPreferredContact, + } + : current, + ) + } + > + Phone + Email + Text + + + 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" + /> + + setVendorEditor((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + setVendorEditor((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + + ); } 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..9273ef8b 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,51 @@ 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 readNumericCompanyId(record: Record): 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, + ...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, ...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 { 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: 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 { 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) : [], + }; +} diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 3425730f..7ce3be5e 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,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; 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/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index c17f207d..a3ede840 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -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(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload)); }, + updateVendorFromWorkOrder: async ( + vendorId: string | number, + payload: WorkOrderVendorUpdatePayload, + ): Promise => { + await apiPut(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload); + }, + addDispatchComment: async ( dispatchId: string | number, text: string, diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 687aa3b1..c424c26e 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -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"), diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts index d5df044c..fdb0175c 100644 --- a/src/domain/work-orders/types/dispatch.ts +++ b/src/domain/work-orders/types/dispatch.ts @@ -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; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index c1440bff..f266bc6a 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -7,6 +7,7 @@ export type { DispatchWorkOrderRef, UpdateDispatchPayload, WorkOrderDispatchSummary, + WorkOrderVendorUpdatePayload, } from "./dispatch"; export type { diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index ed877ab5..5c69e3a4 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -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 { + 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 { 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..b66ce1bf 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 () => { @@ -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"); + }); }); diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index 2c53d784..244c2b8e 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,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); + }); }); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 32040a19..f44e7b30 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -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); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index a9dfc9bf..632fa346 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -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", () => {