diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index bcac5250..9fc93684 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -25,6 +25,13 @@ const VENDORS = [ address: "1 Market St", }, ]; +const COASTAL_VENDOR = { + id: 50, + companyName: "Coastal HVAC", + contactName: "Avery Chen", + tradeSpecialties: "HVAC", + address: "9 Shore Rd", +}; const ROSTER = { companyId: 7, @@ -55,6 +62,10 @@ const BOARD_ROW = { vendorId: 41, vendorName: "Vinewood LLC", techName: "Adam Whyte", + technicianAssigned: true, + completedDate: "2026-08-19", + docStatus: "Yes", + mediaCount: 1, title: "Quarterly HVAC PM", rowVersion: "rv-1", }; @@ -81,7 +92,12 @@ function rosterAfterAdd(body: { addTechnicians?: Array> return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] }; } -async function mockApi(page: Page, writes: Captured[]) { +async function mockApi( + page: Page, + writes: Captured[], + initialRows: Array> = [BOARD_ROW], +) { + const currentBoardRows = [...initialRows]; 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); @@ -100,16 +116,85 @@ async function mockApi(page: Page, writes: Captured[]) { return fulfillJson(route, { weekStart: "2026-08-17", weekEnd: "2026-08-21", - counts: { returned: 1, total: 1 }, + counts: { returned: currentBoardRows.length, total: currentBoardRows.length }, unscheduled: [], - scheduled: [BOARD_ROW], + scheduled: currentBoardRows, + }); + } + if (/^\/api\/workorders\/\d+\/detail$/u.test(pathname) && method === "GET") { + const id = Number(pathname.split("/")[3]); + const detailRow = currentBoardRows.find((row) => row.id === id); + if (detailRow == null) return fulfillJson(route, { message: "Missing fixture row" }, 404); + return fulfillJson(route, { + info: detailRow, + completion: { docStatus: 1 }, + comments: [], + audit: [], + media: [], }); } if (pathname === "/api/workorders/board" && method === "POST") { + const body = bodyOf(request) as { vendorId?: number; technicianAssigned?: boolean }; + const vendorId = body.vendorId ?? 0; + currentBoardRows.push({ + ...BOARD_ROW, + id: 900, + woNumber: "SH-900", + lifecycleStatusLabel: "Incomplete", + vendorId: vendorId || null, + vendorName: vendorId > 0 ? "Vinewood LLC" : null, + technicianAssigned: body.technicianAssigned, + techName: body.technicianAssigned === true ? "Jordan Lee" : null, + techPhone: body.technicianAssigned === true ? "(314) 555-0199" : null, + }); 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 (/^\/api\/workorders\/\d+\/board$/u.test(pathname) && method === "PATCH") { + const body = bodyOf(request) as { + field?: string; + value?: string; + technicianAssigned?: boolean; + }; + const id = Number(pathname.split("/")[3]); + const index = currentBoardRows.findIndex((row) => row.id === id); + if (index < 0) return fulfillJson(route, { message: "Missing fixture row" }, 404); + const current = currentBoardRows[index]; + if (current == null) return fulfillJson(route, { message: "Missing fixture row" }, 404); + if (body.field === "vendorId") { + const vendorId = Number(body.value); + currentBoardRows[index] = { + ...current, + vendorId, + vendorName: vendorId === 50 ? "Coastal HVAC" : "Vinewood LLC", + ...(body.technicianAssigned === false + ? { technicianAssigned: false, techName: null, techPhone: null } + : body.technicianAssigned === true + ? { + technicianAssigned: true, + techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte", + techPhone: "(314) 555-0141", + } + : {}), + rowVersion: "rv-2", + }; + } else if (body.field === "lifecycleStatus") { + currentBoardRows[index] = { + ...current, + lifecycleStatusLabel: body.value, + ...(body.value === "Completed" + ? { + frozenCompany: { + name: current.vendorName, + contactName: current.vendorName === "Coastal HVAC" ? "Avery Chen" : "Adam Whyte", + phone: "555-0100", + technicianAssigned: current.technicianAssigned, + }, + } + : {}), + rowVersion: "rv-3", + }; + } + return fulfillJson(route, currentBoardRows[index]); } if (pathname === "/api/vendor-company-roster" && method === "GET") { return fulfillJson(route, ROSTER); @@ -118,7 +203,7 @@ async function mockApi(page: Page, writes: Captured[]) { return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never)); } if (pathname.toLowerCase().includes("/vendors/dropdown")) { - return fulfillJson(route, { data: VENDORS }); + return fulfillJson(route, { data: [...VENDORS, COASTAL_VENDOR] }); } if (pathname.includes("/locations/sites")) { return fulfillJson(route, { @@ -208,6 +293,262 @@ test.describe("Inline technician registration", () => { expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 })); }); + test("wizard No technician selection overrides a staged technician through create and reopen", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + await openWizardVendorStep(page); + + await page.getByRole("button", { name: "No technician" }).first().click(); + await page.getByRole("button", { name: "No technician" }).last().click(); + await page.getByRole("button", { name: "Create work order" }).click(); + + await expect + .poll(() => + writes.find( + (write) => write.pathname === "/api/workorders/board" && write.method === "POST", + ), + ) + .toBeTruthy(); + const create = writes.find( + (write) => write.pathname === "/api/workorders/board" && write.method === "POST", + ); + expect(create?.body).toEqual( + expect.objectContaining({ vendorId: 41, technicianAssigned: false }), + ); + expect(vendorWrites(writes)).toEqual([ + expect.objectContaining({ pathname: "/api/vendor-company-roster/7" }), + ]); + + await page.reload(); + const createdRow = page.locator("#wo-row-900"); + await expect(createdRow.getByRole("button", { name: /Vinewood LLC/ })).toBeVisible(); + await createdRow.getByRole("button", { name: /Vinewood LLC/ }).click(); + const vendorDialog = page.getByRole("dialog", { name: "Vendor" }); + await expect(vendorDialog.getByRole("button", { name: "No technician" })).toBeVisible(); + await expect(vendorDialog.locator('input[type="tel"]')).toHaveCount(0); + await expect(vendorDialog.getByRole("button", { name: "Jordan Lee" })).toHaveCount(0); + }); + + test("company-only save returns no technician and stays empty after reopen and reload", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes, [{ ...BOARD_ROW, lifecycleStatusLabel: "In Progress" }]); + await page.goto("/workorders"); + + const row = page.locator("#wo-row-1"); + await row.getByRole("button", { name: /Adam Whyte/ }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Vinewood LLC" }).click(); + await page.getByRole("button", { name: "Coastal HVAC" }).last().click(); + await dialog.getByRole("button", { name: /^save$/i }).click(); + + await expect(dialog).toBeHidden(); + const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board"); + expect(boardPatch?.body).toEqual( + expect.objectContaining({ field: "vendorId", value: "50", technicianAssigned: false }), + ); + await expect(row.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible(); + await row.getByRole("button", { name: /Coastal HVAC/ }).click(); + await expect( + page.getByRole("dialog").getByRole("button", { name: "No technician" }), + ).toBeVisible(); + await expect(page.locator('input[type="tel"]')).toHaveCount(0); + await page + .getByRole("dialog") + .getByRole("button", { name: /cancel/i }) + .click(); + + await page.reload(); + const reloadedRow = page.locator("#wo-row-1"); + await expect(reloadedRow.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible(); + await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click(); + await expect( + page.getByRole("dialog").getByRole("button", { name: "No technician" }), + ).toBeVisible(); + await page + .getByRole("dialog") + .getByRole("button", { name: /cancel/i }) + .click(); + await reloadedRow.getByRole("button", { name: /in progress/i }).click(); + await page.getByRole("button", { name: "Completed" }).click(); + await page + .getByRole("dialog", { name: "Mark as Complete?" }) + .getByRole("button", { name: "Mark as Complete" }) + .click(); + await expect + .poll(() => + writes.some( + (write) => + write.pathname === "/api/workorders/1/board" && + (write.body as { field?: string }).field === "lifecycleStatus", + ), + ) + .toBe(true); + await expect(reloadedRow.getByText("Completed", { exact: true })).toBeVisible(); + await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click(); + const completedDialog = page.getByRole("dialog"); + await expect(completedDialog.getByText("—", { exact: true })).toBeVisible(); + await expect(completedDialog.getByRole("button", { name: /^save$/i })).toHaveCount(0); + expect( + writes.some( + (write) => + write.pathname === "/api/workorders/1/board" && + (write.body as { field?: string }).field === "lifecycleStatus", + ), + ).toBe(true); + expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false); + }); + + test("company absence blocks completion for PM, Emergency, Reactive, and Overdue", async ({ + page, + }) => { + const writes: Captured[] = []; + const withoutCompany = [ + [1, 2], + [2, 3], + [3, 6], + [4, 8], + ].map(([id, workOrderType]) => ({ + ...BOARD_ROW, + id, + woNumber: `WO-${600 + id}`, + workOrderType, + lifecycleStatusLabel: "In Progress", + vendorId: null, + vendorName: null, + techName: "Avery Chen", + technicianAssigned: true, + docStatus: "Yes", + mediaCount: 1, + })); + await mockApi(page, writes, withoutCompany); + await page.goto("/workorders"); + + for (const id of [1, 2, 3, 4]) { + const row = page.locator(`#wo-row-${id}`); + await row.getByRole("button", { name: /in progress/i }).click(); + const completed = page.getByRole("button", { name: "Completed" }); + await expect(completed).toBeDisabled(); + await expect(completed).toHaveAttribute( + "title", + "Fill in Vendor before completing this work order.", + ); + await page.keyboard.press("Escape"); + } + expect(writes.filter((write) => write.pathname.endsWith("/board"))).toEqual([]); + }); + + test("explicit technician choice is retained and can complete after reload", async ({ page }) => { + const writes: Captured[] = []; + const unassigned = { + ...BOARD_ROW, + lifecycleStatusLabel: "In Progress", + technicianAssigned: false, + techName: null, + techPhone: null, + }; + await mockApi(page, writes, [unassigned]); + await page.goto("/workorders"); + + const row = page.locator("#wo-row-1"); + await row.getByRole("button", { name: /Vinewood LLC/ }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "No technician" }).click(); + await page.getByRole("button", { name: "Adam Whyte" }).last().click(); + await expect(page.locator('input[type="tel"]')).toBeVisible(); + await dialog.getByRole("button", { name: /^save$/i }).click(); + + const assignmentWrite = writes.find((write) => write.pathname === "/api/workorders/1/board"); + expect(assignmentWrite?.body).toEqual( + expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: true }), + ); + await expect(row.getByRole("button", { name: /Adam Whyte/ })).toBeVisible(); + await page.reload(); + const reloadedRow = page.locator("#wo-row-1"); + await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible(); + await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click(); + const reopenedDialog = page.getByRole("dialog"); + await expect(reopenedDialog.getByRole("button", { name: "Adam Whyte" })).toBeVisible(); + await expect(reopenedDialog.locator('input[type="tel"]')).toHaveValue("(314) 555-0141"); + await page + .getByRole("dialog") + .getByRole("button", { name: /cancel/i }) + .click(); + await reloadedRow.getByRole("button", { name: /in progress/i }).click(); + await page.getByRole("button", { name: "Completed" }).click(); + await page + .getByRole("dialog", { name: "Mark as Complete?" }) + .getByRole("button", { name: "Mark as Complete" }) + .click(); + await expect + .poll(() => writes.filter((write) => write.pathname === "/api/workorders/1/board").length) + .toBe(2); + await expect(reloadedRow.getByText("Completed", { exact: true })).toBeVisible(); + expect( + writes.some( + (write) => + write.pathname === "/api/workorders/1/board" && + (write.body as { field?: string; value?: string }).field === "lifecycleStatus", + ), + ).toBe(true); + }); + + test("slide-over saves a same-company no-technician selection with its vendor anchor", async ({ + page, + }) => { + const writes: Captured[] = []; + const companyOnly = { + ...BOARD_ROW, + vendorId: 50, + vendorName: "Coastal HVAC", + technicianAssigned: false, + techName: null, + techPhone: null, + }; + await mockApi(page, writes, [companyOnly]); + await page.goto("/workorders"); + + const row = page.locator("#wo-row-1"); + await row.hover(); + await row.getByRole("button", { name: "View details" }).click(); + const details = page.getByRole("dialog", { name: "Work order details" }); + await expect(details).toBeVisible(); + await details.getByRole("button", { name: "Edit WO" }).click(); + + await details.getByRole("button", { name: /Coastal HVAC/ }).click(); + const assignment = page.getByRole("dialog").last(); + await assignment.getByRole("button", { name: "Coastal HVAC" }).click(); + await page.getByRole("button", { name: "Vinewood LLC" }).last().click(); + await assignment.getByRole("button", { name: /select technician/i }).click(); + await page.getByRole("button", { name: "No technician" }).last().click(); + await assignment.getByRole("button", { name: /^save$/i }).click(); + await details.getByRole("button", { name: "Save", exact: true }).click(); + + const slideOverPatch = writes.find( + (write) => + write.pathname === "/api/workorders/1/board" && + (write.body as { field?: string }).field === "vendorId", + ); + expect(slideOverPatch?.body).toEqual( + expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: false }), + ); + + await page.reload(); + const reloadedRow = page.locator("#wo-row-1"); + await reloadedRow.hover(); + await reloadedRow.getByRole("button", { name: "View details" }).click(); + const reloadedDetails = page.getByRole("dialog", { name: "Work order details" }); + await expect(reloadedDetails).toBeVisible(); + await reloadedDetails.getByRole("button", { name: "Edit WO" }).click(); + await reloadedDetails.getByRole("button", { name: /Vinewood LLC/ }).click(); + await expect( + page.getByRole("dialog").last().getByRole("button", { name: "No technician" }), + ).toBeVisible(); + }); + test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({ page, }) => { diff --git a/e2e/work-orders/wizard-vendor-company-notes.spec.ts b/e2e/work-orders/wizard-vendor-company-notes.spec.ts index 7006302b..8bb25bdf 100644 --- a/e2e/work-orders/wizard-vendor-company-notes.spec.ts +++ b/e2e/work-orders/wizard-vendor-company-notes.spec.ts @@ -143,7 +143,8 @@ test.describe("wizard vendor company notes (SH-321)", () => { .first() .click(); - await page.getByRole("button", { name: /Select technician/ }).click(); + // Picking a company assigns it with no technician until one is chosen. + await page.getByRole("button", { name: "No technician" }).click(); await page .getByRole("button", { name: /Adam Whyte/ }) .last() diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx index b11f6464..5c0cb39a 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx @@ -31,6 +31,14 @@ type UseCompDocDialogArgs = { onDateCompletedChange?: (date: string) => void | Promise; }; +function completionDocTechnician( + assigned: boolean | null | undefined, + frozenAssigned: boolean | null | undefined, + contactName: string | undefined, +): string { + return assigned === false || frozenAssigned === false ? "" : contactName || ""; +} + function notifyDraftSaved() { toast(
onDraftChange(toVendorTablePatch(patch))} /> diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 0058d354..a11d067b 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -33,6 +33,7 @@ export function buildInlineDraftCreateInput( avetaRequired: Boolean(draft.avetaRequired), pmNote: draft.serviceNotes ?? "", vendorId: draft.vendorId || "", + technicianAssigned: draft.technicianAssigned, vendorName: draft.company, techPhone: draft.techPhone, vendorNotes: "", 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 index 95a75a3b..efe98c44 100644 --- 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 @@ -1,5 +1,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +export const NO_TECHNICIAN_KEY = "__no_technician__"; + /** * Board rows often have tech/company but no vendorId — resolve via vendors list. * Prefers a stable vendor id over name+company matching. 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 c648e0b8..90f83218 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,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company"; @@ -11,7 +11,10 @@ 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 { + NO_TECHNICIAN_KEY, + 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 { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit"; import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; @@ -23,10 +26,74 @@ type UseVendorDialogFormArgs = { company: string; tech: string; techPhone: string; + technicianAssigned?: boolean | null; vendors: VendorDropdownItem[]; onSave: (patch: VendorPatch) => void; }; +type TechnicianKeyArgs = Pick< + UseVendorDialogFormArgs, + "technicianAssigned" | "vendorId" | "tech" | "company" | "vendors" +>; + +function resolveInitialTechnicianKey(args: TechnicianKeyArgs): string { + if (args.technicianAssigned === false) return NO_TECHNICIAN_KEY; + return resolveTechnicianKey(args.vendorId, args.tech, args.company, args.vendors); +} + +function buildSubmitBaseline(args: TechnicianKeyArgs & { techPhone: string }) { + const options = buildTechnicianOptions(args.vendors, args.company, args.tech, args.company); + return { + company: args.company, + selectedKey: resolveInitialTechnicianKey(args), + phone: args.techPhone || phoneForOption(args.tech, options), + notes: "", + pendingCreateCount: 0, + }; +} + +function createCompanyPickHandler(args: { + selectedKey: string; + technicianOptions: ReturnType; + setSelectedCompany: (company: string) => void; + setAddedVendors: Dispatch>; + setPendingCreates: Dispatch>>; + setSelectedKey: (key: string) => void; + setT: (value: string) => void; + setPhone: (value: string) => void; +}) { + return (nextCompany: string) => { + const selectedOption = args.technicianOptions.find((option) => option.key === args.selectedKey); + args.setSelectedCompany(nextCompany); + if (selectedOption?.company === nextCompany) return; + args.setAddedVendors([]); + args.setPendingCreates({}); + args.setSelectedKey(""); + args.setT(""); + args.setPhone(""); + }; +} + +function createTechnicianPickHandler(args: { + technicianOptions: ReturnType; + setSelectedKey: (key: string) => void; + setT: (value: string) => void; + setPhone: (value: string) => void; +}) { + return (key: string) => { + if (key === NO_TECHNICIAN_KEY) { + args.setSelectedKey(key); + args.setT(""); + args.setPhone(""); + return; + } + const option = args.technicianOptions.find((item) => item.key === key); + args.setSelectedKey(key); + args.setT(option?.name ?? key); + args.setPhone(option?.phone || phoneForOption(option?.name ?? key, args.technicianOptions)); + }; +} + export function useVendorDialogForm({ open, onOpenChange, @@ -34,13 +101,18 @@ export function useVendorDialogForm({ company, tech, techPhone, + technicianAssigned, vendors, onSave, }: UseVendorDialogFormArgs) { const addTechnicians = useAddTechniciansToCompany(); const [selectedCompany, setSelectedCompany] = useState(company); + const technicianKeyArgs = useMemo( + () => ({ technicianAssigned, vendorId, tech, company, vendors }), + [technicianAssigned, vendorId, tech, company, vendors], + ); const [selectedKey, setSelectedKey] = useState(() => - resolveTechnicianKey(vendorId, tech, company, vendors), + resolveInitialTechnicianKey(technicianKeyArgs), ); const [t, setT] = useState(tech); const [phone, setPhone] = useState(techPhone); @@ -66,7 +138,7 @@ export function useVendorDialogForm({ useEffect(() => { if (open) { - const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const key = resolveInitialTechnicianKey(technicianKeyArgs); const initialOptions = buildTechnicianOptions(vendors, company, tech, company); setSelectedCompany(company); setSelectedKey(key); @@ -78,7 +150,7 @@ export function useVendorDialogForm({ setSaveError(null); } // invariant: rebuilding options after Add must not clear staged technician drafts. - }, [open, tech, techPhone, vendors, company, vendorId]); + }, [open, tech, techPhone, vendors, company, vendorId, technicianAssigned, technicianKeyArgs]); const selected = technicianOptions.find((option) => option.key === selectedKey); const resolvedCompany = selectedCompany; @@ -100,20 +172,15 @@ export function useVendorDialogForm({ notes, pendingCreateCount: Object.keys(pendingCreates).length, }, - baseline: { - company, - selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), - phone: - techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), - notes: "", - pendingCreateCount: 0, - }, + baseline: buildSubmitBaseline({ ...technicianKeyArgs, techPhone }), }); const { handleSave } = useVendorDialogSave({ allVendors, pendingCreates, selectedKey, + vendorId, + originalCompany: company, selectedVendor, resolvedCompany, anchorCompanyName, @@ -126,6 +193,23 @@ export function useVendorDialogForm({ canSubmit, }); + const handleCompanyPick = createCompanyPickHandler({ + selectedKey, + technicianOptions, + setSelectedCompany, + setAddedVendors, + setPendingCreates, + setSelectedKey, + setT, + setPhone, + }); + const handlePick = createTechnicianPickHandler({ + technicianOptions, + setSelectedKey, + setT, + setPhone, + }); + return { companyOptions, technicianOptions, @@ -143,23 +227,8 @@ export function useVendorDialogForm({ canSubmit, isPending: addTechnicians.isPending, handleSave, - handleCompanyPick: (nextCompany: string) => { - const selectedOption = technicianOptions.find((option) => option.key === selectedKey); - setSelectedCompany(nextCompany); - if (selectedOption?.company !== nextCompany) { - setAddedVendors([]); - setPendingCreates({}); - setSelectedKey(""); - setT(""); - setPhone(""); - } - }, - handlePick: (key: string) => { - const option = technicianOptions.find((o) => o.key === key); - setSelectedKey(key); - setT(option?.name ?? key); - setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); - }, + handleCompanyPick, + handlePick, handleTechnicianCreated: ( created: VendorDropdownItem, createdPhone: string, 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 55cf9ca1..3523c645 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 @@ -8,6 +8,8 @@ type UseVendorDialogSaveArgs = { allVendors: VendorDropdownItem[]; pendingCreates: Record; selectedKey: string; + vendorId: string | number; + originalCompany: string; selectedVendor: VendorDropdownItem | undefined; resolvedCompany: string; anchorCompanyName: string; @@ -20,6 +22,138 @@ type UseVendorDialogSaveArgs = { canSubmit: boolean; }; +type VendorResolutionArgs = Pick< + UseVendorDialogSaveArgs, + "allVendors" | "selectedKey" | "selectedVendor" | "resolvedCompany" | "anchorCompanyName" | "tech" +>; + +function resolveSaveVendor(args: VendorResolutionArgs): VendorDropdownItem | undefined { + return ( + (args.selectedKey + ? args.allVendors.find((vendor) => String(vendor.id) === args.selectedKey) + : undefined) ?? + args.allVendors.find( + (vendor) => + vendor.contactName === args.tech && + vendor.companyName === (args.resolvedCompany || args.anchorCompanyName), + ) ?? + args.selectedVendor + ); +} + +async function resolvePersistedVendor(args: { + vendor: VendorDropdownItem | undefined; + allVendors: VendorDropdownItem[]; + pendingCreates: Record; + phone: string; + addTechnicians: ReturnType; + setSaveError: (error: string | null) => void; +}): Promise { + const payload = args.vendor ? args.pendingCreates[String(args.vendor.id)] : undefined; + if (!args.vendor || !isDraftVendorId(args.vendor.id) || !payload) return args.vendor; + try { + return await persistDraftTechnician({ + allVendors: args.allVendors, + pendingPayload: payload, + phone: args.phone, + addTechnicians: args.addTechnicians, + }); + } catch (error) { + args.setSaveError(error instanceof Error ? error.message : "Failed to create technician"); + return null; + } +} + +function resolveVendorForSave(args: { + vendor: VendorDropdownItem | undefined; + allVendors: VendorDropdownItem[]; + pendingCreates: Record; + phone: string; + addTechnicians: ReturnType; + setSaveError: (error: string | null) => void; +}): VendorDropdownItem | undefined | Promise { + const payload = args.vendor ? args.pendingCreates[String(args.vendor.id)] : undefined; + if (!args.vendor || !isDraftVendorId(args.vendor.id) || !payload) return args.vendor; + return resolvePersistedVendor(args); +} + +function isNoTechnician(key: string): boolean { + return key === "" || key === "__no_technician__"; +} + +function resolveAssignmentVendorId(args: { + noTechnician: boolean; + saveVendor: VendorDropdownItem | undefined; + vendorId: string | number; + resolvedCompany: string; + originalCompany: string; +}): string { + if (!args.noTechnician && args.saveVendor) return String(args.saveVendor.id); + if ( + args.noTechnician && + args.resolvedCompany === args.originalCompany && + args.vendorId !== "" && + args.vendorId != null + ) { + return String(args.vendorId); + } + return args.noTechnician && args.saveVendor ? String(args.saveVendor.id) : ""; +} + +function buildVendorPatch(args: { + selectedKey: string; + saveVendor: VendorDropdownItem | undefined; + resolvedCompany: string; + anchorCompanyName: string; + tech: string; + phone: string; + vendorId: string | number; + originalCompany: string; +}): VendorPatch { + const assigned = args.selectedKey !== "" && !isNoTechnician(args.selectedKey); + const noTechnician = isNoTechnician(args.selectedKey); + return { + vendorId: resolveAssignmentVendorId({ + noTechnician, + saveVendor: args.saveVendor, + vendorId: args.vendorId, + resolvedCompany: args.resolvedCompany, + originalCompany: args.originalCompany, + }), + company: args.resolvedCompany || args.anchorCompanyName, + tech: assigned ? (args.saveVendor?.contactName ?? args.tech) : "", + techPhone: assigned ? args.phone : "", + technicianAssigned: assigned, + }; +} + +function commitVendorPatch(args: { + saveVendor: VendorDropdownItem | undefined; + selectedKey: string; + resolvedCompany: string; + anchorCompanyName: string; + tech: string; + phone: string; + vendorId: string | number; + originalCompany: string; + onSave: (patch: VendorPatch) => void; + onOpenChange: (open: boolean) => void; +}): void { + args.onSave( + buildVendorPatch({ + saveVendor: args.saveVendor, + selectedKey: args.selectedKey, + resolvedCompany: args.resolvedCompany, + anchorCompanyName: args.anchorCompanyName, + tech: args.tech, + phone: args.phone, + vendorId: args.vendorId, + originalCompany: args.originalCompany, + }), + ); + args.onOpenChange(false); +} + /** Persists a staged draft technician under its existing company (additive roster PATCH). */ async function persistDraftTechnician({ allVendors, @@ -56,6 +190,8 @@ export function useVendorDialogSave({ allVendors, pendingCreates, selectedKey, + vendorId, + originalCompany, selectedVendor, resolvedCompany, anchorCompanyName, @@ -67,43 +203,54 @@ export function useVendorDialogSave({ setSaveError, canSubmit, }: UseVendorDialogSaveArgs) { - const resolveSaveVendor = () => - allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? - allVendors.find( - (vendor) => - vendor.contactName === tech && - vendor.companyName === (resolvedCompany || anchorCompanyName), - ) ?? - selectedVendor; - const handleSave = async () => { if (!canSubmit) return; setSaveError(null); - let saveVendor = resolveSaveVendor(); - const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; - - if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) { - try { - saveVendor = await persistDraftTechnician({ - allVendors, - pendingPayload, - phone, - addTechnicians, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create technician"; - setSaveError(message); - return; - } - } - - onSave({ - vendorId: saveVendor ? String(saveVendor.id) : "", - company: resolvedCompany || anchorCompanyName, - tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", - techPhone: selectedKey ? phone : "", + const vendor = resolveSaveVendor({ + allVendors, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech, + }); + const resolvedVendor = resolveVendorForSave({ + vendor, + allVendors, + pendingCreates, + phone, + addTechnicians, + setSaveError, + }); + if (resolvedVendor instanceof Promise) { + const saveVendor = await resolvedVendor; + if (saveVendor === null) return; + commitVendorPatch({ + saveVendor, + selectedKey, + resolvedCompany, + anchorCompanyName, + tech, + phone, + vendorId, + originalCompany, + onSave, + onOpenChange, + }); + return; + } + commitVendorPatch({ + saveVendor: resolvedVendor, + selectedKey, + resolvedCompany, + anchorCompanyName, + tech, + phone, + vendorId, + originalCompany, + onSave, + onOpenChange, }); - onOpenChange(false); }; return { handleSave }; 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 1cfac800..88ee65b9 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 @@ -10,6 +10,7 @@ type VendorCellProps = { company: string; tech: string; techPhone?: string; + technicianAssigned?: boolean | null; vendors: VendorDropdownItem[]; q?: string; viewOnly?: boolean; @@ -22,6 +23,7 @@ export function VendorCell({ company, tech, techPhone, + technicianAssigned, vendors, q, viewOnly = false, @@ -29,7 +31,9 @@ export function VendorCell({ onSave, }: VendorCellProps) { const [open, setOpen] = useState(false); - const hasAny = !!(tech || company); + const effectiveTech = + technicianAssigned === false || frozenCompany?.technicianAssigned === false ? "" : tech; + const hasAny = !!(effectiveTech || company); return ( <> @@ -44,9 +48,13 @@ export function VendorCell({ {hasAny ? (
- {tech ? : } + {effectiveTech ? ( + + ) : ( + + )}
- {Boolean(tech && company) && ( + {Boolean(effectiveTech && company) && (
@@ -64,6 +72,7 @@ export function VendorCell({ company={company} tech={tech} techPhone={techPhone} + technicianAssigned={technicianAssigned ?? frozenCompany?.technicianAssigned} vendors={vendors} viewOnly={viewOnly} frozenCompany={frozenCompany} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 62e69494..e8ce0f2e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -15,6 +15,7 @@ import type { TechnicianOption, } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row"; +import { NO_TECHNICIAN_KEY } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; type VendorDialogBodyProps = { selectedCompany: string; @@ -29,6 +30,7 @@ type VendorDialogBodyProps = { resolvedCompany: string; selectedVendor: VendorDropdownItem | undefined; frozenCompany?: WorkOrderFrozenCompany; + technicianAssigned?: boolean | null; saveError: string | null; onCompanyPick: (company: string) => void; onPick: (key: string) => void; @@ -54,6 +56,7 @@ export function VendorDialogBody({ resolvedCompany, selectedVendor, frozenCompany, + technicianAssigned, saveError, onCompanyPick, onPick, @@ -100,7 +103,9 @@ export function VendorDialogBody({ Technician (optional) {frozenCompany != null ? (
- {frozenCompany.contactName?.trim() || tech || "—"} + {technicianAssigned === false || frozenCompany.technicianAssigned === false + ? "—" + : frozenCompany.contactName?.trim() || tech || "—"}
) : ( } searchPlaceholder="Search technician…" - options={technicianOptions.map((option) => ({ - key: option.key, - label: option.name, - }))} + options={[ + ...technicianOptions.map((option) => ({ + key: option.key, + label: option.name, + })), + { key: NO_TECHNICIAN_KEY, label: "No technician" }, + ]} onPick={onPick} /> )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types.ts b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types.ts index b2e35c0a..536f65d6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types.ts @@ -3,4 +3,5 @@ export type VendorPatch = { company: string; tech: string; techPhone: string; + technicianAssigned?: boolean; }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx index c9bf6b21..ee055cc7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx @@ -6,6 +6,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body"; import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer"; import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form"; +import { NO_TECHNICIAN_KEY } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row"; @@ -25,6 +26,7 @@ type VendorDialogProps = { company: string; tech: string; techPhone?: string; + technicianAssigned?: boolean | null; vendors: VendorDropdownItem[]; viewOnly?: boolean; frozenCompany?: WorkOrderFrozenCompany; @@ -38,6 +40,7 @@ export function VendorDialog({ company, tech, techPhone = "", + technicianAssigned, vendors, viewOnly = false, frozenCompany, @@ -50,9 +53,24 @@ export function VendorDialog({ company, tech, techPhone, + technicianAssigned, vendors, onSave, }); + const hasDraftTechnician = form.selectedKey !== "" && form.selectedKey !== NO_TECHNICIAN_KEY; + const draftTechnician = hasDraftTechnician ? (form.selected?.name ?? form.t) : ""; + const displayTechnician = frozenCompany + ? frozenCompany.technicianAssigned === false + ? "" + : (frozenCompany.contactName ?? draftTechnician) + : draftTechnician; + const displayTechnicianPhone = frozenCompany + ? frozenCompany.technicianAssigned === false + ? "" + : (frozenCompany.phone ?? form.phone) + : hasDraftTechnician + ? form.phone + : ""; return ( onPatch(toVendorTablePatch(p))} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index a9b51892..2990e29b 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -77,6 +77,7 @@ export function WoTableRowServiceCells({ company={company.name ?? row.company} tech={company.contactName ?? row.tech} techPhone={company.phone ?? row.techPhone} + technicianAssigned={row.technicianAssigned ?? row.frozenCompany?.technicianAssigned} vendors={vendors} q={search} viewOnly={viewOnly} 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 a18f1550..d15ade1e 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 @@ -30,9 +30,15 @@ export function buildCreatedTechniciansPatch( 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 === "") { + if ( + created.length === 1 && + only != null && + draft.technicianSelectionCleared !== true && + (draft.technicianAssigned === false || draft.vendorId === "") + ) { patch.vendorId = String(only.id); patch.vendorName = company; + patch.technicianAssigned = true; patch.techPhone = pending[0]?.phone.trim() || draft.techPhone; } return patch; 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 24e78e03..208bb513 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 @@ -190,7 +190,10 @@ export function WizardStepVendorTime({ onUpdate, onPatch, }: WizardStepVendorTimeProps) { - const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId); + const selectedVendor = + draft.technicianAssigned === false + ? undefined + : vendors.find((v) => String(v.id) === draft.vendorId); const statusValue = (draft.status || "Incomplete") as WizardWOStatus; const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles); 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 d74ba779..e0351b2b 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 @@ -5,6 +5,7 @@ import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/li import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians"; +import { NO_TECHNICIAN_KEY } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; type WizardVendorCompanyFieldsProps = { draft: WorkOrderWizardDraft; @@ -49,6 +50,15 @@ export function WizardVendorCompanyFields({ key: String(vendor.id), label: vendor.contactName.trim() || vendor.companyName, })); + const companyAnchor = vendors.find((vendor) => vendor.companyName === company); + const currentVendorInCompany = vendors.some( + (vendor) => String(vendor.id) === draft.vendorId && vendor.companyName === company, + ); + const noTechnicianVendorId = currentVendorInCompany + ? draft.vendorId + : companyAnchor + ? String(companyAnchor.id) + : ""; return ( <> @@ -66,6 +76,7 @@ export function WizardVendorCompanyFields({ techPhone: "", vendorNotes: "", newTechnicians: [], + technicianSelectionCleared: false, }) } > @@ -77,20 +88,23 @@ export function WizardVendorCompanyFields({ icon={} searchPlaceholder="Search company…" options={companyOptions.map((option) => ({ key: option.key, label: option.label }))} - onPick={(nextCompany) => + onPick={(nextCompany) => { + const nextAnchor = vendors.find((vendor) => vendor.companyName === nextCompany); onPatch({ vendorCompany: nextCompany, ...(nextCompany !== company ? { - vendorId: "", - vendorName: "", + vendorId: nextAnchor ? String(nextAnchor.id) : "", + vendorName: nextAnchor ? nextCompany : "", + technicianAssigned: false, + technicianSelectionCleared: false, techPhone: "", vendorNotes: "", newTechnicians: [], } : {}), - }) - } + }); + }} />
@@ -98,15 +112,27 @@ export function WizardVendorCompanyFields({ onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" }) + draft.vendorId !== "" || draft.technicianAssigned === true + ? () => + onPatch({ + vendorId: noTechnicianVendorId, + vendorName: companyAnchor ? company : "", + technicianAssigned: false, + technicianSelectionCleared: true, + techPhone: "", + vendorNotes: "", + }) : undefined } > Technician { + if (id === NO_TECHNICIAN_KEY) { + onPatch({ + vendorId: noTechnicianVendorId, + vendorName: companyAnchor ? company : "", + technicianAssigned: false, + technicianSelectionCleared: true, + techPhone: "", + }); + return; + } const vendor = vendors.find((v) => String(v.id) === id); onPatch({ vendorCompany: vendor?.companyName ?? company, vendorId: id, vendorName: vendor?.companyName ?? "", + technicianAssigned: true, + technicianSelectionCleared: false, techPhone: "", }); }} diff --git a/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts index 43179ba9..b034bb5e 100644 --- a/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts @@ -11,10 +11,13 @@ export function assignWizardVendorApptFields( draft: WorkOrderWizardDraft, ): void { const vendorId = draft.vendorId.trim() ? Number(draft.vendorId) : NaN; - const hasVendor = Number.isFinite(vendorId); + const hasVendor = Number.isFinite(vendorId) && vendorId > 0; if (hasVendor) { payload.vendorId = vendorId; + if (draft.technicianAssigned != null) { + payload.technicianAssigned = draft.technicianAssigned; + } } const apptTime = formatApptTimeValue(draft.apptStart, draft.apptEnd); diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index 7266fbec..c91f11b6 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -7,6 +7,7 @@ import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mappe export type BoardFieldOp = { field: WorkOrderBoardEditableField; value: string; + technicianAssigned?: boolean; }; export { mapDocStatusToApi }; @@ -57,7 +58,13 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] for (const rule of SCALAR_BOARD_OP_RULES) { const value = patch[rule.key]; if (value === undefined) continue; - ops.push({ field: rule.field, value: rule.toValue(value) }); + ops.push({ + field: rule.field, + value: rule.toValue(value), + ...(rule.key === "vendorId" && patch.technicianAssigned != null + ? { technicianAssigned: patch.technicianAssigned } + : {}), + }); } return ops; diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index 65a5dc02..3f3b2f17 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -108,6 +108,10 @@ export function buildBoardPatchPayload( workOrderVersion: row.rowVersion, }; + if (op.field === "vendorId" && op.technicianAssigned != null) { + payload.technicianAssigned = op.technicianAssigned; + } + if (isDispatchBoardField(op.field)) { if (row.dispatchRowVersion) { payload.dispatchVersion = row.dispatchRowVersion; diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 303b03fa..46b3acc8 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -285,6 +285,8 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail { const wo = asRecord(raw); + const technicianAssigned = + "technicianAssigned" in wo ? wo.technicianAssigned : wo.TechnicianAssigned; const locationId = wo.locationId ?? wo.LocationId; const contactIds = readOptionalIdList(wo, "contactIds", "ContactIds", "contacts", "Contacts"); const categoryIds = readOptionalIdList( @@ -296,6 +298,9 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail { ); return { + ...(typeof technicianAssigned === "boolean" || technicianAssigned === null + ? { technicianAssigned: technicianAssigned as boolean | null } + : {}), id: readId(wo), internalWONumber: readString(wo, "internalWONumber", "InternalWONumber"), workOrderNumber: readString(wo, "workerOrderNumber", "workOrderNumber", "WorkerOrderNumber"), diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 2f019989..b325abe3 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -72,6 +72,38 @@ function readBool(record: Record, ...keys: string[]): boolean { return false; } +function readNullableBool( + record: Record, + ...keys: string[] +): boolean | null | undefined { + for (const key of keys) { + if (!(key in record)) continue; + const value = record[key]; + return typeof value === "boolean" ? value : null; + } + return undefined; +} + +function resolveTechnicianAssignment( + rowAssigned: boolean | null | undefined, + frozenAssigned: boolean | null | undefined, +): boolean | null | undefined { + if (rowAssigned === false || frozenAssigned === false) return false; + if (rowAssigned !== undefined) return rowAssigned; + return frozenAssigned; +} + +function readEffectiveTechnicianFields( + item: Record, + assigned: boolean | null | undefined, +): { tech: string; techPhone: string } { + if (assigned === false) return { tech: "", techPhone: "" }; + return { + tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), + techPhone: readString(item, "techPhone", "TechPhone"), + }; +} + function readStringArray(record: Record, ...keys: string[]): string[] { for (const key of keys) { const value = record[key]; @@ -293,9 +325,11 @@ function readFrozenSite(item: Record): WorkOrderFrozenSite | un function readFrozenCompany(item: Record): WorkOrderFrozenCompany | undefined { const source = asRecord(item.frozenCompany ?? item.FrozenCompany); if (Object.keys(source).length === 0) return undefined; + const technicianAssigned = readNullableBool(source, "technicianAssigned", "TechnicianAssigned"); return { name: readString(source, "name", "Name"), contactName: readString(source, "contactName", "ContactName"), + ...(technicianAssigned !== undefined ? { technicianAssigned } : {}), address: readString(source, "address", "Address"), city: readString(source, "city", "City"), state: readString(source, "state", "State"), @@ -380,6 +414,13 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const boardUpliftSummary = readUpliftSummary(item); const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary); const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item); + const frozenCompany = readFrozenCompany(item); + const rowTechnicianAssigned = readNullableBool(item, "technicianAssigned", "TechnicianAssigned"); + const technicianAssigned = resolveTechnicianAssignment( + rowTechnicianAssigned, + frozenCompany?.technicianAssigned, + ); + const technician = readEffectiveTechnicianFields(item, technicianAssigned); const row: WorkOrderTableRow = { id: readId(item), @@ -406,8 +447,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { vendorId: vendorIdNum != null ? String(vendorIdNum) : "", company: readString(item, "company", "Company", "vendorName", "VendorName"), primaryDispatchStatus: readPrimaryDispatchStatus(item), - tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), - techPhone: readString(item, "techPhone", "TechPhone"), + tech: technician.tech, + techPhone: technician.techPhone, + ...(technicianAssigned !== undefined ? { technicianAssigned } : {}), apptStart, apptEnd, apptTime, @@ -418,7 +460,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"), additionalContacts: readAdditionalContacts(item), frozenSite: readFrozenSite(item), - frozenCompany: readFrozenCompany(item), + frozenCompany, frozenPoc: readFrozenPoc(item), dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")), rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0, diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts index 06cf70e3..8ba3c56e 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -42,6 +42,7 @@ export interface WorkOrderBoardDetailInfoApi { scheduleWeekOnly?: boolean | null; dayGroup?: string | null; vendorId?: number | null; + technicianAssigned?: boolean | null; vendorName?: string | null; primaryDispatchStatus?: string | null; techName?: string | null; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 6bfb0e28..e5d8a619 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -98,6 +98,8 @@ export interface WorkOrderBoardCreatePayload { */ isAddOn?: boolean; vendorId?: number; + /** Explicit technician choice; null/omitted retains legacy vendor inference. */ + technicianAssigned?: boolean | null; apptDate?: string; apptTime?: string; docStatus?: DocStatusApi; @@ -131,6 +133,8 @@ export interface WorkOrderBoardCreatePayload { export interface WorkOrderBoardPatchPayload { field: string; value?: string | null; + /** Atomic with vendorId PATCH; never sent as an independent field operation. */ + technicianAssigned?: boolean; workOrderVersion: string; dispatchVersion?: string | null; primaryDispatchId?: number | null; @@ -193,6 +197,8 @@ export interface WorkOrderBoardRowApi { additionalContacts?: WorkOrderAdditionalContact[] | null; frozenSite?: WorkOrderFrozenSite | null; frozenCompany?: WorkOrderFrozenCompany | null; + /** Work-order-owned assignment state; null/omitted means legacy inference. */ + technicianAssigned?: boolean | null; frozenPoc?: WorkOrderFrozenPoc | null; lifecycleStatus?: number | null; lifecycleStatusLabel?: string | null; diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts index 8ca7bd01..7b66eaad 100644 --- a/src/domain/work-orders/types/work-order-detail.ts +++ b/src/domain/work-orders/types/work-order-detail.ts @@ -31,6 +31,7 @@ export interface WorkOrderAuditEntry { } export interface WorkOrderDetail { + technicianAssigned?: boolean | null; id: string | number; internalWONumber: string; workOrderNumber: string; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 179f6c95..0dd54c32 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -28,6 +28,7 @@ export type WorkOrderFrozenSite = { export type WorkOrderFrozenCompany = { name?: string; contactName?: string; + technicianAssigned?: boolean | null; address?: string; city?: string; state?: string; @@ -70,6 +71,8 @@ export interface WorkOrderTableRow { primaryDispatchStatus?: string; tech: string; techPhone: string; + /** Work-order-owned assignment state; null/omitted means legacy inference. */ + technicianAssigned?: boolean | null; apptStart: string; apptEnd: string; apptTime: string; @@ -133,6 +136,7 @@ export interface WorkOrderTableDraft { company: string; tech: string; techPhone: string; + technicianAssigned?: boolean | null; apptStart: string; apptEnd: string; status: WizardWOStatus | ""; diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index 32cf5e7c..3a3416e1 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -54,6 +54,10 @@ export interface WorkOrderWizardDraft { vendorName: string; /** Company picked on the Vendor & time step; scopes the technician list. */ vendorCompany?: string; + /** Explicit technician choice; null/omitted retains legacy inference. */ + technicianAssigned?: boolean | null; + /** UI intent: an explicit clear overrides staged single-technician autoassignment. */ + technicianSelectionCleared?: boolean; /** Technicians registered inline; persisted under `vendorCompany` only on create. */ newTechnicians?: WizardNewTechnician[]; techPhone: string; diff --git a/src/domain/work-orders/utils/completion-doc-site.ts b/src/domain/work-orders/utils/completion-doc-site.ts index 1546fdcc..bc5355c6 100644 --- a/src/domain/work-orders/utils/completion-doc-site.ts +++ b/src/domain/work-orders/utils/completion-doc-site.ts @@ -41,7 +41,7 @@ function withCompDocIdentity(address: CompDocAddress, row: CompDocIdentityRow): return { ...address, code: frozen?.code ?? row.site, - tech: company.contactName ?? "", + tech: company.technicianAssigned === false ? "" : (company.contactName ?? ""), company: company.name ?? "", }; } diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 7135bd01..af8c2679 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -31,6 +31,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "avetaRequired", "additionalContacts", "vendorId", + "technicianAssigned", // company/tech are display companions; board persists vendor via vendorId only "status", "pocName", @@ -91,6 +92,7 @@ export function buildSlideOverPatch( const patch: WorkOrderTablePatch = {}; for (const key of SLIDE_OVER_EDIT_KEYS) { + if (key === "technicianAssigned") continue; if (key === "extraServices") { if (!sameExtraServices(draft.extraServices, baseline.extraServices)) { patch.extraServices = draft.extraServices; @@ -108,5 +110,16 @@ export function buildSlideOverPatch( } } + const assignmentChanged = draft.technicianAssigned !== baseline.technicianAssigned; + const vendorChanged = draft.vendorId !== baseline.vendorId; + if ( + typeof draft.technicianAssigned === "boolean" && + (assignmentChanged || vendorChanged) && + String(draft.vendorId).trim() !== "" + ) { + patch.vendorId = draft.vendorId; + patch.technicianAssigned = draft.technicianAssigned; + } + return patch; } diff --git a/src/domain/work-orders/utils/vendor-assignment-patch.ts b/src/domain/work-orders/utils/vendor-assignment-patch.ts index 0f39e146..cff59463 100644 --- a/src/domain/work-orders/utils/vendor-assignment-patch.ts +++ b/src/domain/work-orders/utils/vendor-assignment-patch.ts @@ -5,11 +5,12 @@ export type VendorAssignmentFields = { company: string; tech: string; techPhone: string; + technicianAssigned?: boolean; }; export type VendorTablePatch = Pick< WorkOrderTableRow, - "vendorId" | "company" | "tech" | "techPhone" | "primaryDispatchStatus" + "vendorId" | "company" | "tech" | "techPhone" | "technicianAssigned" | "primaryDispatchStatus" >; export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTablePatch { @@ -18,6 +19,9 @@ export function toVendorTablePatch(fields: VendorAssignmentFields): VendorTableP company: fields.company, tech: fields.tech, techPhone: fields.techPhone, + ...(fields.technicianAssigned !== undefined + ? { technicianAssigned: fields.technicianAssigned } + : {}), primaryDispatchStatus: "", }; } 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 cebf4d66..6eed4b1a 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 @@ -93,6 +93,7 @@ describe("VendorDialog save gate", () => { vendorId: "11", tech: "Casey New", company: "Gateway Plumbing", + technicianAssigned: true, }), ); }); @@ -122,10 +123,69 @@ describe("VendorDialog save gate", () => { company: "Gateway Plumbing", tech: "", techPhone: "", + technicianAssigned: false, }), ); }); + it("clears a selected technician while retaining the same company vendor anchor", async () => { + const onSave = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /casey new/i })); + fireEvent.click(await screen.findByRole("button", { name: "No technician" })); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + expect(onSave).toHaveBeenCalledWith({ + vendorId: "11", + company: "Gateway Plumbing", + tech: "", + techPhone: "", + technicianAssigned: false, + }); + }); + + it("shows the editable phone for a newly selected technician and hides it for No technician", async () => { + renderWithProviders( + , + ); + + expect(document.querySelector('input[type="tel"]')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "No technician" })); + fireEvent.click(await screen.findByRole("button", { name: /jordan lee/i })); + + const phone = document.querySelector('input[type="tel"]'); + expect(phone).toBeVisible(); + fireEvent.change(phone!, { target: { value: "(314) 555-0141" } }); + expect(phone).toHaveValue("(314) 555-0141"); + + fireEvent.click(await screen.findByRole("button", { name: /jordan lee/i })); + fireEvent.click(await screen.findByRole("button", { name: "No technician" })); + expect(document.querySelector('input[type="tel"]')).not.toBeInTheDocument(); + }); + it("only offers technicians from the selected company", async () => { renderWithProviders( { expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Gateway Plumbing", - vendorId: "", - vendorName: "", + vendorId: "52", + vendorName: "Gateway Plumbing", + technicianAssigned: false, + technicianSelectionCleared: false, techPhone: "", vendorNotes: "", newTechnicians: [], }); }); + it("keeps the existing company anchor when company is chosen without a technician", async () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /select company/i })); + fireEvent.click(await screen.findByRole("button", { name: "Vinewood LLC" })); + + expect(onPatch).toHaveBeenCalledWith({ + vendorCompany: "Vinewood LLC", + vendorId: "41", + vendorName: "Vinewood LLC", + technicianAssigned: false, + technicianSelectionCleared: false, + techPhone: "", + vendorNotes: "", + newTechnicians: [], + }); + }); + + it("marks the exact selected technician as assigned", async () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "No technician" })); + fireEvent.click(await screen.findByRole("button", { name: "Adam Whyte" })); + + expect(onPatch).toHaveBeenLastCalledWith({ + vendorCompany: "Vinewood LLC", + vendorId: "41", + vendorName: "Vinewood LLC", + technicianAssigned: true, + technicianSelectionCleared: false, + techPhone: "", + }); + }); + + it("records explicit no-technician intent while leaving a staged technician untouched", async () => { + const onPatch = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "No technician" })); + const noTechnicianOptions = screen.getAllByRole("button", { name: "No technician" }); + fireEvent.click(noTechnicianOptions[noTechnicianOptions.length - 1]!); + + expect(onPatch).toHaveBeenCalledWith({ + vendorId: "41", + vendorName: "Vinewood LLC", + technicianAssigned: false, + technicianSelectionCleared: true, + techPhone: "", + }); + expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument(); + }); + + it("retains the same company vendor id when clearing a second technician", async () => { + const onPatch = vi.fn(); + const secondVinewoodTech = { + ...vendors[0], + id: 42, + contactName: "Second Tech", + }; + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Second Tech/u })); + fireEvent.click(await screen.findByRole("button", { name: "No technician" })); + + expect(onPatch).toHaveBeenCalledWith({ + vendorId: "42", + vendorName: "Vinewood LLC", + technicianAssigned: false, + technicianSelectionCleared: true, + techPhone: "", + }); + }); + it("preserves the picked vendor and staged technicians when the same company is re-picked", () => { const onPatch = vi.fn(); render( @@ -192,11 +313,72 @@ describe("useWizardCreateWithTechnicians (SH-364)", () => { vendorId: "99", vendorName: "Vinewood LLC", techPhone: "(314) 555-0199", + technicianAssigned: true, }; expect(patchDraft).toHaveBeenCalledWith(expectedPatch); expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch)); }); + it.each([ + ["legacy selection", { vendorId: "41" }, { newTechnicians: [] }], + ["explicit selection", { vendorId: "41", technicianAssigned: true }, { newTechnicians: [] }], + ])("preserves the existing %s when one technician is staged", (_caseName, existing, expected) => { + const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" }; + const patch = buildCreatedTechniciansPatch( + draftWith({ + vendorCompany: "Vinewood LLC", + ...existing, + newTechnicians: [{ name: "Jordan Lee", phone: "" }], + }), + "Vinewood LLC", + [{ name: "Jordan Lee", phone: "" }], + [created], + ); + + expect(patch).toEqual(expected); + }); + + it("assigns the only new technician when the existing company is explicitly company-only", () => { + const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" }; + expect( + buildCreatedTechniciansPatch( + draftWith({ + vendorCompany: "Vinewood LLC", + vendorId: "41", + technicianAssigned: false, + newTechnicians: [{ name: "Jordan Lee", phone: "" }], + }), + "Vinewood LLC", + [{ name: "Jordan Lee", phone: "" }], + [created], + ), + ).toEqual({ + newTechnicians: [], + vendorId: "99", + vendorName: "Vinewood LLC", + technicianAssigned: true, + techPhone: "", + }); + }); + + it("does not autoassign a staged technician after No technician was explicitly chosen", () => { + const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" }; + expect( + buildCreatedTechniciansPatch( + draftWith({ + vendorCompany: "Vinewood LLC", + vendorId: "41", + technicianAssigned: false, + technicianSelectionCleared: true, + newTechnicians: [{ name: "Jordan Lee", phone: "" }], + }), + "Vinewood LLC", + [{ name: "Jordan Lee", phone: "" }], + [created], + ), + ).toEqual({ newTechnicians: [] }); + }); + 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(() => diff --git a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx index a276e515..e1cc7139 100644 --- a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx @@ -12,6 +12,50 @@ function currentMonthIso(day: number): string { } describe("WoTableInlineRowCells — Schedule On range", () => { + it("reopens a company-only inline assignment without inferring its anchor as technician", async () => { + renderWithProviders( + + + + + + +
, + ); + + fireEvent.click(screen.getByRole("button", { name: /Vinewood LLC/ })); + expect(await screen.findByRole("button", { name: "No technician" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Adam Whyte" })).not.toBeInTheDocument(); + }, 10_000); + it("patches schedMode range with start and end", async () => { const onPatch = vi.fn(); renderWithProviders( diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index b82d970c..cb5dca07 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -77,6 +77,20 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload).not.toHaveProperty("customer"); }); + it("omits wizard-only technician selection intent from the create payload", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + vendorId: "41", + technicianAssigned: false, + technicianSelectionCleared: true, + }); + + expect(payload).toEqual(expect.objectContaining({ vendorId: 41, technicianAssigned: false })); + expect(payload).not.toHaveProperty("technicianSelectionCleared"); + }); + it("allows create mapping without a service", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, @@ -172,6 +186,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { scheduleWeekOnly: false, vendorId: "5", + technicianAssigned: false, apptStart: "09:00", @@ -214,6 +229,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { isAddOn: true, vendorId: 5, + technicianAssigned: false, apptDate: "2026-06-25", @@ -336,6 +352,17 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.apptTime).toBeUndefined(); }); + it("creates with the selected technician id and explicit assignment state", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + vendorId: "11", + technicianAssigned: true, + }); + expect(payload).toMatchObject({ vendorId: 11, technicianAssigned: true }); + }); + it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index ab852413..9eec3d6d 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -178,9 +178,10 @@ describe("expandTablePatchToBoardFieldOps", () => { company: "New Co", tech: "Pat", techPhone: "555", + technicianAssigned: false, }), ), - ).toEqual([{ field: "vendorId", value: "9" }]); + ).toEqual([{ field: "vendorId", value: "9", technicianAssigned: false }]); expect( expandTablePatchToBoardFieldOps({ @@ -259,6 +260,32 @@ describe("buildBoardPatchPayload", () => { }); }); + it("keeps technician choice in the same vendorId PATCH envelope", () => { + const op = expandTablePatchToBoardFieldOps({ + vendorId: "11", + technicianAssigned: false, + company: "Gateway Plumbing", + tech: "", + techPhone: "", + })[0]; + expect(op).toEqual({ field: "vendorId", value: "11", technicianAssigned: false }); + expect( + buildBoardPatchPayload( + op as { field: "vendorId"; value: string; technicianAssigned: boolean }, + { + rowVersion: "wo-v1", + dispatchRowVersion: "disp-v1", + primaryDispatchId: 9, + }, + ), + ).toMatchObject({ + field: "vendorId", + value: "11", + technicianAssigned: false, + primaryDispatchId: 9, + }); + }); + it("omits non-positive primaryDispatchId on dispatch fields", () => { const payload = buildBoardPatchPayload( { field: "vendorId", value: "7" }, diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 9315fd9d..a141b74d 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -212,6 +212,57 @@ describe("mapWorkOrderTableRow completion snapshot", () => { }); }); + it("honors explicit no-technician on the returned row and frozen company snapshot", () => { + const current = mapWorkOrderTableRow({ + id: 201, + vendorId: 10, + vendorName: "Gateway Plumbing", + technicianAssigned: false, + techName: null, + techPhone: null, + }); + expect(current).toMatchObject({ + vendorId: "10", + company: "Gateway Plumbing", + technicianAssigned: false, + tech: "", + techPhone: "", + }); + + const frozen = mapWorkOrderTableRow({ + id: 202, + vendorId: 10, + vendorName: "Gateway Plumbing", + techName: "Jordan Lee", + techPhone: "555-0100", + frozenCompany: { + name: "Gateway Plumbing", + contactName: "Jordan Lee", + phone: "555-0100", + technicianAssigned: false, + }, + }); + expect(frozen.tech).toBe(""); + expect(frozen.techPhone).toBe(""); + expect(frozen.frozenCompany).toMatchObject({ + contactName: "Jordan Lee", + technicianAssigned: false, + }); + }); + + it("preserves legacy technician inference when the flag is missing or null", () => { + for (const technicianAssigned of [undefined, null]) { + const row = mapWorkOrderTableRow({ + id: 203, + vendorId: 10, + techName: "Jordan Lee", + ...(technicianAssigned !== undefined ? { technicianAssigned } : {}), + }); + expect(row.tech).toBe("Jordan Lee"); + expect(row.technicianAssigned).toBe(technicianAssigned); + } + }); + it("maps frozen POC contact notes when the payload supplies them", () => { const row = mapWorkOrderTableRow({ id: 192, diff --git a/src/test/domain/work-orders/utils/completion-doc-site.test.ts b/src/test/domain/work-orders/utils/completion-doc-site.test.ts index 4a2cb2f9..625df2ce 100644 --- a/src/test/domain/work-orders/utils/completion-doc-site.test.ts +++ b/src/test/domain/work-orders/utils/completion-doc-site.test.ts @@ -59,4 +59,22 @@ describe("resolveCompDocSite", () => { expect(result.street).toBe("900 Live St"); expect(result.label).toBe("Milwaukee, WI"); }); + + it("does not reuse the frozen company contact as a technician when assignment is false", () => { + const result = resolveCompDocSite({ + site: "BK5", + location: "Chicago, IL", + status: "Completed", + company: "Live Co", + tech: "Live Tech", + frozenCompany: { + name: "Frozen Co", + contactName: "Company Contact", + technicianAssigned: false, + }, + }); + + expect(result.company).toBe("Frozen Co"); + expect(result.tech).toBe(""); + }); }); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 2bc85efc..d0bae108 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -65,6 +65,7 @@ describe("slide-over-draft", () => { expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber"); expect(SLIDE_OVER_EDIT_KEYS).toContain("status"); expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId"); + expect(SLIDE_OVER_EDIT_KEYS).toContain("technicianAssigned"); expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts"); expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName"); @@ -87,6 +88,37 @@ describe("slide-over-draft", () => { expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" }); }); + it("sends a same-vendor assignment toggle in the vendorId envelope", () => { + const row = baseRow({ technicianAssigned: true }); + const draft = baseRow({ technicianAssigned: false }); + + expect(isSlideOverDraftDirty(draft, row)).toBe(true); + expect(buildSlideOverPatch(draft, row)).toEqual({ + vendorId: row.vendorId, + technicianAssigned: false, + }); + }); + + it("includes assignment state when the slide-over changes company", () => { + const row = baseRow({ technicianAssigned: false }); + const draft = baseRow({ vendorId: "v2", company: "Other Co", technicianAssigned: false }); + + expect(isSlideOverDraftDirty(draft, row)).toBe(true); + expect(buildSlideOverPatch(draft, row)).toEqual({ + vendorId: "v2", + technicianAssigned: false, + }); + }); + + it("keeps an unchanged legacy assignment clean", () => { + const legacy = baseRow(); + delete (legacy as Partial).technicianAssigned; + const draft = { ...legacy }; + + expect(isSlideOverDraftDirty(draft, legacy)).toBe(false); + expect(buildSlideOverPatch(draft, legacy)).toEqual({}); + }); + it("tracks POC edits for dirty state and patch", () => { const row = baseRow(); const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });