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);
+ });
});