diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 7eaf94f3..bdfe8af9 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -59,18 +59,29 @@ export function SlideOverInfoTabEditView({ pocName={draft.pocName} pocPhone={draft.pocPhone} pocNotes={draft.pocNotes} + additionalContacts={draft.additionalContacts} sites={sites} onSave={(patch) => onDraftChange({ site: patch.site, location: patch.location, locationId: patch.locationId, + pocName: patch.pocName, + pocPhone: patch.pocPhone, + pocNotes: patch.pocNotes, + ...(patch.additionalContacts !== undefined + ? { additionalContacts: patch.additionalContacts } + : {}), }) } /> - onDraftChange({ type })} /> + onDraftChange({ type, severity })} + /> (); - - const primaryKey = primary?.toLowerCase() ?? ""; - - const out: string[] = []; - - for (const raw of extras) { - const trimmed = raw.trim(); - - if (!trimmed) { - continue; - } - - const key = trimmed.toLowerCase(); - - if (key === primaryKey || seen.has(key)) { - continue; - } - - seen.add(key); - - out.push(trimmed); - } - - return out.length > 0 ? out : undefined; -} - -/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */ +/** Mutates `payload` with primary service, service notes, POC, tech phone, and vendor notes. */ export function assignWizardServicePocFields( payload: WorkOrderBoardCreatePayload, draft: WorkOrderWizardDraft, @@ -54,12 +19,6 @@ export function assignWizardServicePocFields( payload.primaryService = primaryService; } - const extraServices = normalizeExtraServices(primaryService, draft.extraServices); - - if (extraServices) { - payload.extraServices = extraServices; - } - const serviceNotes = trimOrUndefined(draft.pmNote); if (serviceNotes) { diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 2b4a3226..0223f99d 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -1,3 +1,4 @@ +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { WorkOrderTablePatch, WorkOrderTableRow, @@ -5,8 +6,9 @@ import type { /** * Fields compared for dirty state and included in Save patch. - * Only keys that map to board PATCH (or required companions like dispatcherName / vendor labels). - * POC, locationId, and tech phone are not board-editable — exclude so Save cannot no-op. + * Only keys that map to board PATCH (or required companions like dispatcherName). + * Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op. + * additionalContacts is board-editable (SH-218 PATCH field). */ export const SLIDE_OVER_EDIT_KEYS = [ "woNumber", @@ -23,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "pm", "serviceNotes", "extraServices", + "additionalContacts", "vendorId", // company/tech are display companions; board persists vendor via vendorId only "status", @@ -37,6 +40,23 @@ function sameExtraServices(a: string[] | undefined, b: string[] | undefined): bo return left.every((value, index) => value === right[index]); } +function sameAdditionalContacts( + a: WorkOrderAdditionalContact[] | undefined, + b: WorkOrderAdditionalContact[] | undefined, +): boolean { + const left = a ?? []; + const right = b ?? []; + if (left.length !== right.length) return false; + return left.every((contact, index) => { + const other = right[index]; + return ( + contact.name === other.name && + contact.phone === other.phone && + (contact.notes ?? "") === (other.notes ?? "") + ); + }); +} + export function isSlideOverDraftDirty( draft: WorkOrderTableRow, baseline: WorkOrderTableRow, @@ -46,6 +66,12 @@ export function isSlideOverDraftDirty( if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true; continue; } + if (key === "additionalContacts") { + if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) { + return true; + } + continue; + } if (draft[key] !== baseline[key]) return true; } return false; @@ -64,6 +90,12 @@ export function buildSlideOverPatch( } continue; } + if (key === "additionalContacts") { + if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) { + patch.additionalContacts = draft.additionalContacts; + } + continue; + } if (draft[key] !== baseline[key]) { (patch as Record)[key] = draft[key]; } diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx new file mode 100644 index 00000000..a29b66a5 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; +import { buildSlideOverPatch } from "@/domain/work-orders/utils/slide-over-draft"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: ({ + additionalContacts, + onSave, + }: { + additionalContacts?: { name: string; phone: string }[]; + onSave: (patch: { + site: string; + location: string; + locationId: string; + pocName: string; + pocPhone: string; + pocNotes: string; + additionalContacts?: { name: string; phone: string }[]; + }) => void; + }) => ( + + ), +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell", () => ({ + EditableWONumberCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ + TypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ + AssignedToCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-07-20", + completedDate: "", + targetWeek: "", + dayGroup: "07/20", + dayLabel: "Mon", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + company: "Vendor Co", + tech: "Tech", + techPhone: "", + vendorId: "v1", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +describe("SlideOverInfoTabEditView additional contacts", () => { + it("passes additionalContacts into SiteCell and persists them on save", () => { + const onDraftChange = vi.fn(); + const contacts = [{ name: "Existing", phone: "555-0000" }]; + + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /save site 1/i })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ + additionalContacts: [{ name: "Backup", phone: "555-0101" }], + }), + ); + }); + + it("includes additionalContacts in the slide-over board PATCH ops", () => { + const baseline = baseRow(); + const draft = baseRow({ + additionalContacts: [{ name: "Backup", phone: "555-0101", notes: "Gate" }], + }); + const patch = buildSlideOverPatch(draft, baseline); + + expect(expandTablePatchToBoardFieldOps(patch)).toEqual([ + { + field: "additionalContacts", + value: JSON.stringify([{ name: "Backup", phone: "555-0101", notes: "Gate" }]), + }, + ]); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 91ab575d..1acb2aa6 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -140,8 +140,6 @@ describe("mapWizardDraftToBoardCreatePayload", () => { primaryService: "HVAC PM", - extraServices: ["Filter change", "Coil clean"], - serviceNotes: "Check unit on roof", pocName: "Jane Site Lead", @@ -327,7 +325,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.vendorNotes).toBe("Also not in description"); }); - it("maps service notes as structured fields and extraServices without duplicates", () => { + it("maps service notes as structured fields and omits extraServices on create", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, @@ -344,7 +342,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.primaryService).toBe("HVAC PM"); - expect(payload.extraServices).toEqual(["Filter change", "Coil clean"]); + expect(payload.extraServices).toBeUndefined(); expect(payload.serviceNotes).toBe("Check unit on roof"); 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 ca02ae44..ffd65fc0 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 @@ -61,6 +61,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("additionalContacts"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone"); @@ -110,4 +111,14 @@ describe("slide-over-draft", () => { extraServices: ["HVAC"], }); }); + + it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => { + const row = baseRow(); + const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }]; + const draft = baseRow({ additionalContacts: contacts }); + + expect(isSlideOverDraftDirty(draft, row)).toBe(true); + expect(buildSlideOverPatch(draft, row)).toEqual({ additionalContacts: contacts }); + expect(isSlideOverDraftDirty(baseRow({ additionalContacts: [] }), row)).toBe(false); + }); });