fix(work-orders): omit extraServices on create and persist slide-over POCs (SH-218)

This commit is contained in:
arthur.bassi 2026-08-17 14:12:25 -03:00
parent 1e88c3d039
commit 0d609124d1
6 changed files with 221 additions and 49 deletions

View file

@ -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 }
: {}),
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(type, severity) => onDraftChange({ type, severity })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<AssignedToCell

View file

@ -8,42 +8,7 @@ function trimOrUndefined(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
function normalizeExtraServices(
primary: string | undefined,
extras: string[],
): string[] | undefined {
if (extras.length === 0) {
return undefined;
}
const seen = new Set<string>();
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) {

View file

@ -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<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key];
}

View file

@ -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;
}) => (
<button
type="button"
onClick={() =>
onSave({
site: "SEA1",
location: "Seattle",
locationId: "loc-1",
pocName: "Pat",
pocPhone: "111",
pocNotes: "",
additionalContacts: [{ name: "Backup", phone: "555-0101" }],
})
}
>
save site {additionalContacts?.length ?? 0}
</button>
),
}));
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> = {}): 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(
<SlideOverInfoTabEditView
draft={baseRow({ additionalContacts: contacts })}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
allOrders={[]}
onDraftChange={onDraftChange}
/>,
{ 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" }]),
},
]);
});
});

View file

@ -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");

View file

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