fix(work-orders): retain technician state across editors

This commit is contained in:
Alexandre Brandizzi 2026-09-29 18:42:19 -03:00
parent 5adde3723b
commit bfdc2dbdec
8 changed files with 206 additions and 13 deletions

View file

@ -121,6 +121,18 @@ async function mockApi(
scheduled: currentBoardRows,
});
}
if (/^\/api\/workorders\/\d+\/detail$/u.test(pathname) && method === "GET") {
const id = Number(pathname.split("/")[3]);
const detailRow = currentBoardRows.find((row) => row.id === id);
if (detailRow == null) return fulfillJson(route, { message: "Missing fixture row" }, 404);
return fulfillJson(route, {
info: detailRow,
completion: { docStatus: 1 },
comments: [],
audit: [],
media: [],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
@ -147,6 +159,7 @@ async function mockApi(
? {
technicianAssigned: true,
techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte",
techPhone: "(314) 555-0141",
}
: {}),
rowVersion: "rv-2",
@ -294,6 +307,7 @@ test.describe("Inline technician registration", () => {
await expect(
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
).toBeVisible();
await expect(page.locator('input[type="tel"]')).toHaveCount(0);
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
@ -395,6 +409,7 @@ test.describe("Inline technician registration", () => {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "No technician" }).click();
await dialog.getByRole("button", { name: "Adam Whyte" }).click();
await expect(page.locator('input[type="tel"]')).toBeVisible();
await dialog.getByRole("button", { name: /^save$/i }).click();
const assignmentWrite = writes.find((write) => write.pathname === "/api/workorders/1/board");
@ -406,9 +421,9 @@ test.describe("Inline technician registration", () => {
const reloadedRow = page.locator("#wo-row-1");
await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Adam Whyte" }),
).toBeVisible();
const reopenedDialog = page.getByRole("dialog");
await expect(reopenedDialog.getByRole("button", { name: "Adam Whyte" })).toBeVisible();
await expect(reopenedDialog.locator('input[type="tel"]')).toHaveValue("(314) 555-0141");
await page
.getByRole("dialog")
.getByRole("button", { name: /cancel/i })
@ -432,6 +447,56 @@ test.describe("Inline technician registration", () => {
).toBe(true);
});
test("slide-over saves a same-company no-technician selection with its vendor anchor", async ({
page,
}) => {
const writes: Captured[] = [];
const companyOnly = {
...BOARD_ROW,
vendorId: 50,
vendorName: "Coastal HVAC",
technicianAssigned: false,
techName: null,
techPhone: null,
};
await mockApi(page, writes, [companyOnly]);
await page.goto("/workorders");
const row = page.locator("#wo-row-1");
await row.hover();
await row.getByRole("button", { name: "View details" }).click();
const details = page.getByRole("dialog", { name: "Work order details" });
await expect(details).toBeVisible();
await details.getByRole("button", { name: "Edit WO" }).click();
await details.getByRole("button", { name: /Coastal HVAC/ }).click();
const assignment = page.getByRole("dialog").last();
await assignment.getByRole("button", { name: "Coastal HVAC" }).click();
await assignment.getByRole("button", { name: "Vinewood LLC" }).click();
await assignment.getByRole("button", { name: /select technician/i }).click();
await assignment.getByRole("button", { name: "No technician" }).click();
await assignment.getByRole("button", { name: /^save$/i }).click();
await details.getByRole("button", { name: "Save", exact: true }).click();
const slideOverPatch = writes.find(
(write) =>
write.pathname === "/api/workorders/1/board" &&
(write.body as { field?: string }).field === "vendorId",
);
expect(slideOverPatch?.body).toEqual(
expect.objectContaining({ field: "vendorId", value: "41", technicianAssigned: false }),
);
await page.reload();
const reloadedRow = page.locator("#wo-row-1");
await reloadedRow.hover();
await reloadedRow.getByRole("button", { name: "View details" }).click();
const reloadedDetails = page.getByRole("dialog", { name: "Work order details" });
await expect(reloadedDetails).toBeVisible();
await reloadedDetails.getByRole("button", { name: /Vinewood LLC/ }).click();
await expect(reloadedDetails.getByRole("button", { name: "No technician" })).toBeVisible();
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {

View file

@ -213,6 +213,7 @@ export function SlideOverInfoTabEditView({
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<VendorCell
vendorId={draft.vendorId}
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}

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";
@ -56,6 +57,21 @@ export function VendorDialog({
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
@ -97,16 +113,8 @@ export function VendorDialog({
technicianOptions={form.technicianOptions}
anchorCompanyName={form.anchorCompanyName}
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
tech={
technicianAssigned === false || frozenCompany?.technicianAssigned === false
? ""
: (frozenCompany?.contactName ?? form.selected?.name ?? form.t)
}
phone={
technicianAssigned === false || frozenCompany?.technicianAssigned === false
? ""
: (frozenCompany?.phone ?? form.phone)
}
tech={displayTechnician}
phone={displayTechnicianPhone}
notes={form.notes}
resolvedCompany={form.resolvedCompany}
selectedVendor={form.selectedVendor}

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

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

@ -157,6 +157,35 @@ describe("VendorDialog save gate", () => {
});
});
it("shows the editable phone for a newly selected technician and hides it for No technician", async () => {
renderWithProviders(
<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

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

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