mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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:
parent
f01a79d0b5
commit
bd93fc33dc
8 changed files with 290 additions and 14 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function WoTableRowServiceCells({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VendorCell
|
||||
vendorId={row.vendorId}
|
||||
company={row.company}
|
||||
tech={row.tech}
|
||||
techPhone={row.techPhone}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue