fix(work-orders): select technicians by stable id [SH-193]

Keep selectedKey as explicit dialog state (SiteDialog pattern) so same-name drafts do not collide with existing vendors, and cover the regression with interaction tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-11 10:48:07 -03:00
parent f01a79d0b5
commit bd93fc33dc
8 changed files with 290 additions and 14 deletions

View file

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

View file

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

View file

@ -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
<VendorDialog
open={open}
onOpenChange={setOpen}
vendorId={vendorId}
company={company}
tech={tech}
techPhone={techPhone}

View file

@ -15,6 +15,7 @@ import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
@ -23,6 +24,7 @@ export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => 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}

View file

@ -129,6 +129,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}

View file

@ -56,6 +56,7 @@ export function WoTableRowServiceCells({
onClick={(e) => e.stopPropagation()}
>
<VendorCell
vendorId={row.vendorId}
company={row.company}
tech={row.tech}
techPhone={row.techPhone}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
const vendors: VendorDropdownItem[] = [
{
id: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
},
{
id: 11,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
},
];
describe("resolveTechnicianKey", () => {
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");
});
});

View file

@ -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(
<VendorDialog
open={true}
onOpenChange={onOpenChange}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
techPhone="(314) 555-0100"
vendors={[existingVendor]}
onSave={onSave}
/>,
);
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(
<VendorDialog
open={true}
onOpenChange={vi.fn()}
vendorId={11}
company="Gateway Plumbing"
tech="Adam Whyte"
techPhone=""
vendors={[
existingVendor,
{
...existingVendor,
id: 11,
contactName: "Adam Whyte",
},
]}
onSave={onSave}
/>,
);
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",
}),
);
});
});
});