From 5adde3723b2b55ee63ef16fb68e4ed59a0d2c27f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 18:22:30 -0300 Subject: [PATCH] fix(work-orders): preserve explicit empty technician assignment --- e2e/work-orders/inline-technician.spec.ts | 236 +++++++++++++++++- .../comp-doc/use-comp-doc-dialog.tsx | 5 +- .../tabs/slide-over-info-tab-display-view.tsx | 1 + .../tabs/slide-over-info-tab-edit-view.tsx | 1 + .../table/build-inline-draft-create-input.ts | 1 + .../table/cells/resolve-technician-key.ts | 2 + .../table/cells/use-vendor-dialog-form.ts | 31 ++- .../table/cells/use-vendor-dialog-save.ts | 23 +- .../list/table/cells/vendor-cell.tsx | 15 +- .../list/table/cells/vendor-dialog-body.tsx | 18 +- .../list/table/cells/vendor-dialog-types.ts | 1 + .../list/table/cells/vendor-dialog.tsx | 16 +- .../list/table/to-inline-draft-row.ts | 1 + .../list/table/wo-table-row-service-cells.tsx | 1 + .../use-wizard-create-with-technicians.ts | 7 +- .../wizard/wizard-step-vendor-time.tsx | 5 +- .../wizard/wizard-vendor-company-fields.tsx | 51 +++- .../assign-wizard-vendor-appt-fields.ts | 5 +- .../mappers/expand-scalar-board-ops.ts | 9 +- .../mappers/work-order-board-patch-mapper.ts | 4 + .../work-orders/mappers/work-order-mapper.ts | 5 + .../mappers/work-order-table-row-mapper.ts | 32 ++- .../types/work-order-board-detail.ts | 1 + .../work-orders/types/work-order-board.ts | 6 + .../work-orders/types/work-order-detail.ts | 1 + .../work-orders/types/work-order-table-row.ts | 4 + .../work-orders/types/work-order-wizard.ts | 2 + .../work-orders/utils/completion-doc-site.ts | 2 +- .../utils/vendor-assignment-patch.ts | 6 +- .../vendor-dialog-save-gate.test.tsx | 31 +++ .../wizard-vendor-inline-technician.test.tsx | 136 +++++++++- .../mappers/work-order-board-mapper.test.ts | 13 + .../work-order-board-patch-mapper.test.ts | 29 ++- .../work-order-table-row-mapper.test.ts | 51 ++++ .../utils/completion-doc-site.test.ts | 18 ++ 35 files changed, 722 insertions(+), 48 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index bcac5250..bb1fcae5 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,59 @@ 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 (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 (/^\/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", + } + : {}), + 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 +177,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 +267,171 @@ test.describe("Inline technician registration", () => { expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 })); }); + test("company-only save returns no technician and stays empty after reopen and reload", async ({ + page, + }) => { + const writes: Captured[] = []; + await mockApi(page, writes); + 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: /Gateway Plumbing/ }) + .first() + .click(); + await dialog.getByRole("button", { name: "Coastal HVAC" }).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 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.getByRole("button", { name: /completed/i })).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, + 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 dialog.getByRole("button", { name: "Adam Whyte" }).click(); + 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(); + await expect( + page.getByRole("dialog").getByRole("button", { name: "Adam Whyte" }), + ).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.filter((write) => write.pathname === "/api/workorders/1/board").length) + .toBe(2); + await expect(reloadedRow.getByRole("button", { name: /completed/i })).toBeVisible(); + expect( + writes.some( + (write) => + write.pathname === "/api/workorders/1/board" && + (write.body as { field?: string; value?: string }).field === "lifecycleStatus", + ), + ).toBe(true); + }); + test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({ page, }) => { 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..f93a1166 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 @@ -116,7 +116,10 @@ export function useCompDocDialog({ amazonRep, seaRep, siteCode: site.code, - tech: company.contactName || "", + tech: + info.technicianAssigned === false || company.technicianAssigned === false + ? "" + : company.contactName || "", company: company.name || "", }; diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index f74bcf78..fe4a798c 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -172,6 +172,7 @@ function SlideOverVendorField({ company={company.name ?? source.company} tech={company.contactName ?? source.tech} techPhone={company.phone ?? source.techPhone} + technicianAssigned={source.technicianAssigned ?? source.frozenCompany?.technicianAssigned} vendors={vendors} viewOnly frozenCompany={source.frozenCompany} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index fc12a91e..248d7281 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -216,6 +216,7 @@ export function SlideOverInfoTabEditView({ company={draft.company} tech={draft.tech} techPhone={draft.techPhone} + technicianAssigned={draft.technicianAssigned} vendors={vendors} onSave={(patch) => 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..49fe9836 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 @@ -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,6 +26,7 @@ type UseVendorDialogFormArgs = { company: string; tech: string; techPhone: string; + technicianAssigned?: boolean | null; vendors: VendorDropdownItem[]; onSave: (patch: VendorPatch) => void; }; @@ -34,13 +38,16 @@ export function useVendorDialogForm({ company, tech, techPhone, + technicianAssigned, vendors, onSave, }: UseVendorDialogFormArgs) { const addTechnicians = useAddTechniciansToCompany(); const [selectedCompany, setSelectedCompany] = useState(company); const [selectedKey, setSelectedKey] = useState(() => - resolveTechnicianKey(vendorId, tech, company, vendors), + technicianAssigned === false + ? NO_TECHNICIAN_KEY + : resolveTechnicianKey(vendorId, tech, company, vendors), ); const [t, setT] = useState(tech); const [phone, setPhone] = useState(techPhone); @@ -66,7 +73,10 @@ export function useVendorDialogForm({ useEffect(() => { if (open) { - const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const key = + technicianAssigned === false + ? NO_TECHNICIAN_KEY + : resolveTechnicianKey(vendorId, tech, company, vendors); const initialOptions = buildTechnicianOptions(vendors, company, tech, company); setSelectedCompany(company); setSelectedKey(key); @@ -78,7 +88,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]); const selected = technicianOptions.find((option) => option.key === selectedKey); const resolvedCompany = selectedCompany; @@ -102,7 +112,10 @@ export function useVendorDialogForm({ }, baseline: { company, - selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), + selectedKey: + technicianAssigned === false + ? NO_TECHNICIAN_KEY + : resolveTechnicianKey(vendorId, tech, company, vendors), phone: techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), notes: "", @@ -114,6 +127,8 @@ export function useVendorDialogForm({ allVendors, pendingCreates, selectedKey, + vendorId, + originalCompany: company, selectedVendor, resolvedCompany, anchorCompanyName, @@ -155,6 +170,12 @@ export function useVendorDialogForm({ } }, handlePick: (key: string) => { + if (key === NO_TECHNICIAN_KEY) { + setSelectedKey(key); + setT(""); + setPhone(""); + return; + } const option = technicianOptions.find((o) => o.key === key); setSelectedKey(key); setT(option?.name ?? key); 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..c8959380 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; @@ -56,6 +58,8 @@ export function useVendorDialogSave({ allVendors, pendingCreates, selectedKey, + vendorId, + originalCompany, selectedVendor, resolvedCompany, anchorCompanyName, @@ -68,7 +72,7 @@ export function useVendorDialogSave({ canSubmit, }: UseVendorDialogSaveArgs) { const resolveSaveVendor = () => - allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? + (selectedKey ? allVendors.find((vendor) => String(vendor.id) === selectedKey) : undefined) ?? allVendors.find( (vendor) => vendor.contactName === tech && @@ -97,11 +101,22 @@ export function useVendorDialogSave({ } } + const noTechnician = selectedKey === "" || selectedKey === "__no_technician__"; + const preservedVendorId = + noTechnician && resolvedCompany === originalCompany && vendorId !== "" && vendorId != null + ? String(vendorId) + : ""; onSave({ - vendorId: saveVendor ? String(saveVendor.id) : "", + vendorId: + selectedKey && !noTechnician + ? saveVendor + ? String(saveVendor.id) + : "" + : preservedVendorId || (noTechnician && saveVendor ? String(saveVendor.id) : ""), company: resolvedCompany || anchorCompanyName, - tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", - techPhone: selectedKey ? phone : "", + tech: selectedKey && !noTechnician ? (saveVendor?.contactName ?? tech) : "", + techPhone: selectedKey && !noTechnician ? phone : "", + technicianAssigned: Boolean(selectedKey && !noTechnician), }); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx index 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..3a0abe1a 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 @@ -25,6 +25,7 @@ type VendorDialogProps = { company: string; tech: string; techPhone?: string; + technicianAssigned?: boolean | null; vendors: VendorDropdownItem[]; viewOnly?: boolean; frozenCompany?: WorkOrderFrozenCompany; @@ -38,6 +39,7 @@ export function VendorDialog({ company, tech, techPhone = "", + technicianAssigned, vendors, viewOnly = false, frozenCompany, @@ -50,6 +52,7 @@ export function VendorDialog({ company, tech, techPhone, + technicianAssigned, vendors, onSave, }); @@ -94,12 +97,21 @@ export function VendorDialog({ technicianOptions={form.technicianOptions} anchorCompanyName={form.anchorCompanyName} companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)} - tech={frozenCompany?.contactName ?? form.selected?.name ?? form.t} - phone={frozenCompany?.phone ?? form.phone} + tech={ + technicianAssigned === false || frozenCompany?.technicianAssigned === false + ? "" + : (frozenCompany?.contactName ?? form.selected?.name ?? form.t) + } + phone={ + technicianAssigned === false || frozenCompany?.technicianAssigned === false + ? "" + : (frozenCompany?.phone ?? form.phone) + } notes={form.notes} resolvedCompany={form.resolvedCompany} selectedVendor={form.selectedVendor} frozenCompany={frozenCompany} + technicianAssigned={technicianAssigned} saveError={form.saveError} onCompanyPick={form.handleCompanyPick} onPick={form.handlePick} diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts index ce63baec..65bd25a9 100644 --- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts +++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts @@ -21,6 +21,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) { scheduleEnd: draft.scheduleEnd, schedMode: draft.schedMode, vendorId: draft.vendorId, + technicianAssigned: draft.technicianAssigned, completedDate: "", dueDate: draft.dueDate, pm: draft.pm, 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..7d6ef42e 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,14 @@ 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.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..f63346d1 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 ( <> @@ -77,20 +87,22 @@ 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, techPhone: "", vendorNotes: "", newTechnicians: [], } : {}), - }) - } + }); + }} />
@@ -98,15 +110,26 @@ export function WizardVendorCompanyFields({ onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" }) + draft.vendorId !== "" || draft.technicianAssigned === true + ? () => + onPatch({ + vendorId: noTechnicianVendorId, + vendorName: companyAnchor ? company : "", + technicianAssigned: false, + techPhone: "", + vendorNotes: "", + }) : undefined } > Technician { + if (id === NO_TECHNICIAN_KEY) { + onPatch({ + vendorId: noTechnicianVendorId, + vendorName: companyAnchor ? company : "", + technicianAssigned: false, + techPhone: "", + }); + return; + } const vendor = vendors.find((v) => String(v.id) === id); onPatch({ vendorCompany: vendor?.companyName ?? company, vendorId: id, vendorName: vendor?.companyName ?? "", + technicianAssigned: true, 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..b84a0a8b 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,18 @@ 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 readStringArray(record: Record, ...keys: string[]): string[] { for (const key of keys) { const value = record[key]; @@ -293,9 +305,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 +394,14 @@ 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 = + rowTechnicianAssigned === false || frozenCompany?.technicianAssigned === false + ? false + : rowTechnicianAssigned !== undefined + ? rowTechnicianAssigned + : frozenCompany?.technicianAssigned; const row: WorkOrderTableRow = { id: readId(item), @@ -406,8 +428,12 @@ 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: + technicianAssigned === false + ? "" + : readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), + techPhone: technicianAssigned === false ? "" : readString(item, "techPhone", "TechPhone"), + ...(technicianAssigned !== undefined ? { technicianAssigned } : {}), apptStart, apptEnd, apptTime, @@ -418,7 +444,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..b5675c13 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -54,6 +54,8 @@ 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; /** 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/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..967e1a3a 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,40 @@ 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("only offers technicians from the selected company", async () => { renderWithProviders( { expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Gateway Plumbing", - vendorId: "", - vendorName: "", + vendorId: "52", + vendorName: "Gateway Plumbing", + technicianAssigned: 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, + 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, + techPhone: "", + }); + }); + + 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, + techPhone: "", + }); + }); + it("preserves the picked vendor and staged technicians when the same company is re-picked", () => { const onPatch = vi.fn(); render( @@ -192,11 +279,54 @@ 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("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/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..ae62b6b0 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 @@ -172,6 +172,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { scheduleWeekOnly: false, vendorId: "5", + technicianAssigned: false, apptStart: "09:00", @@ -214,6 +215,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { isAddOn: true, vendorId: 5, + technicianAssigned: false, apptDate: "2026-06-25", @@ -336,6 +338,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(""); + }); });