Merge pull request #286 from Sea-Haven-Industries/fix/ab/sh411-empty-technician-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions

fix(workorders): preserve explicit no-technician choice
This commit is contained in:
Alexandre Brandizzi 2026-09-29 23:28:14 +00:00 • committed by GitHub
commit 29d235e133
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1301 additions and 103 deletions

View file

@ -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<Record<string, unknown>>
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
async function mockApi(
page: Page,
writes: Captured[],
initialRows: Array<Record<string, unknown>> = [BOARD_ROW],
) {
const currentBoardRows = [...initialRows];
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
@ -100,16 +116,85 @@ async function mockApi(page: Page, writes: Captured[]) {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
counts: { returned: currentBoardRows.length, total: currentBoardRows.length },
unscheduled: [],
scheduled: [BOARD_ROW],
scheduled: currentBoardRows,
});
}
if (/^\/api\/workorders\/\d+\/detail$/u.test(pathname) && method === "GET") {
const id = Number(pathname.split("/")[3]);
const detailRow = currentBoardRows.find((row) => row.id === id);
if (detailRow == null) return fulfillJson(route, { message: "Missing fixture row" }, 404);
return fulfillJson(route, {
info: detailRow,
completion: { docStatus: 1 },
comments: [],
audit: [],
media: [],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
const body = bodyOf(request) as { vendorId?: number; technicianAssigned?: boolean };
const vendorId = body.vendorId ?? 0;
currentBoardRows.push({
...BOARD_ROW,
id: 900,
woNumber: "SH-900",
lifecycleStatusLabel: "Incomplete",
vendorId: vendorId || null,
vendorName: vendorId > 0 ? "Vinewood LLC" : null,
technicianAssigned: body.technicianAssigned,
techName: body.technicianAssigned === true ? "Jordan Lee" : null,
techPhone: body.technicianAssigned === true ? "(314) 555-0199" : null,
});
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
if (/^\/api\/workorders\/\d+\/board$/u.test(pathname) && method === "PATCH") {
const body = bodyOf(request) as {
field?: string;
value?: string;
technicianAssigned?: boolean;
};
const id = Number(pathname.split("/")[3]);
const index = currentBoardRows.findIndex((row) => row.id === id);
if (index < 0) return fulfillJson(route, { message: "Missing fixture row" }, 404);
const current = currentBoardRows[index];
if (current == null) return fulfillJson(route, { message: "Missing fixture row" }, 404);
if (body.field === "vendorId") {
const vendorId = Number(body.value);
currentBoardRows[index] = {
...current,
vendorId,
vendorName: vendorId === 50 ? "Coastal HVAC" : "Vinewood LLC",
...(body.technicianAssigned === false
? { technicianAssigned: false, techName: null, techPhone: null }
: body.technicianAssigned === true
? {
technicianAssigned: true,
techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte",
techPhone: "(314) 555-0141",
}
: {}),
rowVersion: "rv-2",
};
} else if (body.field === "lifecycleStatus") {
currentBoardRows[index] = {
...current,
lifecycleStatusLabel: body.value,
...(body.value === "Completed"
? {
frozenCompany: {
name: current.vendorName,
contactName: current.vendorName === "Coastal HVAC" ? "Avery Chen" : "Adam Whyte",
phone: "555-0100",
technicianAssigned: current.technicianAssigned,
},
}
: {}),
rowVersion: "rv-3",
};
}
return fulfillJson(route, currentBoardRows[index]);
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
@ -118,7 +203,7 @@ async function mockApi(page: Page, writes: Captured[]) {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
return fulfillJson(route, { data: [...VENDORS, COASTAL_VENDOR] });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
@ -208,6 +293,262 @@ test.describe("Inline technician registration", () => {
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("wizard No technician selection overrides a staged technician through create and reopen", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "No technician" }).first().click();
await page.getByRole("button", { name: "No technician" }).last().click();
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() =>
writes.find(
(write) => write.pathname === "/api/workorders/board" && write.method === "POST",
),
)
.toBeTruthy();
const create = writes.find(
(write) => write.pathname === "/api/workorders/board" && write.method === "POST",
);
expect(create?.body).toEqual(
expect.objectContaining({ vendorId: 41, technicianAssigned: false }),
);
expect(vendorWrites(writes)).toEqual([
expect.objectContaining({ pathname: "/api/vendor-company-roster/7" }),
]);
await page.reload();
const createdRow = page.locator("#wo-row-900");
await expect(createdRow.getByRole("button", { name: /Vinewood LLC/ })).toBeVisible();
await createdRow.getByRole("button", { name: /Vinewood LLC/ }).click();
const vendorDialog = page.getByRole("dialog", { name: "Vendor" });
await expect(vendorDialog.getByRole("button", { name: "No technician" })).toBeVisible();
await expect(vendorDialog.locator('input[type="tel"]')).toHaveCount(0);
await expect(vendorDialog.getByRole("button", { name: "Jordan Lee" })).toHaveCount(0);
});
test("company-only save returns no technician and stays empty after reopen and reload", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes, [{ ...BOARD_ROW, lifecycleStatusLabel: "In Progress" }]);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.getByRole("button", { name: /Adam Whyte/ }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Vinewood LLC" }).click();
await page.getByRole("button", { name: "Coastal HVAC" }).last().click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(boardPatch?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "50", technicianAssigned: false }),
);
await expect(row.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible();
await row.getByRole("button", { name: /Coastal HVAC/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
).toBeVisible();
await expect(page.locator('input[type="tel"]')).toHaveCount(0);
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await expect(reloadedRow.getByRole("button", { name: /Coastal HVAC/ })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
).toBeVisible();
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await reloadedRow.getByRole("button", { name: /in progress/i }).click();
await page.getByRole("button", { name: "Completed" }).click();
await page
.getByRole("dialog", { name: "Mark as Complete?" })
.getByRole("button", { name: "Mark as Complete" })
.click();
await expect
.poll(() =>
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "lifecycleStatus",
),
)
.toBe(true);
await expect(reloadedRow.getByText("Completed", { exact: true })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Coastal HVAC/ }).click();
const completedDialog = page.getByRole("dialog");
await expect(completedDialog.getByText("—", { exact: true })).toBeVisible();
await expect(completedDialog.getByRole("button", { name: /^save$/i })).toHaveCount(0);
expect(
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "lifecycleStatus",
),
).toBe(true);
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
});
test("company absence blocks completion for PM, Emergency, Reactive, and Overdue", async ({
page,
}) => {
const writes: Captured[] = [];
const withoutCompany = [
[1, 2],
[2, 3],
[3, 6],
[4, 8],
].map(([id, workOrderType]) => ({
...BOARD_ROW,
id,
woNumber: `WO-${600 + id}`,
workOrderType,
lifecycleStatusLabel: "In Progress",
vendorId: null,
vendorName: null,
techName: "Avery Chen",
technicianAssigned: true,
docStatus: "Yes",
mediaCount: 1,
}));
await mockApi(page, writes, withoutCompany);
await page.goto("/workorders");
for (const id of [1, 2, 3, 4]) {
const row = page.locator(`#wo-row-${id}`);
await row.getByRole("button", { name: /in progress/i }).click();
const completed = page.getByRole("button", { name: "Completed" });
await expect(completed).toBeDisabled();
await expect(completed).toHaveAttribute(
"title",
"Fill in Vendor before completing this work order.",
);
await page.keyboard.press("Escape");
}
expect(writes.filter((write) => write.pathname.endsWith("/board"))).toEqual([]);
});
test("explicit technician choice is retained and can complete after reload", async ({ page }) => {
const writes: Captured[] = [];
const unassigned = {
...BOARD_ROW,
lifecycleStatusLabel: "In Progress",
technicianAssigned: false,
techName: null,
techPhone: null,
};
await mockApi(page, writes, [unassigned]);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.getByRole("button", { name: /Vinewood LLC/ }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "No technician" }).click();
await page.getByRole("button", { name: "Adam Whyte" }).last().click();
await expect(page.locator('input[type="tel"]')).toBeVisible();
await dialog.getByRole("button", { name: /^save$/i }).click();
const assignmentWrite = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(assignmentWrite?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: true }),
);
await expect(row.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click();
const reopenedDialog = page.getByRole("dialog");
await expect(reopenedDialog.getByRole("button", { name: "Adam Whyte" })).toBeVisible();
await expect(reopenedDialog.locator('input[type="tel"]')).toHaveValue("(314) 555-0141");
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
.click();
await reloadedRow.getByRole("button", { name: /in progress/i }).click();
await page.getByRole("button", { name: "Completed" }).click();
await page
.getByRole("dialog", { name: "Mark as Complete?" })
.getByRole("button", { name: "Mark as Complete" })
.click();
await expect
.poll(() => writes.filter((write) => write.pathname === "/api/workorders/1/board").length)
.toBe(2);
await expect(reloadedRow.getByText("Completed", { exact: true })).toBeVisible();
expect(
writes.some(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string; value?: string }).field === "lifecycleStatus",
),
).toBe(true);
});
test("slide-over saves a same-company no-technician selection with its vendor anchor", async ({
page,
}) => {
const writes: Captured[] = [];
const companyOnly = {
...BOARD_ROW,
vendorId: 50,
vendorName: "Coastal HVAC",
technicianAssigned: false,
techName: null,
techPhone: null,
};
await mockApi(page, writes, [companyOnly]);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.hover();
await row.getByRole("button", { name: "View details" }).click();
const details = page.getByRole("dialog", { name: "Work order details" });
await expect(details).toBeVisible();
await details.getByRole("button", { name: "Edit WO" }).click();
await details.getByRole("button", { name: /Coastal HVAC/ }).click();
const assignment = page.getByRole("dialog").last();
await assignment.getByRole("button", { name: "Coastal HVAC" }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await assignment.getByRole("button", { name: /select technician/i }).click();
await page.getByRole("button", { name: "No technician" }).last().click();
await assignment.getByRole("button", { name: /^save$/i }).click();
await details.getByRole("button", { name: "Save", exact: true }).click();
const slideOverPatch = writes.find(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "vendorId",
);
expect(slideOverPatch?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: false }),
);
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await reloadedRow.hover();
await reloadedRow.getByRole("button", { name: "View details" }).click();
const reloadedDetails = page.getByRole("dialog", { name: "Work order details" });
await expect(reloadedDetails).toBeVisible();
await reloadedDetails.getByRole("button", { name: "Edit WO" }).click();
await reloadedDetails.getByRole("button", { name: /Vinewood LLC/ }).click();
await expect(
page.getByRole("dialog").last().getByRole("button", { name: "No technician" }),
).toBeVisible();
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {

View file

@ -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()

View file

@ -31,6 +31,14 @@ type UseCompDocDialogArgs = {
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
function completionDocTechnician(
assigned: boolean | null | undefined,
frozenAssigned: boolean | null | undefined,
contactName: string | undefined,
): string {
return assigned === false || frozenAssigned === false ? "" : contactName || "";
}
function notifyDraftSaved() {
toast(
<div
@ -116,7 +124,11 @@ export function useCompDocDialog({
amazonRep,
seaRep,
siteCode: site.code,
tech: company.contactName || "",
tech: completionDocTechnician(
info.technicianAssigned,
company.technicianAssigned,
company.contactName,
),
company: company.name || "",
};

View file

@ -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}

View file

@ -213,9 +213,11 @@ export function SlideOverInfoTabEditView({
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
technicianAssigned={draft.technicianAssigned}
vendors={vendors}
onSave={(patch) => onDraftChange(toVendorTablePatch(patch))}
/>

View file

@ -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: "",

View file

@ -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.

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
@ -11,7 +11,10 @@ import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import {
NO_TECHNICIAN_KEY,
resolveTechnicianKey,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
@ -23,10 +26,74 @@ type UseVendorDialogFormArgs = {
company: string;
tech: string;
techPhone: string;
technicianAssigned?: boolean | null;
vendors: VendorDropdownItem[];
onSave: (patch: VendorPatch) => void;
};
type TechnicianKeyArgs = Pick<
UseVendorDialogFormArgs,
"technicianAssigned" | "vendorId" | "tech" | "company" | "vendors"
>;
function resolveInitialTechnicianKey(args: TechnicianKeyArgs): string {
if (args.technicianAssigned === false) return NO_TECHNICIAN_KEY;
return resolveTechnicianKey(args.vendorId, args.tech, args.company, args.vendors);
}
function buildSubmitBaseline(args: TechnicianKeyArgs & { techPhone: string }) {
const options = buildTechnicianOptions(args.vendors, args.company, args.tech, args.company);
return {
company: args.company,
selectedKey: resolveInitialTechnicianKey(args),
phone: args.techPhone || phoneForOption(args.tech, options),
notes: "",
pendingCreateCount: 0,
};
}
function createCompanyPickHandler(args: {
selectedKey: string;
technicianOptions: ReturnType<typeof buildTechnicianOptions>;
setSelectedCompany: (company: string) => void;
setAddedVendors: Dispatch<SetStateAction<VendorDropdownItem[]>>;
setPendingCreates: Dispatch<SetStateAction<Record<string, VendorFormValues>>>;
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<typeof buildTechnicianOptions>;
setSelectedKey: (key: string) => void;
setT: (value: string) => void;
setPhone: (value: string) => void;
}) {
return (key: string) => {
if (key === NO_TECHNICIAN_KEY) {
args.setSelectedKey(key);
args.setT("");
args.setPhone("");
return;
}
const option = args.technicianOptions.find((item) => item.key === key);
args.setSelectedKey(key);
args.setT(option?.name ?? key);
args.setPhone(option?.phone || phoneForOption(option?.name ?? key, args.technicianOptions));
};
}
export function useVendorDialogForm({
open,
onOpenChange,
@ -34,13 +101,18 @@ export function useVendorDialogForm({
company,
tech,
techPhone,
technicianAssigned,
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const addTechnicians = useAddTechniciansToCompany();
const [selectedCompany, setSelectedCompany] = useState(company);
const technicianKeyArgs = useMemo(
() => ({ technicianAssigned, vendorId, tech, company, vendors }),
[technicianAssigned, vendorId, tech, company, vendors],
);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
resolveInitialTechnicianKey(technicianKeyArgs),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
@ -66,7 +138,7 @@ export function useVendorDialogForm({
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
const key = resolveInitialTechnicianKey(technicianKeyArgs);
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
setSelectedCompany(company);
setSelectedKey(key);
@ -78,7 +150,7 @@ export function useVendorDialogForm({
setSaveError(null);
}
// invariant: rebuilding options after Add must not clear staged technician drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
}, [open, tech, techPhone, vendors, company, vendorId, technicianAssigned, technicianKeyArgs]);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedCompany;
@ -100,20 +172,15 @@ export function useVendorDialogForm({
notes,
pendingCreateCount: Object.keys(pendingCreates).length,
},
baseline: {
company,
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
phone:
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
notes: "",
pendingCreateCount: 0,
},
baseline: buildSubmitBaseline({ ...technicianKeyArgs, techPhone }),
});
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
vendorId,
originalCompany: company,
selectedVendor,
resolvedCompany,
anchorCompanyName,
@ -126,6 +193,23 @@ export function useVendorDialogForm({
canSubmit,
});
const handleCompanyPick = createCompanyPickHandler({
selectedKey,
technicianOptions,
setSelectedCompany,
setAddedVendors,
setPendingCreates,
setSelectedKey,
setT,
setPhone,
});
const handlePick = createTechnicianPickHandler({
technicianOptions,
setSelectedKey,
setT,
setPhone,
});
return {
companyOptions,
technicianOptions,
@ -143,23 +227,8 @@ export function useVendorDialogForm({
canSubmit,
isPending: addTechnicians.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
setSelectedCompany(nextCompany);
if (selectedOption?.company !== nextCompany) {
setAddedVendors([]);
setPendingCreates({});
setSelectedKey("");
setT("");
setPhone("");
}
},
handlePick: (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
},
handleCompanyPick,
handlePick,
handleTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,

View file

@ -8,6 +8,8 @@ type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
pendingCreates: Record<string, VendorFormValues>;
selectedKey: string;
vendorId: string | number;
originalCompany: string;
selectedVendor: VendorDropdownItem | undefined;
resolvedCompany: string;
anchorCompanyName: string;
@ -20,6 +22,138 @@ type UseVendorDialogSaveArgs = {
canSubmit: boolean;
};
type VendorResolutionArgs = Pick<
UseVendorDialogSaveArgs,
"allVendors" | "selectedKey" | "selectedVendor" | "resolvedCompany" | "anchorCompanyName" | "tech"
>;
function resolveSaveVendor(args: VendorResolutionArgs): VendorDropdownItem | undefined {
return (
(args.selectedKey
? args.allVendors.find((vendor) => String(vendor.id) === args.selectedKey)
: undefined) ??
args.allVendors.find(
(vendor) =>
vendor.contactName === args.tech &&
vendor.companyName === (args.resolvedCompany || args.anchorCompanyName),
) ??
args.selectedVendor
);
}
async function resolvePersistedVendor(args: {
vendor: VendorDropdownItem | undefined;
allVendors: VendorDropdownItem[];
pendingCreates: Record<string, VendorFormValues>;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
setSaveError: (error: string | null) => void;
}): Promise<VendorDropdownItem | undefined | null> {
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<string, VendorFormValues>;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
setSaveError: (error: string | null) => void;
}): VendorDropdownItem | undefined | Promise<VendorDropdownItem | undefined | null> {
const payload = args.vendor ? args.pendingCreates[String(args.vendor.id)] : undefined;
if (!args.vendor || !isDraftVendorId(args.vendor.id) || !payload) return args.vendor;
return resolvePersistedVendor(args);
}
function isNoTechnician(key: string): boolean {
return key === "" || key === "__no_technician__";
}
function resolveAssignmentVendorId(args: {
noTechnician: boolean;
saveVendor: VendorDropdownItem | undefined;
vendorId: string | number;
resolvedCompany: string;
originalCompany: string;
}): string {
if (!args.noTechnician && args.saveVendor) return String(args.saveVendor.id);
if (
args.noTechnician &&
args.resolvedCompany === args.originalCompany &&
args.vendorId !== "" &&
args.vendorId != null
) {
return String(args.vendorId);
}
return args.noTechnician && args.saveVendor ? String(args.saveVendor.id) : "";
}
function buildVendorPatch(args: {
selectedKey: string;
saveVendor: VendorDropdownItem | undefined;
resolvedCompany: string;
anchorCompanyName: string;
tech: string;
phone: string;
vendorId: string | number;
originalCompany: string;
}): VendorPatch {
const assigned = args.selectedKey !== "" && !isNoTechnician(args.selectedKey);
const noTechnician = isNoTechnician(args.selectedKey);
return {
vendorId: resolveAssignmentVendorId({
noTechnician,
saveVendor: args.saveVendor,
vendorId: args.vendorId,
resolvedCompany: args.resolvedCompany,
originalCompany: args.originalCompany,
}),
company: args.resolvedCompany || args.anchorCompanyName,
tech: assigned ? (args.saveVendor?.contactName ?? args.tech) : "",
techPhone: assigned ? args.phone : "",
technicianAssigned: assigned,
};
}
function commitVendorPatch(args: {
saveVendor: VendorDropdownItem | undefined;
selectedKey: string;
resolvedCompany: string;
anchorCompanyName: string;
tech: string;
phone: string;
vendorId: string | number;
originalCompany: string;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
}): void {
args.onSave(
buildVendorPatch({
saveVendor: args.saveVendor,
selectedKey: args.selectedKey,
resolvedCompany: args.resolvedCompany,
anchorCompanyName: args.anchorCompanyName,
tech: args.tech,
phone: args.phone,
vendorId: args.vendorId,
originalCompany: args.originalCompany,
}),
);
args.onOpenChange(false);
}
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
@ -56,6 +190,8 @@ export function useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
vendorId,
originalCompany,
selectedVendor,
resolvedCompany,
anchorCompanyName,
@ -67,43 +203,54 @@ export function useVendorDialogSave({
setSaveError,
canSubmit,
}: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
allVendors.find(
(vendor) =>
vendor.contactName === tech &&
vendor.companyName === (resolvedCompany || anchorCompanyName),
) ??
selectedVendor;
const handleSave = async () => {
if (!canSubmit) return;
setSaveError(null);
let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
saveVendor = await persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);
return;
}
}
onSave({
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
techPhone: selectedKey ? phone : "",
const vendor = resolveSaveVendor({
allVendors,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech,
});
const resolvedVendor = resolveVendorForSave({
vendor,
allVendors,
pendingCreates,
phone,
addTechnicians,
setSaveError,
});
if (resolvedVendor instanceof Promise) {
const saveVendor = await resolvedVendor;
if (saveVendor === null) return;
commitVendorPatch({
saveVendor,
selectedKey,
resolvedCompany,
anchorCompanyName,
tech,
phone,
vendorId,
originalCompany,
onSave,
onOpenChange,
});
return;
}
commitVendorPatch({
saveVendor: resolvedVendor,
selectedKey,
resolvedCompany,
anchorCompanyName,
tech,
phone,
vendorId,
originalCompany,
onSave,
onOpenChange,
});
onOpenChange(false);
};
return { handleSave };

View file

@ -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 ? (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
{effectiveTech ? (
<HighlightText text={effectiveTech} q={q} />
) : (
<HighlightText text={company} q={q} />
)}
</div>
{Boolean(tech && company) && (
{Boolean(effectiveTech && company) && (
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
<HighlightText text={company} q={q} />
</div>
@ -64,6 +72,7 @@ export function VendorCell({
company={company}
tech={tech}
techPhone={techPhone}
technicianAssigned={technicianAssigned ?? frozenCompany?.technicianAssigned}
vendors={vendors}
viewOnly={viewOnly}
frozenCompany={frozenCompany}

View file

@ -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({
<FieldLabel>Technician (optional)</FieldLabel>
{frozenCompany != null ? (
<div className={DLG_INPUT_CLS} style={DLG_INPUT_STYLE}>
{frozenCompany.contactName?.trim() || tech || "—"}
{technicianAssigned === false || frozenCompany.technicianAssigned === false
? "—"
: frozenCompany.contactName?.trim() || tech || "—"}
</div>
) : (
<WizardFieldSelect
@ -115,10 +120,13 @@ export function VendorDialogBody({
/>
}
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}
/>
)}

View file

@ -3,4 +3,5 @@ export type VendorPatch = {
company: string;
tech: string;
techPhone: string;
technicianAssigned?: boolean;
};

View file

@ -6,6 +6,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
import { NO_TECHNICIAN_KEY } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
@ -25,6 +26,7 @@ type VendorDialogProps = {
company: string;
tech: string;
techPhone?: string;
technicianAssigned?: boolean | null;
vendors: VendorDropdownItem[];
viewOnly?: boolean;
frozenCompany?: WorkOrderFrozenCompany;
@ -38,6 +40,7 @@ export function VendorDialog({
company,
tech,
techPhone = "",
technicianAssigned,
vendors,
viewOnly = false,
frozenCompany,
@ -50,9 +53,24 @@ export function VendorDialog({
company,
tech,
techPhone,
technicianAssigned,
vendors,
onSave,
});
const hasDraftTechnician = form.selectedKey !== "" && form.selectedKey !== NO_TECHNICIAN_KEY;
const draftTechnician = hasDraftTechnician ? (form.selected?.name ?? form.t) : "";
const displayTechnician = frozenCompany
? frozenCompany.technicianAssigned === false
? ""
: (frozenCompany.contactName ?? draftTechnician)
: draftTechnician;
const displayTechnicianPhone = frozenCompany
? frozenCompany.technicianAssigned === false
? ""
: (frozenCompany.phone ?? form.phone)
: hasDraftTechnician
? form.phone
: "";
return (
<Dialog
@ -94,12 +112,13 @@ 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={displayTechnician}
phone={displayTechnicianPhone}
notes={form.notes}
resolvedCompany={form.resolvedCompany}
selectedVendor={form.selectedVendor}
frozenCompany={frozenCompany}
technicianAssigned={technicianAssigned}
saveError={form.saveError}
onCompanyPick={form.handleCompanyPick}
onPick={form.handlePick}

View file

@ -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,

View file

@ -248,6 +248,7 @@ export function WoTableInlineRowCells({
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
technicianAssigned={draft.technicianAssigned}
vendors={vendors}
onSave={(p) => onPatch(toVendorTablePatch(p))}
/>

View file

@ -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}

View file

@ -30,9 +30,15 @@ export function buildCreatedTechniciansPatch(
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
if (
created.length === 1 &&
only != null &&
draft.technicianSelectionCleared !== true &&
(draft.technicianAssigned === false || draft.vendorId === "")
) {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.technicianAssigned = true;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;

View file

@ -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);

View file

@ -5,6 +5,7 @@ import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/li
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
import { NO_TECHNICIAN_KEY } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
@ -49,6 +50,15 @@ export function WizardVendorCompanyFields({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
const companyAnchor = vendors.find((vendor) => vendor.companyName === company);
const currentVendorInCompany = vendors.some(
(vendor) => String(vendor.id) === draft.vendorId && vendor.companyName === company,
);
const noTechnicianVendorId = currentVendorInCompany
? draft.vendorId
: companyAnchor
? String(companyAnchor.id)
: "";
return (
<>
@ -66,6 +76,7 @@ export function WizardVendorCompanyFields({
techPhone: "",
vendorNotes: "",
newTechnicians: [],
technicianSelectionCleared: false,
})
}
>
@ -77,20 +88,23 @@ export function WizardVendorCompanyFields({
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPick={(nextCompany) => {
const nextAnchor = vendors.find((vendor) => vendor.companyName === nextCompany);
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
vendorId: nextAnchor ? String(nextAnchor.id) : "",
vendorName: nextAnchor ? nextCompany : "",
technicianAssigned: false,
technicianSelectionCleared: false,
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
});
}}
/>
</div>
@ -98,15 +112,27 @@ export function WizardVendorCompanyFields({
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
draft.vendorId !== "" || draft.technicianAssigned === true
? () =>
onPatch({
vendorId: noTechnicianVendorId,
vendorName: companyAnchor ? company : "",
technicianAssigned: false,
technicianSelectionCleared: true,
techPhone: "",
vendorNotes: "",
})
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
value={
draft.technicianAssigned === false && draft.vendorId
? NO_TECHNICIAN_KEY
: draft.vendorId
}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
@ -117,13 +143,25 @@ export function WizardVendorCompanyFields({
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
options={[...technicianOptions, { key: NO_TECHNICIAN_KEY, label: "No technician" }]}
onPick={(id) => {
if (id === NO_TECHNICIAN_KEY) {
onPatch({
vendorId: noTechnicianVendorId,
vendorName: companyAnchor ? company : "",
technicianAssigned: false,
technicianSelectionCleared: true,
techPhone: "",
});
return;
}
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
technicianAssigned: true,
technicianSelectionCleared: false,
techPhone: "",
});
}}

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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"),

View file

@ -72,6 +72,38 @@ function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
return false;
}
function readNullableBool(
record: Record<string, unknown>,
...keys: string[]
): boolean | null | undefined {
for (const key of keys) {
if (!(key in record)) continue;
const value = record[key];
return typeof value === "boolean" ? value : null;
}
return undefined;
}
function resolveTechnicianAssignment(
rowAssigned: boolean | null | undefined,
frozenAssigned: boolean | null | undefined,
): boolean | null | undefined {
if (rowAssigned === false || frozenAssigned === false) return false;
if (rowAssigned !== undefined) return rowAssigned;
return frozenAssigned;
}
function readEffectiveTechnicianFields(
item: Record<string, unknown>,
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<string, unknown>, ...keys: string[]): string[] {
for (const key of keys) {
const value = record[key];
@ -293,9 +325,11 @@ function readFrozenSite(item: Record<string, unknown>): WorkOrderFrozenSite | un
function readFrozenCompany(item: Record<string, unknown>): WorkOrderFrozenCompany | undefined {
const source = asRecord(item.frozenCompany ?? item.FrozenCompany);
if (Object.keys(source).length === 0) return undefined;
const technicianAssigned = readNullableBool(source, "technicianAssigned", "TechnicianAssigned");
return {
name: readString(source, "name", "Name"),
contactName: readString(source, "contactName", "ContactName"),
...(technicianAssigned !== undefined ? { technicianAssigned } : {}),
address: readString(source, "address", "Address"),
city: readString(source, "city", "City"),
state: readString(source, "state", "State"),
@ -380,6 +414,13 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const boardUpliftSummary = readUpliftSummary(item);
const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
const frozenCompany = readFrozenCompany(item);
const rowTechnicianAssigned = readNullableBool(item, "technicianAssigned", "TechnicianAssigned");
const technicianAssigned = resolveTechnicianAssignment(
rowTechnicianAssigned,
frozenCompany?.technicianAssigned,
);
const technician = readEffectiveTechnicianFields(item, technicianAssigned);
const row: WorkOrderTableRow = {
id: readId(item),
@ -406,8 +447,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
company: readString(item, "company", "Company", "vendorName", "VendorName"),
primaryDispatchStatus: readPrimaryDispatchStatus(item),
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
techPhone: readString(item, "techPhone", "TechPhone"),
tech: technician.tech,
techPhone: technician.techPhone,
...(technicianAssigned !== undefined ? { technicianAssigned } : {}),
apptStart,
apptEnd,
apptTime,
@ -418,7 +460,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
additionalContacts: readAdditionalContacts(item),
frozenSite: readFrozenSite(item),
frozenCompany: readFrozenCompany(item),
frozenCompany,
frozenPoc: readFrozenPoc(item),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,

View file

@ -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;

View file

@ -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;

View file

@ -31,6 +31,7 @@ export interface WorkOrderAuditEntry {
}
export interface WorkOrderDetail {
technicianAssigned?: boolean | null;
id: string | number;
internalWONumber: string;
workOrderNumber: string;

View file

@ -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 | "";

View file

@ -54,6 +54,10 @@ export interface WorkOrderWizardDraft {
vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Explicit technician choice; null/omitted retains legacy inference. */
technicianAssigned?: boolean | null;
/** UI intent: an explicit clear overrides staged single-technician autoassignment. */
technicianSelectionCleared?: boolean;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string;

View file

@ -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 ?? "",
};
}

View file

@ -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;
}

View file

@ -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: "",
};
}

View file

@ -93,6 +93,7 @@ describe("VendorDialog save gate", () => {
vendorId: "11",
tech: "Casey New",
company: "Gateway Plumbing",
technicianAssigned: true,
}),
);
});
@ -122,10 +123,69 @@ describe("VendorDialog save gate", () => {
company: "Gateway Plumbing",
tech: "",
techPhone: "",
technicianAssigned: false,
}),
);
});
it("clears a selected technician while retaining the same company vendor anchor", async () => {
const onSave = vi.fn();
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={11}
company="Gateway Plumbing"
tech="Casey New"
techPhone="555-0111"
technicianAssigned
vendors={[jordan, casey]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /casey new/i }));
fireEvent.click(await screen.findByRole("button", { name: "No technician" }));
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(onSave).toHaveBeenCalledWith({
vendorId: "11",
company: "Gateway Plumbing",
tech: "",
techPhone: "",
technicianAssigned: false,
});
});
it("shows the editable phone for a newly selected technician and hides it for No technician", async () => {
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech=""
techPhone=""
technicianAssigned={false}
vendors={[jordan, casey]}
onSave={vi.fn()}
/>,
);
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<HTMLInputElement>('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(
<VendorDialog

View file

@ -1,7 +1,10 @@
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import {
buildCreatedTechniciansPatch,
useWizardCreateWithTechnicians,
} from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
EMPTY_WIZARD_DRAFT,
@ -110,14 +113,132 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => {
expect(onPatch).toHaveBeenCalledWith({
vendorCompany: "Gateway Plumbing",
vendorId: "",
vendorName: "",
vendorId: "52",
vendorName: "Gateway Plumbing",
technicianAssigned: false,
technicianSelectionCleared: false,
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("keeps the existing company anchor when company is chosen without a technician", async () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /select company/i }));
fireEvent.click(await screen.findByRole("button", { name: "Vinewood LLC" }));
expect(onPatch).toHaveBeenCalledWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
technicianAssigned: false,
technicianSelectionCleared: false,
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("marks the exact selected technician as assigned", async () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
technicianAssigned: false,
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "No technician" }));
fireEvent.click(await screen.findByRole("button", { name: "Adam Whyte" }));
expect(onPatch).toHaveBeenLastCalledWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
technicianAssigned: true,
technicianSelectionCleared: false,
techPhone: "",
});
});
it("records explicit no-technician intent while leaving a staged technician untouched", async () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
technicianAssigned: false,
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "No technician" }));
const noTechnicianOptions = screen.getAllByRole("button", { name: "No technician" });
fireEvent.click(noTechnicianOptions[noTechnicianOptions.length - 1]!);
expect(onPatch).toHaveBeenCalledWith({
vendorId: "41",
vendorName: "Vinewood LLC",
technicianAssigned: false,
technicianSelectionCleared: true,
techPhone: "",
});
expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument();
});
it("retains the same company vendor id when clearing a second technician", async () => {
const onPatch = vi.fn();
const secondVinewoodTech = {
...vendors[0],
id: 42,
contactName: "Second Tech",
};
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "42",
technicianAssigned: true,
})}
vendors={[vendors[0], secondVinewoodTech, vendors[1]]}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Second Tech/u }));
fireEvent.click(await screen.findByRole("button", { name: "No technician" }));
expect(onPatch).toHaveBeenCalledWith({
vendorId: "42",
vendorName: "Vinewood LLC",
technicianAssigned: false,
technicianSelectionCleared: true,
techPhone: "",
});
});
it("preserves the picked vendor and staged technicians when the same company is re-picked", () => {
const onPatch = vi.fn();
render(
@ -192,11 +313,72 @@ describe("useWizardCreateWithTechnicians (SH-364)", () => {
vendorId: "99",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0199",
technicianAssigned: true,
};
expect(patchDraft).toHaveBeenCalledWith(expectedPatch);
expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch));
});
it.each([
["legacy selection", { vendorId: "41" }, { newTechnicians: [] }],
["explicit selection", { vendorId: "41", technicianAssigned: true }, { newTechnicians: [] }],
])("preserves the existing %s when one technician is staged", (_caseName, existing, expected) => {
const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" };
const patch = buildCreatedTechniciansPatch(
draftWith({
vendorCompany: "Vinewood LLC",
...existing,
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
"Vinewood LLC",
[{ name: "Jordan Lee", phone: "" }],
[created],
);
expect(patch).toEqual(expected);
});
it("assigns the only new technician when the existing company is explicitly company-only", () => {
const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" };
expect(
buildCreatedTechniciansPatch(
draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
technicianAssigned: false,
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
"Vinewood LLC",
[{ name: "Jordan Lee", phone: "" }],
[created],
),
).toEqual({
newTechnicians: [],
vendorId: "99",
vendorName: "Vinewood LLC",
technicianAssigned: true,
techPhone: "",
});
});
it("does not autoassign a staged technician after No technician was explicitly chosen", () => {
const created = { ...vendors[0], id: 99, contactName: "Jordan Lee" };
expect(
buildCreatedTechniciansPatch(
draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
technicianAssigned: false,
technicianSelectionCleared: true,
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
"Vinewood LLC",
[{ name: "Jordan Lee", phone: "" }],
[created],
),
).toEqual({ newTechnicians: [] });
});
it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>

View file

@ -12,6 +12,50 @@ function currentMonthIso(day: number): string {
}
describe("WoTableInlineRowCells — Schedule On range", () => {
it("reopens a company-only inline assignment without inferring its anchor as technician", async () => {
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableInlineRowCells
draft={{
...EMPTY_TABLE_DRAFT,
vendorId: "41",
company: "Vinewood LLC",
technicianAssigned: false,
}}
weekDefault={false}
users={[]}
sites={[]}
vendors={[
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
distanceMiles: null,
},
]}
pmTypes={[]}
allRows={[]}
siteCellRef={createRef()}
woCellRef={createRef()}
siteShowError={false}
siteError=""
woShowError={false}
onPatch={vi.fn()}
/>
</tr>
</tbody>
</table>,
);
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(

View file

@ -77,6 +77,20 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload).not.toHaveProperty("customer");
});
it("omits wizard-only technician selection intent from the create payload", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
vendorId: "41",
technicianAssigned: false,
technicianSelectionCleared: true,
});
expect(payload).toEqual(expect.objectContaining({ vendorId: 41, technicianAssigned: false }));
expect(payload).not.toHaveProperty("technicianSelectionCleared");
});
it("allows create mapping without a service", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
@ -172,6 +186,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
scheduleWeekOnly: false,
vendorId: "5",
technicianAssigned: false,
apptStart: "09:00",
@ -214,6 +229,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
isAddOn: true,
vendorId: 5,
technicianAssigned: false,
apptDate: "2026-06-25",
@ -336,6 +352,17 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.apptTime).toBeUndefined();
});
it("creates with the selected technician id and explicit assignment state", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
vendorId: "11",
technicianAssigned: true,
});
expect(payload).toMatchObject({ vendorId: 11, technicianAssigned: true });
});
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -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" },

View file

@ -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,

View file

@ -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("");
});
});

View file

@ -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<WorkOrderTableRow>).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" });