diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index f7eef6e6..1b4b2abf 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -127,8 +127,8 @@ function SlideOverOpenService({ if (!source.pm) { return —; } - const name = - services.find((service) => String(service.id) === String(source.serviceId))?.name ?? source.pm; + const linkedService = services.find((service) => String(service.id) === String(source.serviceId)); + const name = linkedService?.isActive ? linkedService.name : source.pm; return ( diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index e5152aa7..cbb1f9e3 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -60,8 +60,8 @@ export function PMTypeCell({ const filtered = search ? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase())) : serviceNames; - const displayValue = - services?.find((service) => String(service.id) === String(serviceId))?.name ?? value; + const linkedService = services?.find((service) => String(service.id) === String(serviceId)); + const displayValue = linkedService?.isActive ? linkedService.name : value; const extras = extraServices ?? []; const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index 72d6abe5..f6dd5d04 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -21,7 +21,8 @@ type ScalarBoardOpRule = { /** One row per scalar patch field, in emission order. */ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ { key: "woNumber", field: "woNumber", toValue: (value) => value as string }, - // Severity before type so Emergency/Reactive never land without required severity. + // Severity before type, then service clear before type because the backend validates + // the currently assigned service against the requested work-order type. { key: "severity", field: "severity", diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index af8c2679..8fa3ac57 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -15,6 +15,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "woNumber", "site", "type", + "severity", "dispatcherId", "dispatcherName", "scheduledOn", @@ -26,6 +27,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "completedDate", "apptTime", "pm", + "serviceId", "serviceNotes", "extraServices", "avetaRequired", diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index 389f58ae..ff77c95b 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell"; import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { renderWithProviders } from "@/test/test-utils"; describe("Aveta Service & Notes", () => { @@ -49,6 +50,76 @@ describe("Aveta Service & Notes", () => { expect(onSave).toHaveBeenCalledWith("QA Test Service", 701); }); + it("keeps the historical label when its linked registry service is inactive", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: /Historic Service Name/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Renamed Service/i })).not.toBeInTheDocument(); + }); + + it("preserves the historic identity when only service notes change", () => { + const onSaveDetails = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Historic Service Name/i })); + fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i })); + fireEvent.change( + screen.getByPlaceholderText( + "Describe the specific issue, location details, door number, etc.", + ), + { target: { value: "Changed detail only" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + const saved = onSaveDetails.mock.calls[0]![0]; + expect(saved).toMatchObject({ pm: "Historic Service Name", serviceId: 701 }); + expect(serviceIdentityPatch(701, "Historic Service Name", saved.pm, saved.serviceId)).toEqual( + {}, + ); + }); + it("shows the Aveta shield on the Service cell when required", () => { renderWithProviders( ({ - TypeCell: () => null, + TypeCell: ({ onSave }: { onSave: (type: "Reactive", severity: number) => void }) => ( + + ), })); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ AssignedToCell: () => null, @@ -121,6 +127,85 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow } describe("SlideOverInfoTabEditView additional contacts", () => { + it("keeps the historic service label in slide-over display for an inactive service", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Historic Service Name")).toBeInTheDocument(); + expect(screen.queryByText("Renamed Service")).not.toBeInTheDocument(); + }); + + it("saves an incompatible type change only after clearing the registry service", () => { + const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); + const onSave = vi.fn(); + const pmOnlyService = { + id: 701, + name: "PM only", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM" as const], + }; + + function SlideOverSaveHarness() { + const [draft, setDraft] = useState(baseline); + return ( + <> + setDraft((current) => ({ ...current, ...patch }))} + /> + + + ); + } + + renderWithProviders(, { withAuth: false }); + fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" })); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(onSave).toHaveBeenCalledWith([ + { field: "severity", value: "2" }, + { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Reactive" }, + ]); + }); + it("passes additionalContacts into SiteCell and persists them on save", () => { const onDraftChange = vi.fn(); const contacts = [{ name: "Existing", phone: "555-0000" }]; diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index d0bae108..358f85b3 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -7,6 +7,7 @@ import { import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; +import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; function baseRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -68,6 +69,7 @@ describe("slide-over-draft", () => { 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("serviceId"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes"); @@ -77,6 +79,20 @@ describe("slide-over-draft", () => { expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech"); }); + it("saves an incompatible type change after clearing its registry service", () => { + const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); + const draft = baseRow({ type: "Reactive", severity: 2, pm: "", serviceId: null }); + const patch = buildSlideOverPatch(draft, baseline); + + expect(isSlideOverDraftDirty(draft, baseline)).toBe(true); + expect(patch).toMatchObject({ type: "Reactive", severity: 2, serviceId: null, pm: "" }); + expect(expandTablePatchToBoardFieldOps(patch)).toEqual([ + { field: "severity", value: "2" }, + { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Reactive" }, + ]); + }); + it("tracks vendor via vendorId only (company/tech are display companions)", () => { const row = baseRow(); const draft = baseRow({ company: "Other Co", tech: "Pat" });