diff --git a/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts new file mode 100644 index 00000000..95a75a3b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts @@ -0,0 +1,36 @@ +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +/** + * Board rows often have tech/company but no vendorId — resolve via vendors list. + * Prefers a stable vendor id over name+company matching. + */ +export function resolveTechnicianKey( + vendorId: string | number, + tech: string, + company: string, + vendors: VendorDropdownItem[], +): string { + const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : ""; + if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) { + return idStr; + } + if (idStr && vendors.length === 0) { + return idStr; + } + + const name = tech.trim(); + if (!name) { + return idStr; + } + + const byNameAndCompany = + vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ?? + vendors.find((vendor) => vendor.contactName === name); + + if (byNameAndCompany) { + return String(byNameAndCompany.id); + } + + // Orphan tech string used as option key when no vendor row matches (legacy board cells). + return idStr || name; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 4af98e27..502714c6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -65,7 +65,7 @@ export function useVendorDialogSave({ onSave({ vendorId: saveVendor ? String(saveVendor.id) : "", company: resolvedCompany || anchorCompanyName, - tech, + tech: saveVendor?.contactName ?? tech, techPhone: phone, }); onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx index a627d8da..8cd6dca2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx @@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron"; import { VendorDialog, type VendorPatch } from "./vendor-dialog"; type VendorCellProps = { + vendorId?: string | number; company: string; tech: string; techPhone?: string; @@ -13,7 +14,15 @@ type VendorCellProps = { onSave: (patch: VendorPatch) => void; }; -export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) { +export function VendorCell({ + vendorId = "", + company, + tech, + techPhone, + vendors, + q, + onSave, +}: VendorCellProps) { const [open, setOpen] = useState(false); const hasAny = !!(tech || company); @@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven void; + vendorId?: string | number; company: string; tech: string; techPhone?: string; @@ -33,6 +35,7 @@ type VendorDialogProps = { export function VendorDialog({ open, onOpenChange, + vendorId = "", company, tech, techPhone = "", @@ -40,6 +43,9 @@ export function VendorDialog({ onSave, }: VendorDialogProps) { const createVendor = useCreateVendor(); + const [selectedKey, setSelectedKey] = useState(() => + resolveTechnicianKey(vendorId, tech, company, vendors), + ); const [t, setT] = useState(tech); const [phone, setPhone] = useState(techPhone); const [notes, setNotes] = useState(""); @@ -59,6 +65,8 @@ export function VendorDialog({ useEffect(() => { if (open) { + const key = resolveTechnicianKey(vendorId, tech, company, vendors); + setSelectedKey(key); setT(tech); setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company))); setNotes(""); @@ -67,15 +75,12 @@ export function VendorDialog({ setSaveError(null); } // Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts. - }, [open, tech, techPhone, vendors, company]); + }, [open, tech, techPhone, vendors, company, vendorId]); - const selected = - technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ?? - technicianOptions.find((o) => o.name === t || o.key === t); - const selectedKey = selected?.key ?? ""; - const resolvedCompany = t ? (selected?.company ?? company) : company; + const selected = technicianOptions.find((option) => option.key === selectedKey); + const resolvedCompany = selectedKey ? (selected?.company ?? company) : company; const selectedVendor = - selected?.vendor ?? allVendors.find((v) => v.companyName === resolvedCompany); + selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey); const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({ company, @@ -86,9 +91,9 @@ export function VendorDialog({ const handlePick = (key: string) => { const option = technicianOptions.find((o) => o.key === key); - const name = option?.name ?? key; - setT(name); - setPhone(phoneForOption(name, technicianOptions)); + setSelectedKey(key); + setT(option?.name ?? key); + setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); }; const handleTechnicianCreated = ( @@ -98,6 +103,7 @@ export function VendorDialog({ ) => { setAddedVendors((current) => [...current, created]); setPendingCreates((current) => ({ ...current, [String(created.id)]: payload })); + setSelectedKey(String(created.id)); setT(created.contactName); setPhone(createdPhone); }; @@ -109,7 +115,7 @@ export function VendorDialog({ selectedVendor, resolvedCompany, anchorCompanyName, - tech: t, + tech: selected?.name ?? t, phone, createVendor, onSave, @@ -144,7 +150,7 @@ export function VendorDialog({ technicianOptions={technicianOptions} anchorCompanyName={anchorCompanyName} companyVendorForAdd={companyVendor ?? undefined} - tech={t} + tech={selected?.name ?? t} phone={phone} notes={notes} resolvedCompany={resolvedCompany} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index a9052764..5169158f 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -129,6 +129,7 @@ export function WoTableInlineRowCells({ e.stopPropagation()} > { + it("prefers a stable vendorId when it exists in the list", () => { + expect(resolveTechnicianKey(11, "Jordan Lee", "Gateway Plumbing", vendors)).toBe("11"); + }); + + it("falls back to name+company when vendorId is missing", () => { + expect(resolveTechnicianKey("", "Jordan Lee", "Gateway Plumbing", vendors)).toBe("10"); + }); + + it("keeps orphan tech string as key when no vendor matches", () => { + expect(resolveTechnicianKey("", "Unknown Tech", "Gateway Plumbing", vendors)).toBe( + "Unknown Tech", + ); + }); + + it("keeps vendorId when the vendors list is still empty", () => { + expect(resolveTechnicianKey(42, "Jordan Lee", "Gateway Plumbing", [])).toBe("42"); + }); +}); diff --git a/src/test/app/(protected)/workorders/vendor-dialog-stable-identity.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-stable-identity.test.tsx new file mode 100644 index 00000000..c987eab5 --- /dev/null +++ b/src/test/app/(protected)/workorders/vendor-dialog-stable-identity.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; +import type { Vendor } from "@/domain/vendors/types/vendor"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { renderWithProviders } from "@/test/test-utils"; + +const mutateAsync = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ + useCreateVendor: () => ({ + mutateAsync, + isPending: false, + }), +})); + +const existingVendor: VendorDropdownItem = { + id: 10, + companyName: "Gateway Plumbing", + contactName: "Jordan Lee", + tradeSpecialties: "Plumbing", + address: "1 Main St", + distanceMiles: 4.2, + email: "dispatch@gateway.test", +}; + +const createdVendor: Vendor = { + id: 99, + companyId: 10, + companyName: "Gateway Plumbing", + contactName: "Jordan Lee", + email: "dispatch@gateway.test", + phone: "(314) 555-9999", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + address: "1 Main St", + city: "", + state: "", + zip: "", + tradeSpecialties: "Plumbing", + isActive: true, + preferredContact: "Phone", +}; + +describe("VendorDialog stable technician identity", () => { + beforeEach(() => { + mutateAsync.mockReset(); + mutateAsync.mockResolvedValue(createdVendor); + }); + + it("keeps draft identity on same-name collision and posts live phone on save", async () => { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i })); + fireEvent.change(screen.getByPlaceholderText("Full name"), { + target: { value: "Jordan Lee" }, + }); + fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), { + target: { value: "3145550199" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^add$/i })); + + // Draft stays staged: add form collapses and select still shows the colliding name. + expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy(); + expect(screen.getByText("Jordan Lee · Gateway Plumbing")).toBeTruthy(); + + fireEvent.change(screen.getByDisplayValue("3145550199"), { + target: { value: "(314) 555-9999" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledTimes(1); + }); + + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + contactName: "Jordan Lee", + companyName: "Gateway Plumbing", + phone: "(314) 555-9999", + }), + ); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + vendorId: "99", + tech: "Jordan Lee", + company: "Gateway Plumbing", + techPhone: "(314) 555-9999", + }), + ); + }); + + // Must not persist the pre-existing colliding vendor id. + expect(onSave.mock.calls[0]?.[0].vendorId).not.toBe("10"); + }); + + it("does not clear staged draft when technicianOptions rebuild after add", async () => { + const onSave = vi.fn(); + mutateAsync.mockResolvedValue({ + ...createdVendor, + id: 88, + contactName: "Casey New", + phone: "3145550188", + }); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i })); + fireEvent.change(screen.getByPlaceholderText("Full name"), { + target: { value: "Casey New" }, + }); + fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), { + target: { value: "3145550188" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^add$/i })); + + expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledTimes(1); + }); + + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + contactName: "Casey New", + phone: "3145550188", + }), + ); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + vendorId: "88", + tech: "Casey New", + }), + ); + }); + }); +});