mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 03:23:12 +00:00
fix(work-orders): preserve explicit empty technician assignment
This commit is contained in:
parent
24cbd09a60
commit
5adde3723b
35 changed files with 722 additions and 48 deletions
|
|
@ -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,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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ export type VendorPatch = {
|
|||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
technicianAssigned?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@ 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.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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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={<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,
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -98,15 +110,26 @@ 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,
|
||||
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 +140,23 @@ 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,
|
||||
techPhone: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const vendor = vendors.find((v) => String(v.id) === id);
|
||||
onPatch({
|
||||
vendorCompany: vendor?.companyName ?? company,
|
||||
vendorId: id,
|
||||
vendorName: vendor?.companyName ?? "",
|
||||
technicianAssigned: true,
|
||||
techPhone: "",
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,18 @@ 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 readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
|
|
@ -293,9 +305,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 +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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface WorkOrderAuditEntry {
|
|||
}
|
||||
|
||||
export interface WorkOrderDetail {
|
||||
technicianAssigned?: boolean | null;
|
||||
id: string | number;
|
||||
internalWONumber: string;
|
||||
workOrderNumber: string;
|
||||
|
|
|
|||
|
|
@ -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 | "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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("only offers technicians from the selected company", async () => {
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
|
|
|
|||
|
|
@ -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,98 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => {
|
|||
|
||||
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(
|
||||
<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,
|
||||
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,
|
||||
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(
|
||||
<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,
|
||||
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(() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue