mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 20:32:05 +00:00
fix(work-orders): retain technician state across editors
This commit is contained in:
parent
5adde3723b
commit
bfdc2dbdec
8 changed files with 206 additions and 13 deletions
|
|
@ -121,6 +121,18 @@ async function mockApi(
|
||||||
scheduled: currentBoardRows,
|
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") {
|
if (pathname === "/api/workorders/board" && method === "POST") {
|
||||||
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
|
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +159,7 @@ async function mockApi(
|
||||||
? {
|
? {
|
||||||
technicianAssigned: true,
|
technicianAssigned: true,
|
||||||
techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte",
|
techName: vendorId === 50 ? "Avery Chen" : "Adam Whyte",
|
||||||
|
techPhone: "(314) 555-0141",
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
rowVersion: "rv-2",
|
rowVersion: "rv-2",
|
||||||
|
|
@ -294,6 +307,7 @@ test.describe("Inline technician registration", () => {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
|
page.getByRole("dialog").getByRole("button", { name: "No technician" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="tel"]')).toHaveCount(0);
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog")
|
.getByRole("dialog")
|
||||||
.getByRole("button", { name: /cancel/i })
|
.getByRole("button", { name: /cancel/i })
|
||||||
|
|
@ -395,6 +409,7 @@ test.describe("Inline technician registration", () => {
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await dialog.getByRole("button", { name: "No technician" }).click();
|
await dialog.getByRole("button", { name: "No technician" }).click();
|
||||||
await dialog.getByRole("button", { name: "Adam Whyte" }).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();
|
await dialog.getByRole("button", { name: /^save$/i }).click();
|
||||||
|
|
||||||
const assignmentWrite = writes.find((write) => write.pathname === "/api/workorders/1/board");
|
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");
|
const reloadedRow = page.locator("#wo-row-1");
|
||||||
await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
|
await expect(reloadedRow.getByRole("button", { name: /Adam Whyte/ })).toBeVisible();
|
||||||
await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click();
|
await reloadedRow.getByRole("button", { name: /Adam Whyte/ }).click();
|
||||||
await expect(
|
const reopenedDialog = page.getByRole("dialog");
|
||||||
page.getByRole("dialog").getByRole("button", { name: "Adam Whyte" }),
|
await expect(reopenedDialog.getByRole("button", { name: "Adam Whyte" })).toBeVisible();
|
||||||
).toBeVisible();
|
await expect(reopenedDialog.locator('input[type="tel"]')).toHaveValue("(314) 555-0141");
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog")
|
.getByRole("dialog")
|
||||||
.getByRole("button", { name: /cancel/i })
|
.getByRole("button", { name: /cancel/i })
|
||||||
|
|
@ -432,6 +447,56 @@ test.describe("Inline technician registration", () => {
|
||||||
).toBe(true);
|
).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 ({
|
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ export function SlideOverInfoTabEditView({
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Vendor">
|
<SlideOverInfoRow label="Vendor">
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={draft.vendorId}
|
||||||
company={draft.company}
|
company={draft.company}
|
||||||
tech={draft.tech}
|
tech={draft.tech}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { 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 { 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 { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
|
@ -56,6 +57,21 @@ export function VendorDialog({
|
||||||
vendors,
|
vendors,
|
||||||
onSave,
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -97,16 +113,8 @@ export function VendorDialog({
|
||||||
technicianOptions={form.technicianOptions}
|
technicianOptions={form.technicianOptions}
|
||||||
anchorCompanyName={form.anchorCompanyName}
|
anchorCompanyName={form.anchorCompanyName}
|
||||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||||
tech={
|
tech={displayTechnician}
|
||||||
technicianAssigned === false || frozenCompany?.technicianAssigned === false
|
phone={displayTechnicianPhone}
|
||||||
? ""
|
|
||||||
: (frozenCompany?.contactName ?? form.selected?.name ?? form.t)
|
|
||||||
}
|
|
||||||
phone={
|
|
||||||
technicianAssigned === false || frozenCompany?.technicianAssigned === false
|
|
||||||
? ""
|
|
||||||
: (frozenCompany?.phone ?? form.phone)
|
|
||||||
}
|
|
||||||
notes={form.notes}
|
notes={form.notes}
|
||||||
resolvedCompany={form.resolvedCompany}
|
resolvedCompany={form.resolvedCompany}
|
||||||
selectedVendor={form.selectedVendor}
|
selectedVendor={form.selectedVendor}
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,7 @@ export function WoTableInlineRowCells({
|
||||||
company={draft.company}
|
company={draft.company}
|
||||||
tech={draft.tech}
|
tech={draft.tech}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
|
technicianAssigned={draft.technicianAssigned}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
onSave={(p) => onPatch(toVendorTablePatch(p))}
|
onSave={(p) => onPatch(toVendorTablePatch(p))}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"avetaRequired",
|
"avetaRequired",
|
||||||
"additionalContacts",
|
"additionalContacts",
|
||||||
"vendorId",
|
"vendorId",
|
||||||
|
"technicianAssigned",
|
||||||
// company/tech are display companions; board persists vendor via vendorId only
|
// company/tech are display companions; board persists vendor via vendorId only
|
||||||
"status",
|
"status",
|
||||||
"pocName",
|
"pocName",
|
||||||
|
|
@ -91,6 +92,7 @@ export function buildSlideOverPatch(
|
||||||
const patch: WorkOrderTablePatch = {};
|
const patch: WorkOrderTablePatch = {};
|
||||||
|
|
||||||
for (const key of SLIDE_OVER_EDIT_KEYS) {
|
for (const key of SLIDE_OVER_EDIT_KEYS) {
|
||||||
|
if (key === "technicianAssigned") continue;
|
||||||
if (key === "extraServices") {
|
if (key === "extraServices") {
|
||||||
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) {
|
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) {
|
||||||
patch.extraServices = draft.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;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it("only offers technicians from the selected company", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<VendorDialog
|
<VendorDialog
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,50 @@ function currentMonthIso(day: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTableInlineRowCells — Schedule On range", () => {
|
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 () => {
|
it("patches schedMode range with start and end", async () => {
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ describe("slide-over-draft", () => {
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
|
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("additionalContacts");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
|
||||||
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
|
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
|
||||||
|
|
@ -87,6 +88,37 @@ describe("slide-over-draft", () => {
|
||||||
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
|
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", () => {
|
it("tracks POC edits for dirty state and patch", () => {
|
||||||
const row = baseRow();
|
const row = baseRow();
|
||||||
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
|
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue