void;
+ onConfirm: () => void;
+};
+
+export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) {
+ return (
+
+
+ Cancel
+
+
+ Save
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
index 61ca8f78..66874d72 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx
@@ -1,87 +1 @@
-import {
- DLG_INPUT_CLS,
- DLG_INPUT_STYLE,
-} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
-import { Text } from "@/components/ui/text";
-import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
-
-type SiteDialogPocFieldsProps = {
- pocName: string;
- pocPhone: string;
- pocNotes: string;
- editable?: boolean;
- showErrors?: boolean;
- onPocNameChange?: (value: string) => void;
- onPocPhoneChange?: (value: string) => void;
- onPocNotesChange?: (value: string) => void;
-};
-
-export function SiteDialogPocFields({
- pocName,
- pocPhone,
- pocNotes,
- editable = false,
- showErrors = false,
- onPocNameChange,
- onPocPhoneChange,
- onPocNotesChange,
-}: SiteDialogPocFieldsProps) {
- const nameMissing = showErrors && !pocName.trim();
- const phoneMissing = showErrors && !pocPhone.trim();
-
- return (
-
-
Point of Contact
-
- {editable
- ? "Auto-filled from records — edit if outdated"
- : "Shown from site/work order records. Board edit saves site code only."}
-
-
-
- );
-}
+export { PocContactFields as SiteDialogPocFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx
new file mode 100644
index 00000000..48ecbec6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx
@@ -0,0 +1,41 @@
+import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+
+type SiteDialogPocSectionProps = {
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
+ showPrimaryErrors?: boolean;
+ onPocNameChange: (value: string) => void;
+ onPocPhoneChange: (value: string) => void;
+ onPocNotesChange: (value: string) => void;
+ onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
+};
+
+export function SiteDialogPocSection({
+ pocName,
+ pocPhone,
+ pocNotes,
+ additionalContacts,
+ showPrimaryErrors = false,
+ onPocNameChange,
+ onPocPhoneChange,
+ onPocNotesChange,
+ onAdditionalContactsChange,
+}: SiteDialogPocSectionProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts
new file mode 100644
index 00000000..499af6aa
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts
@@ -0,0 +1,12 @@
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+
+export type SitePatch = {
+ site: string;
+ location: string;
+ locationId: string | number;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ /** Omitted for site-only saves when GET had no contacts and the user did not edit them. */
+ additionalContacts?: WorkOrderAdditionalContact[];
+};
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
index 12c17523..9ef54664 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
@@ -2,17 +2,16 @@ import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
-import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
-import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
-import {
- useSiteDialogState,
- type SitePatch,
-} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
+import { SiteDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer";
+import { SiteDialogPocSection } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section";
+import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
+import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
export type { SitePatch };
@@ -24,6 +23,7 @@ type SiteDialogProps = {
pocName?: string;
pocPhone?: string;
pocNotes?: string;
+ additionalContacts?: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode?: boolean;
onSave: (patch: SitePatch) => void;
@@ -37,6 +37,7 @@ export function SiteDialog({
pocName = "",
pocPhone = "",
pocNotes = "",
+ additionalContacts = [],
sites,
createMode = false,
onSave,
@@ -49,6 +50,7 @@ export function SiteDialog({
pocName,
pocPhone,
pocNotes,
+ additionalContacts,
sites,
createMode,
onSave,
@@ -116,26 +118,20 @@ export function SiteDialog({
/>
)}
-
-
-
- Cancel
-
-
- Save
-
-
+
);
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
index 17d44464..32df0645 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
@@ -1,21 +1,14 @@
import { useEffect, useState } from "react";
-import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
+import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
+import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
+import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
-import {
- extractLocationLabel,
- resolveLocationId,
-} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
-export type SitePatch = {
- site: string;
- location: string;
- locationId: string | number;
- pocName: string;
- pocPhone: string;
- pocNotes: string;
-};
+export type { SitePatch };
type UseSiteDialogStateArgs = {
open: boolean;
@@ -25,6 +18,7 @@ type UseSiteDialogStateArgs = {
pocName: string;
pocPhone: string;
pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
onSave: (patch: SitePatch) => void;
@@ -38,6 +32,7 @@ export function useSiteDialogState({
pocName,
pocPhone,
pocNotes,
+ additionalContacts = [],
sites,
createMode,
onSave,
@@ -47,7 +42,13 @@ export function useSiteDialogState({
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
+ const [extraContacts, setExtraContacts] = useState(additionalContacts);
const [pocFilledFor, setPocFilledFor] = useState("");
+ const [dirty, setDirty] = useState(false);
+ const [contactsDirty, setContactsDirty] = useState(false);
+ const [baselineHadContacts, setBaselineHadContacts] = useState(
+ () => additionalContacts.length > 0,
+ );
const [showErrors, setShowErrors] = useState(false);
const {
@@ -64,6 +65,14 @@ export function useSiteDialogState({
useEffect(() => {
if (!open) {
+ setDirty(false);
+ setContactsDirty(false);
+ setShowErrors(false);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || dirty) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
@@ -72,15 +81,15 @@ export function useSiteDialogState({
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
- setShowErrors(false);
+ setExtraContacts(additionalContacts);
+ setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
- }, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
+ }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
- // Only autofill empty fields so typed createMode POC edits are not overwritten.
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
@@ -94,49 +103,72 @@ export function useSiteDialogState({
setCode(resolveSiteCode(next));
}
if (siteChanged) {
- setPn("");
- setPp("");
+ setExtraContacts([]);
+ setContactsDirty(true);
+ setDirty(true);
+ if (createMode) {
+ setPn("");
+ setPp("");
+ }
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
+ const buildPatch = (): SitePatch =>
+ buildSiteDialogPatch({
+ code,
+ locId,
+ selected,
+ pocName: pn,
+ pocPhone: pp,
+ pocNotes: notes,
+ extraContacts,
+ contactsDirty,
+ baselineHadContacts,
+ });
+
+ const markPocDirty = (apply: () => void) => {
+ setDirty(true);
+ apply();
+ };
+
+ const markContactsDirty = (contacts: WorkOrderAdditionalContact[]) => {
+ setDirty(true);
+ setContactsDirty(true);
+ setExtraContacts(contacts);
+ };
+
const attemptSave = () => {
if (createMode && !canConfirm) {
setShowErrors(true);
return;
}
- onSave({
- site: code || (selected ? resolveSiteCode(selected) : ""),
- location: selected ? extractLocationLabel(selected) : "",
- locationId: locId,
- pocName: pn,
- pocPhone: pp,
- pocNotes: notes,
- });
+ onSave(buildPatch());
onOpenChange(false);
};
return {
locId,
- pn,
- pp,
- notes,
- showErrors,
+ code,
+ selected,
locationDetail,
locationDetailLoading,
locationDetailError,
- selected,
addressPreview,
+ pn,
+ pp,
+ notes,
+ extraContacts,
+ showErrors,
siteMissing,
- canConfirm,
- code,
handlePick,
attemptSave,
- setPn,
- setPp,
- setNotes,
+ setPn: (next: string) => markPocDirty(() => setPn(next)),
+ setPp: (next: string) => markPocDirty(() => setPp(next)),
+ setNotes: (next: string) => markPocDirty(() => setNotes(next)),
+ setExtraContacts: markContactsDirty,
close: () => onOpenChange(false),
};
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
index 0b79520d..47ca7b46 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx
@@ -55,6 +55,7 @@ export function WoTableInlineIdentityCells({
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
+ additionalContacts={draft.additionalContacts}
sites={sites}
createMode
onSave={(p) =>
@@ -65,6 +66,9 @@ export function WoTableInlineIdentityCells({
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
+ ...(p.additionalContacts !== undefined
+ ? { additionalContacts: p.additionalContacts }
+ : {}),
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index a022f6fa..f50d464d 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -160,7 +160,6 @@ export function WoTableInlineRowCells({
}}
/>
-
@@ -170,7 +169,6 @@ export function WoTableInlineRowCells({
onSave={(date) => onPatch({ dueDate: date })}
/>
-
-
-
-
@@ -62,6 +63,9 @@ export function WoTableRowCells({
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
+ ...(p.additionalContacts !== undefined
+ ? { additionalContacts: p.additionalContacts }
+ : {}),
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
new file mode 100644
index 00000000..0449fadd
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
@@ -0,0 +1,223 @@
+import { Plus, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import {
+ DLG_INPUT_CLS,
+ DLG_INPUT_STYLE,
+} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
+import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
+import { isIncompleteAdditionalContact } from "@/domain/work-orders/mappers/additional-contacts-mapper";
+import {
+ EMPTY_ADDITIONAL_CONTACT,
+ type WorkOrderAdditionalContact,
+} from "@/domain/work-orders/types/work-order-additional-contact";
+
+type PocContactFieldsProps = {
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
+ onPocNameChange: (value: string) => void;
+ onPocPhoneChange: (value: string) => void;
+ onPocNotesChange: (value: string) => void;
+ onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
+ helperText?: string;
+ showPrimaryErrors?: boolean;
+};
+
+function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
+ return isIncompleteAdditionalContact(contact) && !contact.name.trim();
+}
+
+function extraPhoneMissing(contact: WorkOrderAdditionalContact): boolean {
+ return isIncompleteAdditionalContact(contact) && !contact.phone.trim();
+}
+
+function updateContactAt(
+ contacts: WorkOrderAdditionalContact[],
+ index: number,
+ patch: Partial,
+): WorkOrderAdditionalContact[] {
+ return contacts.map((contact, contactIndex) =>
+ contactIndex === index ? { ...contact, ...patch } : contact,
+ );
+}
+
+function AdditionalPocCard({
+ contact,
+ index,
+ onChange,
+ onRemove,
+}: {
+ contact: WorkOrderAdditionalContact;
+ index: number;
+ onChange: (patch: Partial) => void;
+ onRemove: () => void;
+}) {
+ const nameMissing = extraNameMissing(contact);
+ const phoneMissing = extraPhoneMissing(contact);
+
+ return (
+
+
+
+ Point of contact {index + 2}
+
+
+
+
+
+
+
+ );
+}
+
+export function PocContactFields({
+ pocName,
+ pocPhone,
+ pocNotes,
+ additionalContacts,
+ onPocNameChange,
+ onPocPhoneChange,
+ onPocNotesChange,
+ onAdditionalContactsChange,
+ helperText,
+ showPrimaryErrors = false,
+}: PocContactFieldsProps) {
+ const nameMissing = showPrimaryErrors && !pocName.trim();
+ const phoneMissing = showPrimaryErrors && !pocPhone.trim();
+
+ const handleAddContact = () => {
+ onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
+ };
+
+ return (
+
+
Point of Contact
+ {Boolean(helperText) && (
+
+ {helperText}
+
+ )}
+
+
+ {additionalContacts.map((contact, index) => (
+
+ onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
+ }
+ onRemove={() =>
+ onAdditionalContactsChange(
+ additionalContacts.filter((_, contactIndex) => contactIndex !== index),
+ )
+ }
+ />
+ ))}
+
+
+
+ Add point of contact
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
index 5295cc69..e49e267a 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
@@ -26,6 +26,18 @@ function pocLabel(draft: WorkOrderWizardDraft): string {
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
}
+function formatAdditionalPocSummary(draft: WorkOrderWizardDraft): string {
+ const count = draft.additionalContacts.length;
+ if (count === 0) {
+ return "—";
+ }
+ const names = draft.additionalContacts.map((contact) => contact.name.trim()).filter(Boolean);
+ if (names.length > 0) {
+ return names.join(", ");
+ }
+ return `${count} contact${count === 1 ? "" : "s"}`;
+}
+
/** Builds the label/value rows for the wizard sidebar's live summary card. */
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
return [
@@ -36,6 +48,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
["Assigned to", draft.assigneeName || "—"],
["Site", draft.siteCode || draft.locationName || "—"],
["POC", pocLabel(draft)],
+ ["Additional POCs", formatAdditionalPocSummary(draft)],
["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"],
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
index cfd6f17f..61bf4927 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
@@ -1,10 +1,5 @@
-import { Text } from "@/components/ui/text";
+import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
-import {
- DLG_INPUT_CLS,
- DLG_INPUT_STYLE,
-} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
-import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft;
@@ -20,44 +15,17 @@ export function WizardStepLocationPocFields({
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return (
-
-
Point of contact
-
- Auto-filled from records — edit if outdated
-
-
- onUpdate("pocName", e.target.value)}
- placeholder="POC name"
- className={DLG_INPUT_CLS}
- style={DLG_INPUT_STYLE}
- />
- onUpdate("pocPhone", e.target.value)}
- placeholder="POC phone"
- className={DLG_INPUT_CLS}
- style={DLG_INPUT_STYLE}
- />
-
- {Boolean(showErrors && pocIncomplete) && (
-
- POC name and phone are required.
-
- )}
-
+ onUpdate("pocName", value)}
+ onPocPhoneChange={(value) => onUpdate("pocPhone", value)}
+ onPocNotesChange={(value) => onUpdate("pocNotes", value)}
+ onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
+ helperText="Auto-filled from records — edit if outdated"
+ showPrimaryErrors={showErrors && pocIncomplete}
+ />
);
}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
index 8d06d966..583828d9 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
@@ -42,6 +42,7 @@ export function WizardStepLocationSiteSelect({
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
});
}}
/>
diff --git a/src/domain/work-orders/mappers/additional-contacts-mapper.ts b/src/domain/work-orders/mappers/additional-contacts-mapper.ts
new file mode 100644
index 00000000..82ab5b17
--- /dev/null
+++ b/src/domain/work-orders/mappers/additional-contacts-mapper.ts
@@ -0,0 +1,102 @@
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+
+function asRecord(raw: unknown): Record {
+ return typeof raw === "object" && raw !== null ? (raw as Record) : {};
+}
+
+function readString(record: Record, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number") {
+ return String(value);
+ }
+ }
+ return "";
+}
+
+function mapContactItem(raw: unknown): WorkOrderAdditionalContact | null {
+ const item = asRecord(raw);
+ const name = readString(item, "name", "Name");
+ const phone = readString(item, "phone", "Phone");
+ const notes = readString(item, "notes", "Notes");
+
+ if (!name.trim() && !phone.trim()) {
+ return null;
+ }
+
+ const contact: WorkOrderAdditionalContact = {
+ name,
+ phone,
+ };
+
+ if (notes.trim()) {
+ contact.notes = notes;
+ }
+
+ return contact;
+}
+
+/** Reads additional POCs from board/detail payloads; missing field yields []. */
+export function readAdditionalContacts(
+ record: Record,
+): WorkOrderAdditionalContact[] {
+ for (const key of ["additionalContacts", "AdditionalContacts"]) {
+ const value = record[key];
+ if (!Array.isArray(value)) {
+ continue;
+ }
+
+ return value
+ .map(mapContactItem)
+ .filter((contact): contact is WorkOrderAdditionalContact => contact !== null);
+ }
+
+ return [];
+}
+
+/** True when exactly one of name/phone is filled (not persistable). */
+export function isIncompleteAdditionalContact(contact: WorkOrderAdditionalContact): boolean {
+ const hasName = Boolean(contact.name.trim());
+ const hasPhone = Boolean(contact.phone.trim());
+ return hasName !== hasPhone;
+}
+
+/** Drops empty and incomplete rows; returns undefined when nothing to persist. */
+export function normalizeAdditionalContacts(
+ contacts: WorkOrderAdditionalContact[],
+): WorkOrderAdditionalContact[] | undefined {
+ const out: WorkOrderAdditionalContact[] = [];
+
+ for (const raw of contacts) {
+ const name = raw.name.trim();
+ const phone = raw.phone.trim();
+ const notes = raw.notes?.trim();
+
+ if (!name && !phone) {
+ continue;
+ }
+
+ if (!name || !phone) {
+ continue;
+ }
+
+ const contact: WorkOrderAdditionalContact = { name, phone };
+
+ if (notes) {
+ contact.notes = notes;
+ }
+
+ out.push(contact);
+ }
+
+ return out.length > 0 ? out : undefined;
+}
+
+export function serializeAdditionalContactsForPatch(
+ contacts: WorkOrderAdditionalContact[],
+): string {
+ return JSON.stringify(normalizeAdditionalContacts(contacts) ?? []);
+}
diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
index f6636932..e6041f83 100644
--- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
@@ -1,5 +1,6 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
function trimOrUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
@@ -42,6 +43,11 @@ export function assignWizardServicePocFields(
payload.pocNotes = pocNotes;
}
+ const additionalContacts = normalizeAdditionalContacts(draft.additionalContacts);
+ if (additionalContacts) {
+ payload.additionalContacts = additionalContacts;
+ }
+
const techPhone = trimOrUndefined(draft.techPhone);
if (techPhone) {
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 d5ba9f78..49d3e157 100644
--- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
+++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
@@ -1,5 +1,7 @@
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+import { serializeAdditionalContactsForPatch } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
export type BoardFieldOp = {
@@ -37,6 +39,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
+ {
+ key: "additionalContacts",
+ field: "additionalContacts",
+ toValue: (value) => serializeAdditionalContactsForPatch(value as WorkOrderAdditionalContact[]),
+ },
];
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index f845f009..8dfe70e0 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -1,3 +1,4 @@
+import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
@@ -357,6 +358,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
pocName: readString(item, "pocName", "PocName", "POCName"),
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
+ additionalContacts: readAdditionalContacts(item),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
diff --git a/src/domain/work-orders/types/work-order-additional-contact.ts b/src/domain/work-orders/types/work-order-additional-contact.ts
new file mode 100644
index 00000000..dd5d7abf
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-additional-contact.ts
@@ -0,0 +1,11 @@
+export interface WorkOrderAdditionalContact {
+ name: string;
+ phone: string;
+ notes?: string;
+}
+
+export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
+ name: "",
+ phone: "",
+ notes: "",
+};
diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts
index ab3737b2..dda17df8 100644
--- a/src/domain/work-orders/types/work-order-board-detail.ts
+++ b/src/domain/work-orders/types/work-order-board-detail.ts
@@ -1,4 +1,5 @@
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
@@ -26,6 +27,7 @@ export interface WorkOrderBoardDetailInfoApi {
pocName?: string | null;
pocPhone?: string | null;
pocNotes?: string | null;
+ additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null;
legacyStatus?: string | null;
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index 9d6e822e..5011b67b 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -1,4 +1,5 @@
import type { DateRangeKey } from "./work-order-advanced-filters";
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WorkOrderTableRow } from "./work-order-table-row";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
@@ -108,6 +109,7 @@ export interface WorkOrderBoardCreatePayload {
pocName?: string;
pocPhone?: string;
pocNotes?: string;
+ additionalContacts?: WorkOrderAdditionalContact[];
techPhone?: string;
vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
@@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
| "serviceNotes"
| "extraServices"
| "flagColor"
+ | "additionalContacts"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
@@ -159,6 +162,7 @@ export interface WorkOrderBoardRowApi {
pocName?: string | null;
pocPhone?: string | null;
pocNotes?: string | null;
+ additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null;
legacyStatus?: string | null;
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index d4dc3782..4b066382 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -1,3 +1,4 @@
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
@@ -46,6 +47,7 @@ export interface WorkOrderTableRow {
pocName: string;
pocPhone: string;
pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
dueDate: string;
rescheduleCount: number;
carriedOver: number;
@@ -94,6 +96,7 @@ export interface WorkOrderTableDraft {
pocName: string;
pocPhone: string;
pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
targetWeek: string;
/** Manual Add-On override when schedule is empty at inline create. */
@@ -124,6 +127,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts
index f0cc1c79..cc4c1ad7 100644
--- a/src/domain/work-orders/types/work-order-wizard.ts
+++ b/src/domain/work-orders/types/work-order-wizard.ts
@@ -1,3 +1,4 @@
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
export type { WorkOrderSeverity } from "./work-order-severity";
@@ -50,6 +51,8 @@ export interface WorkOrderWizardDraft {
pocName: string;
pocPhone: string;
pocNotes: string;
+ /** Secondary POCs beyond primary pocName/pocPhone/pocNotes. */
+ additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
targetWeek?: string;
@@ -89,6 +92,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
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/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
index 72fa8e30..f36cc442 100644
--- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -58,6 +58,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
index 18bf4862..fd628353 100644
--- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -70,6 +70,7 @@ function baseRow(): WorkOrderTableRow {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
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..b1627674
--- /dev/null
+++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx
@@ -0,0 +1,165 @@
+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;
+ }) => (
+
+ onSave({
+ site: "SEA1",
+ location: "Seattle",
+ locationId: "loc-1",
+ pocName: "Pat",
+ pocPhone: "111",
+ pocNotes: "",
+ additionalContacts: [{ name: "Backup", phone: "555-0101" }],
+ })
+ }
+ >
+ save site {additionalContacts?.length ?? 0}
+
+ ),
+}));
+
+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,
+ isAddOn: false,
+ 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/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
index d886232a..a3755204 100644
--- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
+++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
@@ -50,6 +50,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ additionalContacts: [],
...overrides,
};
}
diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
index a7992bac..80a9b011 100644
--- a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
+++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
@@ -45,6 +45,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
index dc0adb78..b3f72c52 100644
--- a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
@@ -42,6 +42,7 @@ function renderSiteDialogState() {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
sites: SITES,
createMode: true,
onSave: vi.fn(),
diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
index 9b933157..e4d7d670 100644
--- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -35,6 +35,7 @@ function baseInfo(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
index 66a61208..899c1ee8 100644
--- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
@@ -59,6 +59,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ additionalContacts: [],
...overrides,
};
}
diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
index 36b6c520..a8c2924a 100644
--- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
@@ -39,6 +39,7 @@ const unscheduledRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-15",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
index f49cf4a1..9597efee 100644
--- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
@@ -78,6 +78,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
index 4230eb74..1afe8888 100644
--- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
@@ -54,6 +54,7 @@ const baseRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 08019764..c9b46873 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -97,6 +97,7 @@ const canceledRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts b/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts
new file mode 100644
index 00000000..9b76a8b5
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import {
+ isIncompleteAdditionalContact,
+ normalizeAdditionalContacts,
+ readAdditionalContacts,
+ serializeAdditionalContactsForPatch,
+} from "@/domain/work-orders/mappers/additional-contacts-mapper";
+
+describe("readAdditionalContacts", () => {
+ it("returns [] when field is missing", () => {
+ expect(readAdditionalContacts({ id: 1 })).toEqual([]);
+ });
+
+ it("maps camelCase and PascalCase arrays", () => {
+ expect(
+ readAdditionalContacts({
+ additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate code" }],
+ }),
+ ).toEqual([{ name: "Alex", phone: "555-0100", notes: "Gate code" }]);
+
+ expect(
+ readAdditionalContacts({
+ AdditionalContacts: [{ Name: "Sam", Phone: "555-0200" }],
+ }),
+ ).toEqual([{ name: "Sam", phone: "555-0200" }]);
+ });
+
+ it("drops empty rows", () => {
+ expect(
+ readAdditionalContacts({
+ additionalContacts: [
+ { name: "", phone: "" },
+ { name: "Pat", phone: "111" },
+ ],
+ }),
+ ).toEqual([{ name: "Pat", phone: "111" }]);
+ });
+});
+
+describe("normalizeAdditionalContacts", () => {
+ it("trims and omits empty notes", () => {
+ expect(
+ normalizeAdditionalContacts([
+ { name: " Alex ", phone: " 555 ", notes: " " },
+ { name: "", phone: "" },
+ ]),
+ ).toEqual([{ name: "Alex", phone: "555" }]);
+ });
+
+ it("returns undefined when all rows are empty", () => {
+ expect(normalizeAdditionalContacts([{ name: "", phone: "" }])).toBeUndefined();
+ });
+
+ it("drops name-only and phone-only rows", () => {
+ expect(
+ normalizeAdditionalContacts([
+ { name: "Alex", phone: "" },
+ { name: "", phone: "555-0100" },
+ { name: "Pat", phone: "111" },
+ ]),
+ ).toEqual([{ name: "Pat", phone: "111" }]);
+ });
+});
+
+describe("isIncompleteAdditionalContact", () => {
+ it("is true when exactly one of name or phone is filled", () => {
+ expect(isIncompleteAdditionalContact({ name: "Alex", phone: "" })).toBe(true);
+ expect(isIncompleteAdditionalContact({ name: "", phone: "555" })).toBe(true);
+ expect(isIncompleteAdditionalContact({ name: "Alex", phone: "555" })).toBe(false);
+ expect(isIncompleteAdditionalContact({ name: "", phone: "" })).toBe(false);
+ });
+});
+
+describe("serializeAdditionalContactsForPatch", () => {
+ it("JSON-stringifies normalized contacts for board PATCH", () => {
+ expect(
+ serializeAdditionalContactsForPatch([
+ { name: "Alex", phone: "555-0100" },
+ { name: "", phone: "" },
+ ]),
+ ).toBe('[{"name":"Alex","phone":"555-0100"}]');
+ });
+
+ it("serializes empty array when nothing to persist", () => {
+ expect(serializeAdditionalContactsForPatch([])).toBe("[]");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
index 1bf399ce..edfd4067 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
@@ -41,6 +41,10 @@ const SAMPLE_DETAIL = {
pm: "HVAC PM",
serviceNotes: "Check filters",
extraServices: ["Filter Change"],
+ additionalContacts: [
+ { name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
+ { name: "Security Desk", phone: "+1 555-0102" },
+ ],
docStatus: 2,
flagColor: "#EF4444",
primaryDispatchId: 789,
@@ -124,6 +128,10 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.tech).toBe("Mike Tech");
expect(detail.info.pm).toBe("HVAC PM");
expect(detail.info.location).toBe("Main Warehouse");
+ expect(detail.info.additionalContacts).toEqual([
+ { name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
+ { name: "Security Desk", phone: "+1 555-0102" },
+ ]);
expect(detail.completion.docStatus).toBe("No");
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
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 c766362d..eff3b3fc 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
@@ -161,6 +161,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
pocNotes: "Call before arrival",
+ additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
+
techPhone: "+1 555-0199",
vendorNotes: "Gate code 4421",
@@ -204,12 +206,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
vendorNotes: "Gate code 4421",
locationId: 12,
+
+ additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
});
expect(payload.trade).toBeUndefined();
- expect(payload.extraServices).toBeUndefined();
-
expect(payload.description).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined();
@@ -376,7 +378,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.vendorNotes).toBe("Also not in description");
});
- it("maps service notes as structured fields and omits extraServices from wizard create", () => {
+ it("maps service notes as structured fields and omits extraServices on create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
index 9558aa55..f26d13fe 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts
@@ -113,6 +113,19 @@ describe("expandTablePatchToBoardFieldOps", () => {
]);
});
+ it("emits additionalContacts as serialized JSON for board PATCH", () => {
+ expect(
+ expandTablePatchToBoardFieldOps({
+ additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate" }],
+ }),
+ ).toEqual([
+ {
+ field: "additionalContacts",
+ value: JSON.stringify([{ name: "Alex", phone: "555-0100", notes: "Gate" }]),
+ },
+ ]);
+ });
+
it("ignores local-only companion keys when expanding vendor/site", () => {
expect(
expandTablePatchToBoardFieldOps({
diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index 107d63f0..676e26a3 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -164,6 +164,7 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(row.serviceNotes).toBe("service notes");
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
expect(row.docStatus).toBe("Yes");
+ expect(row.additionalContacts).toEqual([]);
expect(row.dayGroup).toBe("07/15");
expect(row.flagColor).toBe("#EF4444");
});
@@ -175,6 +176,15 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
});
+ it("maps additionalContacts from board rows", () => {
+ expect(
+ mapWorkOrderTableRow({
+ id: 1,
+ additionalContacts: [{ name: "Alex", phone: "555-0100" }],
+ }).additionalContacts,
+ ).toEqual([{ name: "Alex", phone: "555-0100" }]);
+ });
+
it("maps uplift summary and pending flag from API payload", () => {
const row = mapWorkOrderTableRow({
id: 303,
diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
index 80f4092b..73729279 100644
--- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -83,6 +83,7 @@ const BASE_ROW: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
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 fa9db9e1..885b7780 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
@@ -38,6 +38,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
@@ -63,6 +64,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");
@@ -112,4 +114,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);
+ });
});
diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
index bb1c5d8a..a85a291a 100644
--- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
@@ -59,6 +59,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
index f2b39dab..a8b09c3a 100644
--- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts
+++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts
@@ -54,6 +54,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,