From ee95847354cbbf2812b02a98f6f7e71fb524cdc7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:16:44 -0300 Subject: [PATCH 1/4] fix(work-orders): register inline technicians through the additive vendor roster --- e2e/work-orders/inline-technician.spec.ts | 265 ++++++++++++++++++ .../table/cells/use-vendor-dialog-form.ts | 8 +- .../table/cells/use-vendor-dialog-save.ts | 48 +++- .../wizard/use-new-wo-wizard-controller.ts | 8 +- .../use-wizard-create-with-technicians.ts | 85 ++++++ .../wizard/wizard-step-vendor-time.tsx | 64 +---- .../wizard/wizard-vendor-company-fields.tsx | 134 +++++++++ .../wizard/wizard-vendor-new-technicians.tsx | 96 +++++++ .../use-add-technicians-to-company.ts | 96 +++++++ .../vendors/use-cases/use-create-vendor.ts | 21 -- .../work-orders/types/work-order-wizard.ts | 11 + .../vendor-dialog-map-and-scroll.test.tsx | 4 +- .../vendor-dialog-save-gate.test.tsx | 4 +- .../vendor-dialog-stable-identity.test.tsx | 60 ++-- .../wizard-vendor-inline-technician.test.tsx | 164 +++++++++++ .../add-technicians-to-company.test.ts | 91 ++++++ 16 files changed, 1024 insertions(+), 135 deletions(-) create mode 100644 e2e/work-orders/inline-technician.spec.ts create mode 100644 src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx create mode 100644 src/domain/vendors/use-cases/use-add-technicians-to-company.ts delete mode 100644 src/domain/vendors/use-cases/use-create-vendor.ts create mode 100644 src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx create mode 100644 src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts new file mode 100644 index 00000000..b2dee631 --- /dev/null +++ b/e2e/work-orders/inline-technician.spec.ts @@ -0,0 +1,265 @@ +import { expect, test, type Page, type Request, type Route } from "@playwright/test"; + +// SH-364 / SH-365 / SH-366: technicians registered inline from work-order entry points +// become real vendor records only on save, through the additive vendor-company roster +// PATCH (never a whole-vendor POST that restates or blanks company contact fields). + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wo-inline-technician-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +const VENDORS = [ + { + id: 41, + companyName: "Vinewood LLC", + contactName: "Adam Whyte", + tradeSpecialties: "HVAC", + address: "1 Market St", + }, +]; + +const ROSTER = { + companyId: 7, + rowVersion: "AAAAAAAAB9E=", + name: "Vinewood LLC", + companyPhone: "(314) 555-0100", + email: "", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + notes: "", + technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }], +}; + +const BOARD_ROW = { + id: 1, + woNumber: "WO-501", + workOrderType: 2, + siteCode: "STL-01", + locationName: "St. Louis HQ", + locationId: 10, + lifecycleStatusLabel: "Scheduled", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + vendorId: 41, + vendorName: "Vinewood LLC", + techName: "Adam Whyte", + title: "Quarterly HVAC PM", + rowVersion: "rv-1", +}; + +type Captured = { method: string; pathname: string; body: unknown }; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function bodyOf(request: Request): unknown { + try { + return request.postDataJSON(); + } catch { + return undefined; + } +} + +function rosterAfterAdd(body: { addTechnicians?: Array> }) { + const added = (body.addTechnicians ?? []).map((technician, index) => ({ + ...technician, + id: 42 + index, + })); + return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] }; +} + +async function mockApi(page: Page, writes: Captured[]) { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/")) return route.fallback(); + const method = request.method(); + if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) }); + + if (pathname.includes("/workorders/board/search")) { + return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }); + } + if (pathname === "/api/workorders/board" && method === "GET") { + return fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [BOARD_ROW], + }); + } + if (pathname === "/api/workorders/board" && method === "POST") { + return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } }); + } + if (pathname === "/api/workorders/1/board" && method === "PATCH") { + return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" }); + } + if (pathname === "/api/vendor-company-roster" && method === "GET") { + return fulfillJson(route, ROSTER); + } + if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") { + return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never)); + } + if (pathname.toLowerCase().includes("/vendors/dropdown")) { + return fulfillJson(route, { data: VENDORS }); + } + if (pathname.includes("/locations/sites")) { + return fulfillJson(route, { + data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }], + }); + } + if (pathname === "/api/locations/10") { + return fulfillJson(route, { + data: { + id: 10, + name: "St. Louis HQ", + siteCode: "STL-01", + contact: "Pat Site", + phone: "3145550111", + }, + }); + } + return fulfillJson(route, { data: [] }); + }); +} + +function vendorWrites(writes: Captured[]) { + return writes.filter( + (write) => + write.pathname.startsWith("/api/vendors") || + write.pathname.startsWith("/api/vendor-company-roster"), + ); +} + +async function openWizardVendorStep(page: Page) { + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /select site/i }).click(); + await page + .getByRole("button", { name: /STL-01/ }) + .last() + .click(); + await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /select company/i }).click(); + await page.getByRole("button", { name: "Vinewood LLC" }).last().click(); + await page.getByRole("button", { name: "+ Add technician" }).click(); + await page.getByLabel("New Technician 1 name").fill("Jordan Lee"); + await page.getByLabel("New Technician 1 phone").fill("3145550199"); +} + +test.describe("Inline technician registration", () => { + test("wizard persists the technician under the company on create and assigns it", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await openWizardVendorStep(page); + + // Staging a technician is local only. + expect(vendorWrites(writes)).toEqual([]); + + await page.getByRole("button", { name: "Create work order" }).click(); + + await expect + .poll(() => writes.find((write) => write.pathname === "/api/workorders/board")) + .toBeTruthy(); + expect(vendorWrites(writes)).toEqual([ + { + method: "PATCH", + pathname: "/api/vendor-company-roster/7", + body: { + rowVersion: "AAAAAAAAB9E=", + addTechnicians: [ + { + contactName: "Jordan Lee", + phone: "(314) 555-0199", + email: "", + tradeSpecialties: "HVAC", + isActive: true, + preferredContact: "Phone", + }, + ], + }, + }, + ]); + const create = writes.find((write) => write.pathname === "/api/workorders/board"); + expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 })); + }); + + test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await openWizardVendorStep(page); + + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden(); + + // Reopening starts clean: the staged technician was discarded, not persisted. + await page.getByRole("button", { name: "New WO" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /select site/i }).click(); + await page + .getByRole("button", { name: /STL-01/ }) + .last() + .click(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0); + + expect(writes).toEqual([]); + }); + + test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await page.goto("/workorders"); + + await page + .locator("#wo-row-1") + .getByRole("button", { name: /Adam Whyte/ }) + .click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: /\+ add technician/i }).click(); + await dialog.getByPlaceholder("Full name").fill("Jordan Lee"); + await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199"); + await dialog.getByRole("button", { name: /^add$/i }).click(); + await dialog.getByRole("button", { name: /^save$/i }).click(); + + await expect(dialog).toBeHidden(); + await expect + .poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board")) + .toBeTruthy(); + expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false); + const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7"); + expect(rosterPatch?.body).not.toHaveProperty("companyFields"); + const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board"); + expect(JSON.stringify(boardPatch?.body)).toContain("42"); + }); +}); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts index cf3624c9..c648e0b8 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; import { buildCompanyOptions, buildTechnicianOptions, @@ -37,7 +37,7 @@ export function useVendorDialogForm({ vendors, onSave, }: UseVendorDialogFormArgs) { - const createVendor = useCreateVendor(); + const addTechnicians = useAddTechniciansToCompany(); const [selectedCompany, setSelectedCompany] = useState(company); const [selectedKey, setSelectedKey] = useState(() => resolveTechnicianKey(vendorId, tech, company, vendors), @@ -119,7 +119,7 @@ export function useVendorDialogForm({ anchorCompanyName, tech: selected?.name ?? t, phone, - createVendor, + addTechnicians, onSave, onOpenChange, setSaveError, @@ -141,7 +141,7 @@ export function useVendorDialogForm({ companyVendor, saveError, canSubmit, - isPending: createVendor.isPending, + isPending: addTechnicians.isPending, handleSave, handleCompanyPick: (nextCompany: string) => { const selectedOption = technicianOptions.find((option) => option.key === selectedKey); 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 619bc6c9..55cf9ca1 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 @@ -1,9 +1,8 @@ import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor"; -import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; -import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; type UseVendorDialogSaveArgs = { allVendors: VendorDropdownItem[]; @@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = { anchorCompanyName: string; tech: string; phone: string; - createVendor: ReturnType; + addTechnicians: ReturnType; onSave: (patch: VendorPatch) => void; onOpenChange: (open: boolean) => void; setSaveError: (error: string | null) => void; canSubmit: boolean; }; +/** Persists a staged draft technician under its existing company (additive roster PATCH). */ +async function persistDraftTechnician({ + allVendors, + pendingPayload, + phone, + addTechnicians, +}: { + allVendors: VendorDropdownItem[]; + pendingPayload: VendorFormValues; + phone: string; + addTechnicians: ReturnType; +}): Promise { + const anchor = allVendors.find( + (vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName, + ); + if (!anchor) { + throw new Error("Select an existing vendor company before adding a technician."); + } + const [created] = await addTechnicians.mutateAsync({ + anchorVendorId: anchor.id, + technicians: [ + { + contactName: pendingPayload.contactName, + phone: phone.trim() || (pendingPayload.phone ?? ""), + tradeSpecialties: pendingPayload.tradeSpecialties, + }, + ], + }); + if (created == null) throw new Error("Failed to create technician"); + return created; +} + export function useVendorDialogSave({ allVendors, pendingCreates, @@ -30,7 +61,7 @@ export function useVendorDialogSave({ anchorCompanyName, tech, phone, - createVendor, + addTechnicians, onSave, onOpenChange, setSaveError, @@ -53,11 +84,12 @@ export function useVendorDialogSave({ if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) { try { - const created = await createVendor.mutateAsync({ - ...pendingPayload, - phone: phone.trim() || pendingPayload.phone, + saveVendor = await persistDraftTechnician({ + allVendors, + pendingPayload, + phone, + addTechnicians, }); - saveVendor = vendorToDropdownItem(created); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create technician"; setSaveError(message); diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 908c6d1a..c087c062 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -16,6 +16,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians"; type UseNewWoWizardControllerArgs = { open: boolean; @@ -57,7 +58,12 @@ export function useNewWoWizardController({ } }; - const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const createWorkOrder = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const createMutation = useWizardCreateWithTechnicians({ + vendors, + patchDraft: draftState.patchDraft, + createMutation: createWorkOrder, + }); const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = useWizardDuplicateActions({ open, diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts new file mode 100644 index 00000000..29d874e6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts @@ -0,0 +1,85 @@ +import { toast } from "react-toastify"; +import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { + WizardNewTechnician, + WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +type UseWizardCreateWithTechniciansArgs = { + vendors: VendorDropdownItem[]; + patchDraft: (patch: Partial) => void; + createMutation: { + mutate: (draft: WorkOrderWizardDraft) => void; + isPending: boolean; + }; +}; + +function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] { + return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== ""); +} + +/** Draft changes applied once inline technicians exist as real vendor records. */ +export function buildCreatedTechniciansPatch( + draft: WorkOrderWizardDraft, + company: string, + pending: WizardNewTechnician[], + created: VendorDropdownItem[], +): Partial { + const patch: Partial = { newTechnicians: [] }; + const [only] = created; + // Prototype parity: a single new technician is assigned when none was picked. + if (created.length === 1 && only != null && draft.vendorId === "") { + patch.vendorId = String(only.id); + patch.vendorName = company; + patch.techPhone = pending[0]?.phone.trim() || draft.techPhone; + } + return patch; +} + +/** + * SH-364: persists technicians registered inline on the Vendor & time step under the + * selected company, then creates the work order. Nothing is written before Create, so + * cancelling the wizard leaves no stray vendor records (SH-366). + */ +export function useWizardCreateWithTechnicians({ + vendors, + patchDraft, + createMutation, +}: UseWizardCreateWithTechniciansArgs) { + const addTechnicians = useAddTechniciansToCompany(); + + const mutate = (draft: WorkOrderWizardDraft) => { + const pending = pendingTechnicians(draft); + const company = (draft.vendorCompany ?? "").trim(); + if (pending.length === 0 || company === "") { + createMutation.mutate(draft); + return; + } + const anchor = vendors.find((vendor) => vendor.companyName === company); + if (!anchor) { + toast.error("Select an existing vendor company before adding technicians."); + return; + } + void (async () => { + try { + const created = await addTechnicians.mutateAsync({ + anchorVendorId: anchor.id, + technicians: pending.map((technician) => ({ + contactName: technician.name, + phone: technician.phone, + tradeSpecialties: anchor.tradeSpecialties, + })), + }); + const patch = buildCreatedTechniciansPatch(draft, company, pending, created); + // Clear the staged rows first so a failed WO create cannot re-create them on retry. + patchDraft(patch); + createMutation.mutate({ ...draft, ...patch }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to add technicians"); + } + })(); + }; + + return { mutate, isPending: createMutation.isPending || addTechnicians.isPending }; +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index 1fbd6142..24e78e03 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -1,4 +1,5 @@ import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; +import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields"; import { Text } from "@/components/ui/text"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { @@ -8,10 +9,7 @@ import { type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils"; -import { - formatVendorDistance, - formatVendorTechnicianLabel, -} from "@/domain/work-orders/utils/vendor-dropdown-label"; +import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label"; import { getClosabilityGaps, wizardDraftToClosabilityInput, @@ -30,7 +28,6 @@ import { ColorDot, Detail, WizLabel, - WizLabelRow, WizSubLabel, } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; @@ -201,57 +198,12 @@ export function WizardStepVendorTime({ return (
-
- onPatch({ vendorId: "", vendorName: "", techPhone: "" }) - : undefined - } - > - Technician - - - {(selectedVendor.contactName || selectedVendor.companyName) - .slice(0, 2) - .toUpperCase()} - - ) : ( - - ) - } - searchPlaceholder="Search technician…" - options={vendors.map((v) => ({ - key: String(v.id), - label: formatVendorTechnicianLabel(v), - }))} - onPick={(id) => { - const vendor = vendors.find((v) => String(v.id) === id); - onPatch({ - vendorId: id, - vendorName: vendor?.companyName ?? "", - techPhone: "", - }); - }} - /> -
+ {draft.vendorId !== "" && selectedVendor != null && ( ) => void; +}; + +const ICON_STYLE = { color: "var(--muted-foreground)" }; + +function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) { + return ( + + {(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()} + + ); +} + +/** Company-first vendor selection with inline technician registration (SH-364 prototype parity). */ +export function WizardVendorCompanyFields({ + draft, + vendors, + selectedVendor, + onPatch, +}: WizardVendorCompanyFieldsProps) { + const company = draft.vendorCompany || selectedVendor?.companyName || ""; + const companyOptions = buildCompanyOptions(vendors, company); + const technicianOptions = vendors + .filter((vendor) => vendor.companyName === company) + .map((vendor) => ({ + key: String(vendor.id), + label: vendor.contactName.trim() || vendor.companyName, + })); + + return ( + <> +
+ + onPatch({ + vendorCompany: "", + vendorId: "", + vendorName: "", + techPhone: "", + newTechnicians: [], + }) + } + > + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ key: option.key, label: option.label }))} + onPick={(nextCompany) => + onPatch({ + vendorCompany: nextCompany, + ...(selectedVendor != null && selectedVendor.companyName !== nextCompany + ? { vendorId: "", vendorName: "", techPhone: "" } + : {}), + }) + } + /> +
+ +
+ onPatch({ vendorId: "", vendorName: "", techPhone: "" }) + : undefined + } + > + Technician + + + ) : ( + + ) + } + searchPlaceholder="Search technician…" + options={technicianOptions} + onPick={(id) => { + const vendor = vendors.find((v) => String(v.id) === id); + onPatch({ + vendorCompany: vendor?.companyName ?? company, + vendorId: id, + vendorName: vendor?.companyName ?? "", + techPhone: "", + }); + }} + /> +
+ + {company !== "" && ( + onPatch({ newTechnicians })} + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx new file mode 100644 index 00000000..7e68e39b --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx @@ -0,0 +1,96 @@ +import { X } from "lucide-react"; +import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard"; +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +type WizardVendorNewTechniciansProps = { + technicians: WizardNewTechnician[]; + onChange: (technicians: WizardNewTechnician[]) => void; +}; + +/** + * SH-364: inline technician registration on the wizard's Vendor & time step. + * Rows stay local to the draft; they are persisted under the selected company only + * when the work order is created, so cancelling the wizard never creates a vendor. + */ +export function WizardVendorNewTechnicians({ + technicians, + onChange, +}: WizardVendorNewTechniciansProps) { + const update = (index: number, patch: Partial) => + onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row))); + + return ( + <> + {technicians.map((technician, index) => { + const label = `New Technician ${index + 1}`; + return ( +
+
+ + {label} + + +
+
+
+ Technician name + update(index, { name: e.target.value })} + placeholder="Technician name" + className={`${DLG_INPUT_CLS} mt-2`} + style={DLG_INPUT_STYLE} + /> +
+
+ Technician phone + update(index, { phone: e.target.value })} + placeholder="(000) 000-0000" + className={`${DLG_INPUT_CLS} mt-2`} + style={DLG_INPUT_STYLE} + /> +
+
+
+ ); + })} + + + ); +} diff --git a/src/domain/vendors/use-cases/use-add-technicians-to-company.ts b/src/domain/vendors/use-cases/use-add-technicians-to-company.ts new file mode 100644 index 00000000..477cc0cb --- /dev/null +++ b/src/domain/vendors/use-cases/use-add-technicians-to-company.ts @@ -0,0 +1,96 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export interface NewCompanyTechnician { + contactName: string; + phone: string; + tradeSpecialties?: string; +} + +export interface AddTechniciansToCompanyInput { + /** Any persisted vendor (technician) of the target company; resolves the company roster. */ + anchorVendorId: string | number; + technicians: NewCompanyTechnician[]; +} + +function takeAddedTechnician( + added: VendorRosterTechnician[], + contactName: string, +): VendorRosterTechnician { + const index = added.findIndex((technician) => technician.contactName.trim() === contactName); + if (index < 0) { + throw new Error("The technician was saved but could not be matched. Reload and try again."); + } + const [match] = added.splice(index, 1); + return match as VendorRosterTechnician; +} + +/** + * Adds technicians to an existing vendor company through the additive roster PATCH. + * + * SH-365: work-order entry points used to POST a whole new vendor with blank company + * fields, which the create validator rejects (company phone/email) and which would + * overwrite company data. The additive path only appends technicians, keeps the + * company record untouched, and returns the persisted technician ids. + */ +export async function addTechniciansToCompany({ + anchorVendorId, + technicians, +}: AddTechniciansToCompanyInput): Promise { + const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId }); + if (roster.companyId == null || roster.rowVersion === "") { + throw new Error("Vendor company could not be loaded. Try again."); + } + + const existingIds = new Set(roster.technicians.map((technician) => String(technician.id))); + const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, { + rowVersion: roster.rowVersion, + addTechnicians: technicians.map((technician) => { + const phone = technician.phone.trim(); + return { + contactName: technician.contactName.trim(), + phone, + email: "", + tradeSpecialties: technician.tradeSpecialties ?? "", + isActive: true, + ...(phone === "" ? {} : { preferredContact: "Phone" }), + }; + }), + }); + + const added = saved.technicians.filter( + (technician) => technician.id != null && !existingIds.has(String(technician.id)), + ); + + return technicians.map((technician) => { + const match = takeAddedTechnician(added, technician.contactName.trim()); + const email = match.email.trim(); + return { + id: match.id ?? "", + companyName: saved.name || roster.name, + contactName: match.contactName, + tradeSpecialties: match.tradeSpecialties, + address: saved.address, + distanceMiles: null, + email: email || undefined, + }; + }); +} + +export function useAddTechniciansToCompany(): UseMutationResult< + VendorDropdownItem[], + Error, + AddTechniciansToCompanyInput +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addTechniciansToCompany, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + }, + }); +} diff --git a/src/domain/vendors/use-cases/use-create-vendor.ts b/src/domain/vendors/use-cases/use-create-vendor.ts deleted file mode 100644 index fa1c730f..00000000 --- a/src/domain/vendors/use-cases/use-create-vendor.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { toast } from "react-toastify"; -import { vendorsApi } from "@/domain/vendors/api/vendors-api"; -import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { Vendor } from "@/domain/vendors/types/vendor"; -import { queryKeys } from "@/infra/query-key/query-key"; - -export function useCreateVendor(): UseMutationResult { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (values: VendorFormValues) => vendorsApi.create(values), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); - toast.success("Vendor created"); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to create vendor"); - }, - }); -} diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index f13b387e..e93cbd6a 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number]; export type WizardSchedMode = "date" | "week" | "range"; +export interface WizardNewTechnician { + name: string; + phone: string; +} + export interface WorkOrderWizardDraft { locationId: string; locationName: string; @@ -45,6 +50,10 @@ export interface WorkOrderWizardDraft { avetaRequired: boolean; vendorId: string; vendorName: string; + /** Company picked on the Vendor & time step; scopes the technician list (SH-364). */ + vendorCompany?: string; + /** Technicians registered inline; persisted under `vendorCompany` only on create (SH-364). */ + newTechnicians?: WizardNewTechnician[]; techPhone: string; vendorNotes: string; apptStart: string; @@ -87,6 +96,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { avetaRequired: false, vendorId: "", vendorName: "", + vendorCompany: "", + newTechnicians: [], techPhone: "", vendorNotes: "", apptStart: "", diff --git a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx index 013dcc2d..fdd184eb 100644 --- a/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx +++ b/src/test/app/(protected)/workorders/vendor-dialog-map-and-scroll.test.tsx @@ -8,8 +8,8 @@ import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_component import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { renderWithProviders } from "@/test/test-utils"; -vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ - useCreateVendor: () => ({ +vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({ + useAddTechniciansToCompany: () => ({ mutateAsync: vi.fn(), isPending: false, }), diff --git a/src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx index 59f7e004..cebf4d66 100644 --- a/src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx +++ b/src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx @@ -4,8 +4,8 @@ import { VendorDialog } from "@/app/(protected)/workorders/_components/list/tabl import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { renderWithProviders } from "@/test/test-utils"; -vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({ - useCreateVendor: () => ({ +vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({ + useAddTechniciansToCompany: () => ({ mutateAsync: vi.fn(), isPending: false, }), 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 index fb89fc1c..00bb3167 100644 --- a/src/test/app/(protected)/workorders/vendor-dialog-stable-identity.test.tsx +++ b/src/test/app/(protected)/workorders/vendor-dialog-stable-identity.test.tsx @@ -1,14 +1,13 @@ 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: () => ({ +vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({ + useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false, }), @@ -24,30 +23,19 @@ const existingVendor: VendorDropdownItem = { email: "dispatch@gateway.test", }; -const createdVendor: Vendor = { +const createdVendor: VendorDropdownItem = { 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", + address: "1 Main St", + distanceMiles: null, }; describe("VendorDialog stable technician identity", () => { beforeEach(() => { mutateAsync.mockReset(); - mutateAsync.mockResolvedValue(createdVendor); + mutateAsync.mockResolvedValue([createdVendor]); }); it("keeps draft identity on same-name collision and posts live phone on save", async () => { @@ -90,13 +78,15 @@ describe("VendorDialog stable technician identity", () => { expect(mutateAsync).toHaveBeenCalledTimes(1); }); - expect(mutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - contactName: "Jordan Lee", - companyName: "Gateway Plumbing", - phone: "(314) 555-9999", - }), - ); + expect(mutateAsync).toHaveBeenCalledWith({ + anchorVendorId: 10, + technicians: [ + expect.objectContaining({ + contactName: "Jordan Lee", + phone: "(314) 555-9999", + }), + ], + }); await waitFor(() => { expect(onSave).toHaveBeenCalledWith( @@ -115,12 +105,7 @@ describe("VendorDialog stable technician identity", () => { 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", - }); + mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]); renderWithProviders( { expect(mutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - contactName: "Casey New", - phone: "3145550188", + technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })], }), ); @@ -178,12 +162,7 @@ describe("VendorDialog stable technician identity", () => { it("keeps a staged draft when the same company is picked again", async () => { const onSave = vi.fn(); - mutateAsync.mockResolvedValue({ - ...createdVendor, - id: 88, - contactName: "Casey New", - phone: "3145550188", - }); + mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]); renderWithProviders( { await waitFor(() => { expect(mutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - contactName: "Casey New", - phone: "3145550188", + technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })], }), ); }); diff --git a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx new file mode 100644 index 00000000..ca694615 --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx @@ -0,0 +1,164 @@ +import { act, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; +import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +const mutateAsync = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({ + useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +const vendors: VendorDropdownItem[] = [ + { + id: 41, + companyName: "Vinewood LLC", + contactName: "Adam Whyte", + tradeSpecialties: "HVAC", + address: "1 Market St", + distanceMiles: null, + }, + { + id: 52, + companyName: "Gateway Plumbing", + contactName: "Casey Pipe", + tradeSpecialties: "Plumbing", + address: "", + distanceMiles: null, + }, +]; + +function draftWith(patch: Partial): WorkOrderWizardDraft { + return { ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete", ...patch }; +} + +describe("WizardStepVendorTime inline technician (SH-364)", () => { + it("hides the add action until a company is picked", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "+ Add technician" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled(); + }); + + it("stages a new technician row for the picked company", () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "+ Add technician" })); + expect(onPatch).toHaveBeenCalledWith({ newTechnicians: [{ name: "", phone: "" }] }); + }); + + it("scopes technicians to the picked company", () => { + render( + , + ); + + expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /select technician/i })); + expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument(); + }); +}); + +describe("useWizardCreateWithTechnicians (SH-364)", () => { + beforeEach(() => { + mutateAsync.mockReset(); + }); + + it("creates the work order directly when no technician was staged", () => { + const createMutation = { mutate: vi.fn(), isPending: false }; + const { result } = renderHook(() => + useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }), + ); + const draft = draftWith({ vendorCompany: "Vinewood LLC", newTechnicians: [] }); + + result.current.mutate(draft); + + expect(mutateAsync).not.toHaveBeenCalled(); + expect(createMutation.mutate).toHaveBeenCalledWith(draft); + }); + + it("persists staged technicians first and assigns a single new technician", async () => { + mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]); + const createMutation = { mutate: vi.fn(), isPending: false }; + const patchDraft = vi.fn(); + const { result } = renderHook(() => + useWizardCreateWithTechnicians({ vendors, patchDraft, createMutation }), + ); + + await act(async () => { + result.current.mutate( + draftWith({ + vendorCompany: "Vinewood LLC", + newTechnicians: [ + { name: "Jordan Lee", phone: "(314) 555-0199" }, + { name: " ", phone: "" }, + ], + }), + ); + }); + + expect(mutateAsync).toHaveBeenCalledWith({ + anchorVendorId: 41, + technicians: [ + { contactName: "Jordan Lee", phone: "(314) 555-0199", tradeSpecialties: "HVAC" }, + ], + }); + const expectedPatch = { + newTechnicians: [], + vendorId: "99", + vendorName: "Vinewood LLC", + techPhone: "(314) 555-0199", + }; + expect(patchDraft).toHaveBeenCalledWith(expectedPatch); + expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch)); + }); + + it("does not create the work order when the technician save fails", async () => { + mutateAsync.mockRejectedValue(new Error("boom")); + const createMutation = { mutate: vi.fn(), isPending: false }; + const { result } = renderHook(() => + useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }), + ); + + await act(async () => { + result.current.mutate( + draftWith({ + vendorCompany: "Vinewood LLC", + newTechnicians: [{ name: "Jordan Lee", phone: "" }], + }), + ); + }); + + expect(createMutation.mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts b/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts new file mode 100644 index 00000000..507c52c3 --- /dev/null +++ b/src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPut = vi.fn(); +const apiPatch = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), + apiPatch: (...args: unknown[]) => apiPatch(...args), +})); + +import { addTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; + +const roster = { + companyId: 7, + rowVersion: "AAAAAAAAB9E=", + name: "Vinewood LLC", + companyPhone: "(314) 555-0100", + email: "office@vinewood.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + notes: "", + technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }], +}; + +describe("addTechniciansToCompany (SH-365)", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiPut.mockReset(); + apiPatch.mockReset(); + apiGet.mockResolvedValue(roster); + apiPatch.mockResolvedValue({ + ...roster, + rowVersion: "AAAAAAAAB9F=", + technicians: [ + ...roster.technicians, + { id: 42, contactName: "Jordan Lee", phone: "(314) 555-0199", email: "", isActive: true }, + ], + }); + }); + + it("appends the technician through the additive roster PATCH without company fields", async () => { + const created = await addTechniciansToCompany({ + anchorVendorId: 41, + technicians: [{ contactName: " Jordan Lee ", phone: "3145550199", tradeSpecialties: "HVAC" }], + }); + + expect(apiGet).toHaveBeenCalledWith("vendor-company-roster", { + searchParams: { vendorId: "41" }, + }); + expect(apiPost).not.toHaveBeenCalled(); + expect(apiPut).not.toHaveBeenCalled(); + expect(apiPatch).toHaveBeenCalledTimes(1); + const [path, body] = apiPatch.mock.calls[0] ?? []; + expect(path).toBe("vendor-company-roster/7"); + // No companyFields: an existing company's phone/email/address are never sent or blanked. + expect(body).toEqual({ + rowVersion: "AAAAAAAAB9E=", + addTechnicians: [ + { + contactName: "Jordan Lee", + phone: "(314) 555-0199", + email: "", + tradeSpecialties: "HVAC", + isActive: true, + preferredContact: "Phone", + }, + ], + }); + expect(created).toEqual([ + expect.objectContaining({ id: 42, contactName: "Jordan Lee", companyName: "Vinewood LLC" }), + ]); + }); + + it("fails instead of returning a technician the server did not persist", async () => { + apiPatch.mockResolvedValue(roster); + + await expect( + addTechniciansToCompany({ + anchorVendorId: 41, + technicians: [{ contactName: "Jordan Lee", phone: "" }], + }), + ).rejects.toThrow(/could not be matched/i); + }); +}); From 712d8e0dc6123865dde9510b70c80e7da29db5b7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 16:50:26 -0300 Subject: [PATCH 2/4] fix(work-orders): clear stale technician drafts on selection changes --- e2e/work-orders/inline-technician.spec.ts | 2 +- .../use-wizard-create-with-technicians.ts | 4 +- .../wizard-step-location-service-select.tsx | 4 +- .../wizard/wizard-vendor-company-fields.tsx | 6 +- .../wizard/wizard-vendor-new-technicians.tsx | 2 +- .../use-add-technicians-to-company.ts | 2 +- .../work-orders/types/work-order-wizard.ts | 4 +- .../wizard-service-clears-vendor.test.tsx | 71 +++++++++++++++++++ .../wizard-vendor-inline-technician.test.tsx | 53 ++++++++++++++ 9 files changed, 137 insertions(+), 11 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index b2dee631..bcac5250 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page, type Request, type Route } from "@playwright/test"; -// SH-364 / SH-365 / SH-366: technicians registered inline from work-order entry points +// Technicians registered inline from work-order entry points // become real vendor records only on save, through the additive vendor-company roster // PATCH (never a whole-vendor POST that restates or blanks company contact fields). diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts index 29d874e6..fe0f1950 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts @@ -38,9 +38,9 @@ export function buildCreatedTechniciansPatch( } /** - * SH-364: persists technicians registered inline on the Vendor & time step under the + * Persists technicians registered inline on the Vendor & time step under the * selected company, then creates the work order. Nothing is written before Create, so - * cancelling the wizard leaves no stray vendor records (SH-366). + * cancelling the wizard leaves no stray vendor records. */ export function useWizardCreateWithTechnicians({ vendors, diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx index bfefcf5d..c9b2c41a 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -29,7 +29,9 @@ export function WizardStepLocationServiceSelect({ onPatch, }: WizardStepLocationServiceSelectProps) { const clearVendorIfPrimaryChanged = (nextPm: string): Partial => - nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; + nextPm !== draft.pm + ? { vendorId: "", vendorName: "", techPhone: "", vendorCompany: "", newTechnicians: [] } + : {}; const selectSvc = (p: string) => { onPatch({ diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx index 1859fa0d..24cef2a4 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx @@ -34,7 +34,7 @@ function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) { ); } -/** Company-first vendor selection with inline technician registration (SH-364 prototype parity). */ +/** Company-first vendor selection with inline technician registration (prototype parity). */ export function WizardVendorCompanyFields({ draft, vendors, @@ -79,8 +79,8 @@ export function WizardVendorCompanyFields({ onPick={(nextCompany) => onPatch({ vendorCompany: nextCompany, - ...(selectedVendor != null && selectedVendor.companyName !== nextCompany - ? { vendorId: "", vendorName: "", techPhone: "" } + ...(nextCompany !== company + ? { vendorId: "", vendorName: "", techPhone: "", newTechnicians: [] } : {}), }) } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx index 7e68e39b..4496a6cb 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians.tsx @@ -12,7 +12,7 @@ type WizardVendorNewTechniciansProps = { }; /** - * SH-364: inline technician registration on the wizard's Vendor & time step. + * Inline technician registration on the wizard's Vendor & time step. * Rows stay local to the draft; they are persisted under the selected company only * when the work order is created, so cancelling the wizard never creates a vendor. */ diff --git a/src/domain/vendors/use-cases/use-add-technicians-to-company.ts b/src/domain/vendors/use-cases/use-add-technicians-to-company.ts index 477cc0cb..7085d2d8 100644 --- a/src/domain/vendors/use-cases/use-add-technicians-to-company.ts +++ b/src/domain/vendors/use-cases/use-add-technicians-to-company.ts @@ -31,7 +31,7 @@ function takeAddedTechnician( /** * Adds technicians to an existing vendor company through the additive roster PATCH. * - * SH-365: work-order entry points used to POST a whole new vendor with blank company + * Work-order entry points used to POST a whole new vendor with blank company * fields, which the create validator rejects (company phone/email) and which would * overwrite company data. The additive path only appends technicians, keeps the * company record untouched, and returns the persisted technician ids. diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index e93cbd6a..dc657c92 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -50,9 +50,9 @@ export interface WorkOrderWizardDraft { avetaRequired: boolean; vendorId: string; vendorName: string; - /** Company picked on the Vendor & time step; scopes the technician list (SH-364). */ + /** Company picked on the Vendor & time step; scopes the technician list. */ vendorCompany?: string; - /** Technicians registered inline; persisted under `vendorCompany` only on create (SH-364). */ + /** Technicians registered inline; persisted under `vendorCompany` only on create. */ newTechnicians?: WizardNewTechnician[]; techPhone: string; vendorNotes: string; diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index 3812fb63..c227d9d0 100644 --- a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -43,6 +43,8 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorCompany: "", + newTechnicians: [], }), ); }); @@ -83,6 +85,75 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorCompany: "", + newTechnicians: [], }); }); + + it("clears the vendor company and staged inline technicians when the service changes", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + pm: "HVAC", + vendorCompany: "Vinewood LLC", + newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }], + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "HVAC" })); + fireEvent.click(screen.getByRole("button", { name: "Plumbing" })); + + expect(onPatch).toHaveBeenCalledWith({ + pm: "Plumbing", + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + vendorCompany: "", + newTechnicians: [], + }); + }); + + it("keeps vendor company and staged technicians when the service is unchanged", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + pm: "HVAC", + vendorCompany: "Vinewood LLC", + newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }], + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "HVAC" })); + fireEvent.click(screen.getByRole("button", { name: "HVAC" })); + + expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", extraServices: [] }); + }); }); diff --git a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx index ca694615..96d7b612 100644 --- a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx @@ -87,6 +87,59 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => { expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument(); }); + + it("clears the picked vendor and staged technicians when the company changes", () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" })); + fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" })); + + expect(onPatch).toHaveBeenCalledWith({ + vendorCompany: "Gateway Plumbing", + vendorId: "", + vendorName: "", + techPhone: "", + newTechnicians: [], + }); + }); + + it("preserves the picked vendor and staged technicians when the same company is re-picked", () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" })); + const companyOptions = screen.getAllByRole("button", { name: "Vinewood LLC" }); + fireEvent.click(companyOptions[companyOptions.length - 1]); + + expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Vinewood LLC" }); + }); }); describe("useWizardCreateWithTechnicians (SH-364)", () => { From 0b229fd0546b78442aff24ae2289bed343f43114 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 19:45:07 -0300 Subject: [PATCH 3/4] fix(work-orders): validate inline technician phone before roster save The inline technician rows on the wizard's Vendor & time step had no client-side phone check, so a number that does not normalize to ten digits failed the roster PATCH and blocked the whole Create with the raw backend message in a toast. Validate each staged phone with the same isValidNorthAmericanPhone predicate the vendor roster schema uses, so the inline path matches the modal path. A blank phone stays valid. --- .../use-wizard-create-with-technicians.ts | 8 ++++ .../wizard-vendor-inline-technician.test.tsx | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts index fe0f1950..a18f1550 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians.ts @@ -1,5 +1,6 @@ import { toast } from "react-toastify"; import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; +import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { WizardNewTechnician, @@ -56,6 +57,13 @@ export function useWizardCreateWithTechnicians({ createMutation.mutate(draft); return; } + // Mirror the vendor roster schema so an unparseable phone is caught here with a + // clear message instead of failing the roster PATCH and blocking Create with the + // raw backend sentence. A blank phone stays valid, matching the modal path. + if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) { + toast.error("Enter a 10-digit phone number for each new technician."); + return; + } const anchor = vendors.find((vendor) => vendor.companyName === company); if (!anchor) { toast.error("Select an existing vendor company before adding technicians."); diff --git a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx index 96d7b612..1774db4f 100644 --- a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx @@ -196,6 +196,48 @@ describe("useWizardCreateWithTechnicians (SH-364)", () => { expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch)); }); + it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => { + const createMutation = { mutate: vi.fn(), isPending: false }; + const { result } = renderHook(() => + useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }), + ); + + await act(async () => { + result.current.mutate( + draftWith({ + vendorCompany: "Vinewood LLC", + newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-01" }], + }), + ); + }); + + expect(mutateAsync).not.toHaveBeenCalled(); + expect(createMutation.mutate).not.toHaveBeenCalled(); + }); + + it("allows a staged technician with a blank phone, matching the modal path", async () => { + mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]); + const createMutation = { mutate: vi.fn(), isPending: false }; + const { result } = renderHook(() => + useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }), + ); + + await act(async () => { + result.current.mutate( + draftWith({ + vendorCompany: "Vinewood LLC", + newTechnicians: [{ name: "Jordan Lee", phone: "" }], + }), + ); + }); + + expect(mutateAsync).toHaveBeenCalledWith({ + anchorVendorId: 41, + technicians: [{ contactName: "Jordan Lee", phone: "", tradeSpecialties: "HVAC" }], + }); + expect(createMutation.mutate).toHaveBeenCalled(); + }); + it("does not create the work order when the technician save fails", async () => { mutateAsync.mockRejectedValue(new Error("boom")); const createMutation = { mutate: vi.fn(), isPending: false }; From 8a0a13423864e3de5e3a8f0812c0df650a16f030 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 17 Sep 2026 15:31:15 -0300 Subject: [PATCH 4/4] style: format wizard-vendor-company-fields with prettier --- .../_components/wizard/wizard-vendor-company-fields.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx index 9156aff6..d74ba779 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields.tsx @@ -81,7 +81,13 @@ export function WizardVendorCompanyFields({ onPatch({ vendorCompany: nextCompany, ...(nextCompany !== company - ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "", newTechnicians: [] } + ? { + vendorId: "", + vendorName: "", + techPhone: "", + vendorNotes: "", + newTechnicians: [], + } : {}), }) }