From 5adde3723b2b55ee63ef16fb68e4ed59a0d2c27f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 18:22:30 -0300 Subject: [PATCH 01/10] 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(""); + }); }); From bfdc2dbdecce590eddf20a5995ff711ec6d1908e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 18:42:19 -0300 Subject: [PATCH 02/10] fix(work-orders): retain technician state across editors --- e2e/work-orders/inline-technician.spec.ts | 71 ++++++++++++++++++- .../tabs/slide-over-info-tab-edit-view.tsx | 1 + .../list/table/cells/vendor-dialog.tsx | 28 +++++--- .../list/table/wo-table-inline-row-cells.tsx | 1 + .../work-orders/utils/slide-over-draft.ts | 13 ++++ .../vendor-dialog-save-gate.test.tsx | 29 ++++++++ .../wo-table-inline-schedule-range.test.tsx | 44 ++++++++++++ .../utils/slide-over-draft.test.ts | 32 +++++++++ 8 files changed, 206 insertions(+), 13 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index bb1fcae5..f61412e0 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -121,6 +121,18 @@ async function mockApi( 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") { return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } }); } @@ -147,6 +159,7 @@ async function mockApi( ? { technicianAssigned: true, techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte", + techPhone: "(314) 555-0141", } : {}), rowVersion: "rv-2", @@ -294,6 +307,7 @@ test.describe("Inline technician registration", () => { 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 }) @@ -395,6 +409,7 @@ test.describe("Inline technician registration", () => { const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "No technician" }).click(); await dialog.getByRole("button", { name: "Adam Whyte" }).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"); @@ -406,9 +421,9 @@ test.describe("Inline technician registration", () => { 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(); + 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 }) @@ -432,6 +447,56 @@ test.describe("Inline technician registration", () => { ).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 assignment.getByRole("button", { name: "Vinewood LLC" }).click(); + await assignment.getByRole("button", { name: /select technician/i }).click(); + await assignment.getByRole("button", { name: "No technician" }).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: /Vinewood LLC/ }).click(); + await expect(reloadedDetails.getByRole("button", { name: "No technician" })).toBeVisible(); + }); + test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({ page, }) => { 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 248d7281..2d718b4f 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 @@ -213,6 +213,7 @@ export function SlideOverInfoTabEditView({ onPatch(toVendorTablePatch(p))} /> 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/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx b/src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx index 967e1a3a..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 @@ -157,6 +157,35 @@ describe("VendorDialog save gate", () => { }); }); + 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( { + 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/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" }); From 1c9b909861afbc54d681218fb0fde5a4fcb74993 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 18:50:08 -0300 Subject: [PATCH 03/10] style(work-orders): format technician dialog state --- .../workorders/_components/list/table/cells/vendor-dialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 687e9218..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 @@ -57,8 +57,7 @@ export function VendorDialog({ vendors, onSave, }); - const hasDraftTechnician = - form.selectedKey !== "" && form.selectedKey !== NO_TECHNICIAN_KEY; + const hasDraftTechnician = form.selectedKey !== "" && form.selectedKey !== NO_TECHNICIAN_KEY; const draftTechnician = hasDraftTechnician ? (form.selected?.name ?? form.t) : ""; const displayTechnician = frozenCompany ? frozenCompany.technicianAssigned === false From 5b4da1163137ce661a2334aec7adf56cc94dd496 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 19:05:34 -0300 Subject: [PATCH 04/10] test(work-orders): use visible vendor and technician pickers --- e2e/work-orders/inline-technician.spec.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index f61412e0..f24acbd6 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -290,11 +290,8 @@ test.describe("Inline technician registration", () => { 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: "Vinewood LLC" }).click(); + await page.getByRole("button", { name: "Coastal HVAC" }).last().click(); await dialog.getByRole("button", { name: /^save$/i }).click(); await expect(dialog).toBeHidden(); @@ -408,7 +405,7 @@ test.describe("Inline technician registration", () => { 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 page.getByRole("button", { name: "Adam Whyte" }).last().click(); await expect(page.locator('input[type="tel"]')).toBeVisible(); await dialog.getByRole("button", { name: /^save$/i }).click(); @@ -472,9 +469,9 @@ test.describe("Inline technician registration", () => { await details.getByRole("button", { name: /Coastal HVAC/ }).click(); const assignment = page.getByRole("dialog").last(); await assignment.getByRole("button", { name: "Coastal HVAC" }).click(); - await assignment.getByRole("button", { name: "Vinewood LLC" }).click(); + await page.getByRole("button", { name: "Vinewood LLC" }).last().click(); await assignment.getByRole("button", { name: /select technician/i }).click(); - await assignment.getByRole("button", { name: "No technician" }).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(); From fdbb602e0c8b2216d7dec47d7829c9f35d11b95f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 19:10:46 -0300 Subject: [PATCH 05/10] test(work-orders): exercise reachable completion controls --- e2e/work-orders/inline-technician.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index f24acbd6..41a7ffed 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -284,7 +284,7 @@ test.describe("Inline technician registration", () => { page, }) => { const writes: Captured[] = []; - await mockApi(page, writes); + await mockApi(page, writes, [{ ...BOARD_ROW, lifecycleStatusLabel: "In Progress" }]); await page.goto("/workorders"); const row = page.locator("#wo-row-1"); @@ -394,6 +394,7 @@ test.describe("Inline technician registration", () => { const writes: Captured[] = []; const unassigned = { ...BOARD_ROW, + lifecycleStatusLabel: "In Progress", technicianAssigned: false, techName: null, techPhone: null, @@ -490,8 +491,11 @@ test.describe("Inline technician registration", () => { 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(reloadedDetails.getByRole("button", { name: "No technician" })).toBeVisible(); + 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 ({ From 262c93b41cebcad7fce60693539782cef2d6ef0a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 19:13:51 -0300 Subject: [PATCH 06/10] test(work-orders): assert completed status as display text --- e2e/work-orders/inline-technician.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index 41a7ffed..66b214fb 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -336,7 +336,7 @@ test.describe("Inline technician registration", () => { ), ) .toBe(true); - await expect(reloadedRow.getByRole("button", { name: /completed/i })).toBeVisible(); + 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(); @@ -435,7 +435,7 @@ test.describe("Inline technician registration", () => { await expect .poll(() => writes.filter((write) => write.pathname === "/api/workorders/1/board").length) .toBe(2); - await expect(reloadedRow.getByRole("button", { name: /completed/i })).toBeVisible(); + await expect(reloadedRow.getByText("Completed", { exact: true })).toBeVisible(); expect( writes.some( (write) => From 55b5441d7162a82af7d00c9647d4999fe3c09281 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 19:26:34 -0300 Subject: [PATCH 07/10] refactor(work-orders): keep technician assignment paths maintainable --- .../comp-doc/use-comp-doc-dialog.tsx | 17 +- .../table/cells/use-vendor-dialog-form.ts | 131 +++++++---- .../table/cells/use-vendor-dialog-save.ts | 220 ++++++++++++++---- .../mappers/work-order-table-row-mapper.ts | 38 ++- 4 files changed, 307 insertions(+), 99 deletions(-) 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 f93a1166..6238d1df 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, +): string { + return assigned === false || frozenAssigned === false ? "" : contactName || ""; +} + function notifyDraftSaved() { toast(
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, @@ -44,10 +107,12 @@ export function useVendorDialogForm({ }: 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(() => - technicianAssigned === false - ? NO_TECHNICIAN_KEY - : resolveTechnicianKey(vendorId, tech, company, vendors), + resolveInitialTechnicianKey(technicianKeyArgs), ); const [t, setT] = useState(tech); const [phone, setPhone] = useState(techPhone); @@ -73,10 +138,7 @@ export function useVendorDialogForm({ useEffect(() => { if (open) { - const key = - technicianAssigned === false - ? NO_TECHNICIAN_KEY - : resolveTechnicianKey(vendorId, tech, company, vendors); + const key = resolveInitialTechnicianKey(technicianKeyArgs); const initialOptions = buildTechnicianOptions(vendors, company, tech, company); setSelectedCompany(company); setSelectedKey(key); @@ -88,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, technicianAssigned]); + }, [open, tech, techPhone, vendors, company, vendorId, technicianAssigned, technicianKeyArgs]); const selected = technicianOptions.find((option) => option.key === selectedKey); const resolvedCompany = selectedCompany; @@ -112,14 +174,7 @@ export function useVendorDialogForm({ }, baseline: { company, - selectedKey: - technicianAssigned === false - ? NO_TECHNICIAN_KEY - : resolveTechnicianKey(vendorId, tech, company, vendors), - phone: - techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), - notes: "", - pendingCreateCount: 0, + ...buildSubmitBaseline({ ...technicianKeyArgs, techPhone }), }, }); @@ -141,6 +196,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, @@ -158,29 +230,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) => { - if (key === NO_TECHNICIAN_KEY) { - setSelectedKey(key); - setT(""); - setPhone(""); - return; - } - 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 c8959380..f02ea2c3 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 @@ -22,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, @@ -71,54 +203,54 @@ export function useVendorDialogSave({ setSaveError, canSubmit, }: UseVendorDialogSaveArgs) { - const resolveSaveVendor = () => - (selectedKey ? allVendors.find((vendor) => String(vendor.id) === selectedKey) : undefined) ?? - 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; - } - } - - const noTechnician = selectedKey === "" || selectedKey === "__no_technician__"; - const preservedVendorId = - noTechnician && resolvedCompany === originalCompany && vendorId !== "" && vendorId != null - ? String(vendorId) - : ""; - onSave({ - vendorId: - selectedKey && !noTechnician - ? saveVendor - ? String(saveVendor.id) - : "" - : preservedVendorId || (noTechnician && saveVendor ? String(saveVendor.id) : ""), - company: resolvedCompany || anchorCompanyName, - tech: selectedKey && !noTechnician ? (saveVendor?.contactName ?? tech) : "", - techPhone: selectedKey && !noTechnician ? phone : "", - technicianAssigned: Boolean(selectedKey && !noTechnician), + 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/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index b84a0a8b..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 @@ -84,6 +84,26 @@ function readNullableBool( 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]; @@ -396,12 +416,11 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { 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 technicianAssigned = resolveTechnicianAssignment( + rowTechnicianAssigned, + frozenCompany?.technicianAssigned, + ); + const technician = readEffectiveTechnicianFields(item, technicianAssigned); const row: WorkOrderTableRow = { id: readId(item), @@ -428,11 +447,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { vendorId: vendorIdNum != null ? String(vendorIdNum) : "", company: readString(item, "company", "Company", "vendorName", "VendorName"), primaryDispatchStatus: readPrimaryDispatchStatus(item), - tech: - technicianAssigned === false - ? "" - : readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"), - techPhone: technicianAssigned === false ? "" : readString(item, "techPhone", "TechPhone"), + tech: technician.tech, + techPhone: technician.techPhone, ...(technicianAssigned !== undefined ? { technicianAssigned } : {}), apptStart, apptEnd, From 316cafccaa267182754621a3639c98b8a71a9a79 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 19:30:48 -0300 Subject: [PATCH 08/10] fix(work-orders): satisfy technician assignment types --- .../workorders/_components/comp-doc/use-comp-doc-dialog.tsx | 2 +- .../_components/list/table/cells/use-vendor-dialog-form.ts | 1 - .../_components/list/table/cells/use-vendor-dialog-save.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) 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 6238d1df..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 @@ -34,7 +34,7 @@ type UseCompDocDialogArgs = { function completionDocTechnician( assigned: boolean | null | undefined, frozenAssigned: boolean | null | undefined, - contactName: string, + contactName: string | undefined, ): string { return assigned === false || frozenAssigned === false ? "" : contactName || ""; } 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 3cdd7841..4411f5ba 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 @@ -173,7 +173,6 @@ export function useVendorDialogForm({ pendingCreateCount: Object.keys(pendingCreates).length, }, baseline: { - company, ...buildSubmitBaseline({ ...technicianKeyArgs, techPhone }), }, }); 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 f02ea2c3..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 @@ -48,7 +48,7 @@ async function resolvePersistedVendor(args: { phone: string; addTechnicians: ReturnType; setSaveError: (error: string | null) => void; -}): Promise { +}): Promise { const payload = args.vendor ? args.pendingCreates[String(args.vendor.id)] : undefined; if (!args.vendor || !isDraftVendorId(args.vendor.id) || !payload) return args.vendor; try { @@ -71,7 +71,7 @@ function resolveVendorForSave(args: { phone: string; addTechnicians: ReturnType; setSaveError: (error: string | null) => void; -}): VendorDropdownItem | undefined | Promise { +}): 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); From 64c53e9da0201f810629edef55ece634bc9d4797 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 20:06:05 -0300 Subject: [PATCH 09/10] fix(workorders): preserve explicit no-technician choice --- e2e/work-orders/inline-technician.spec.ts | 51 ++++++++++++++++++ .../table/cells/use-vendor-dialog-form.ts | 4 +- .../use-wizard-create-with-technicians.ts | 1 + .../wizard/wizard-vendor-company-fields.tsx | 5 ++ .../work-orders/types/work-order-wizard.ts | 2 + .../wizard-vendor-inline-technician.test.tsx | 52 +++++++++++++++++++ .../mappers/work-order-board-mapper.test.ts | 14 +++++ 7 files changed, 126 insertions(+), 3 deletions(-) diff --git a/e2e/work-orders/inline-technician.spec.ts b/e2e/work-orders/inline-technician.spec.ts index 66b214fb..9fc93684 100644 --- a/e2e/work-orders/inline-technician.spec.ts +++ b/e2e/work-orders/inline-technician.spec.ts @@ -134,6 +134,19 @@ async function mockApi( }); } 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 (/^\/api\/workorders\/\d+\/board$/u.test(pathname) && method === "PATCH") { @@ -280,6 +293,44 @@ 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, }) => { 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 4411f5ba..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 @@ -172,9 +172,7 @@ export function useVendorDialogForm({ notes, pendingCreateCount: Object.keys(pendingCreates).length, }, - baseline: { - ...buildSubmitBaseline({ ...technicianKeyArgs, techPhone }), - }, + baseline: buildSubmitBaseline({ ...technicianKeyArgs, techPhone }), }); const { handleSave } = useVendorDialogSave({ 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 7d6ef42e..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 @@ -33,6 +33,7 @@ export function buildCreatedTechniciansPatch( if ( created.length === 1 && only != null && + draft.technicianSelectionCleared !== true && (draft.technicianAssigned === false || draft.vendorId === "") ) { patch.vendorId = String(only.id); 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 f63346d1..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 @@ -76,6 +76,7 @@ export function WizardVendorCompanyFields({ techPhone: "", vendorNotes: "", newTechnicians: [], + technicianSelectionCleared: false, }) } > @@ -96,6 +97,7 @@ export function WizardVendorCompanyFields({ vendorId: nextAnchor ? String(nextAnchor.id) : "", vendorName: nextAnchor ? nextCompany : "", technicianAssigned: false, + technicianSelectionCleared: false, techPhone: "", vendorNotes: "", newTechnicians: [], @@ -116,6 +118,7 @@ export function WizardVendorCompanyFields({ vendorId: noTechnicianVendorId, vendorName: companyAnchor ? company : "", technicianAssigned: false, + technicianSelectionCleared: true, techPhone: "", vendorNotes: "", }) @@ -147,6 +150,7 @@ export function WizardVendorCompanyFields({ vendorId: noTechnicianVendorId, vendorName: companyAnchor ? company : "", technicianAssigned: false, + technicianSelectionCleared: true, techPhone: "", }); return; @@ -157,6 +161,7 @@ export function WizardVendorCompanyFields({ vendorId: id, vendorName: vendor?.companyName ?? "", technicianAssigned: true, + technicianSelectionCleared: false, techPhone: "", }); }} diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index b5675c13..3a3416e1 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -56,6 +56,8 @@ export interface WorkOrderWizardDraft { 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/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx index dfe099f0..462a3ebe 100644 --- a/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-vendor-inline-technician.test.tsx @@ -116,6 +116,7 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => { vendorId: "52", vendorName: "Gateway Plumbing", technicianAssigned: false, + technicianSelectionCleared: false, techPhone: "", vendorNotes: "", newTechnicians: [], @@ -141,6 +142,7 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => { vendorId: "41", vendorName: "Vinewood LLC", technicianAssigned: false, + technicianSelectionCleared: false, techPhone: "", vendorNotes: "", newTechnicians: [], @@ -170,10 +172,41 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => { 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 = { @@ -201,6 +234,7 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => { vendorId: "42", vendorName: "Vinewood LLC", technicianAssigned: false, + technicianSelectionCleared: true, techPhone: "", }); }); @@ -327,6 +361,24 @@ describe("useWizardCreateWithTechnicians (SH-364)", () => { }); }); + 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/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index ae62b6b0..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, From 1746ded43479ae80b04d6088e1924dfbe129b7a7 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 29 Sep 2026 20:22:31 -0300 Subject: [PATCH 10/10] test(e2e): open wizard technician picker from its No technician state --- e2e/work-orders/wizard-vendor-company-notes.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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()