diff --git a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts index 8566c1cd..fc557598 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts @@ -36,10 +36,11 @@ export function buildSiteDialogPatch({ locationId: locId, pocName, pocPhone, - // Notes live on the site record; an empty override lets the site's notes show through. - pocNotes: followsSiteRecord ? "" : pocNotes, + pocNotes, }; - if (followsSiteRecord || contactsDirty || baselineHadContacts) { + // Following the site record, the dialog's extra contacts are the site's: copy them only when edited. + const sendContacts = followsSiteRecord ? contactsDirty : contactsDirty || baselineHadContacts; + if (sendContacts) { patch.additionalContacts = extraContacts; } return patch; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx index 93815ade..da15ab57 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx @@ -35,6 +35,9 @@ export function SiteDialogDetailPanel({ addressPreview, fallbackLabel, }: SiteDialogDetailPanelProps) { + // The site's own phone; a frozen snapshot carries it as `phone`. + const sitePhone = locationDetail?.sitePhone ?? locationDetail?.phone ?? ""; + return (
)} {!addressPreview && } - {Boolean(locationDetail.phone) && ( - - )} + {Boolean(sitePhone) && } {Boolean(locationDetail.contactEmail) && ( )} 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 ecb98c03..0f7cfec1 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 @@ -2,7 +2,11 @@ import { useEffect, useMemo } from "react"; import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; -import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; +import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import { + useSiteRecordSync, + workOrderPocAfterSiteSave, +} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; @@ -106,14 +110,18 @@ export function useSiteDialogState({ }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); const saveWorkOrder = () => { + const poc = workOrderPocAfterSiteSave( + siteRecord, + { pocName, pocPhone, pocNotes }, + { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, + fields.locId !== resolveLocationId(locationId, value, sites), + ); onSave( buildSiteDialogPatch({ code: fields.code, locId: fields.locId, selected: fields.selected, - pocName: fields.pn, - pocPhone: fields.pp, - pocNotes: fields.notes, + ...poc, extraContacts: fields.extraContacts, contactsDirty: fields.contactsDirty, baselineHadContacts: fields.baselineHadContacts, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts index 2c5bcbad..07a482f5 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -24,6 +24,10 @@ type UseSiteRecordSyncArgs = { fields: SiteRecordFields; }; +export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string }; + +type SiteRecordEdits = { primary: boolean; notes: boolean }; + function toRequest( primaryId: number | undefined, fields: Pick, @@ -48,9 +52,47 @@ function fingerprint(request: SiteContactInfoInput): string { }); } +function diffEdits( + baseline: SiteContactInfoInput | null, + request: SiteContactInfoInput, +): SiteRecordEdits { + if (baseline === null) { + return { primary: false, notes: false }; + } + const before = baseline.contacts[0]; + const after = request.contacts[0]; + return { + primary: before.name.trim() !== after.name.trim() || before.phone.trim() !== after.phone.trim(), + notes: baseline.notes.trim() !== request.notes.trim(), + }; +} + /** - * Loads the selected site's contacts and notes into the Site dialog and reports whether the - * user changed them, so Save can write them back to the site record. + * The work order's own POC values once the site record owns them: a field follows the site only + * when the user edited it (or picked another site); untouched fields keep the work order's values. + */ +export function workOrderPocAfterSiteSave( + siteRecord: { synced: boolean; edits: SiteRecordEdits }, + original: WorkOrderPoc, + current: WorkOrderPoc, + siteSwitched: boolean, +): WorkOrderPoc { + if (!siteRecord.synced) { + return current; + } + const primary = siteSwitched || siteRecord.edits.primary; + const notes = siteSwitched || siteRecord.edits.notes; + return { + pocName: primary ? current.pocName : original.pocName, + pocPhone: primary ? current.pocPhone : original.pocPhone, + // Notes live on the site record; an empty override lets the site's notes show through. + pocNotes: notes ? "" : original.pocNotes, + }; +} + +/** + * Loads the selected site's contacts and notes into the Site dialog and reports what the user + * changed, so Save can write them back to the site record. */ export function useSiteRecordSync({ enabled, @@ -62,7 +104,7 @@ export function useSiteRecordSync({ }: UseSiteRecordSyncArgs) { const [syncedFor, setSyncedFor] = useState(""); const [primaryId, setPrimaryId] = useState(undefined); - const [baseline, setBaseline] = useState(""); + const [baseline, setBaseline] = useState(null); const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; useEffect(() => { @@ -87,14 +129,12 @@ export function useSiteRecordSync({ setNotes(notes); setPrimaryId(main?.id); setBaseline( - fingerprint( - toRequest(main?.id, { - pn: main?.name ?? "", - pp: main?.phone ?? "", - notes, - extraContacts: extras, - }), - ), + toRequest(main?.id, { + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes, + extraContacts: extras, + }), ); setSyncedFor(locId); }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]); @@ -106,7 +146,8 @@ export function useSiteRecordSync({ synced, // Until the site loads the fields do not reflect it; a failed load falls back to the work order. loading: enabled && Boolean(locId) && !synced && !locationDetailError, - siteChanged: synced && fingerprint(request) !== baseline, + siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline), + edits: diffEdits(synced ? baseline : null, request), request, }; } diff --git a/src/domain/locations/use-cases/use-update-site-contact-info.ts b/src/domain/locations/use-cases/use-update-site-contact-info.ts index 0f677c6b..4f218a42 100644 --- a/src/domain/locations/use-cases/use-update-site-contact-info.ts +++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts @@ -8,7 +8,7 @@ interface UpdateSiteContactInfoInput extends SiteContactInfoInput { id: string | number; } -/** Writes contacts and notes to the site record, then refetches every site query. */ +/** Writes contacts and notes to the site record, then refetches sites and work orders. */ export function useUpdateSiteContactInfo(): UseMutationResult< void, Error, @@ -20,7 +20,11 @@ export function useUpdateSiteContactInfo(): UseMutationResult< mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) => locationsApi.updateContactInfo(id, { contacts, notes }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); + // Other work orders at this site show its contacts and notes too. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }), + ]); }, onError: (error: Error) => { toast.error(error.message || "Failed to update the site"); diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx index 52d52fe8..c77f15d2 100644 --- a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -21,7 +21,8 @@ const dallas: Location = { city: "Dallas", state: "TX", zipCode: "75201", - phone: "(214) 555-0100", + sitePhone: "(214) 555-0100", + phone: "(421) 433-0032", contactEmail: "dal1@acme.test", notes: "Gate code 1234", contacts: [ @@ -83,6 +84,8 @@ describe("Work order Site dialog edits the site record", () => { ); expect(address).toHaveAttribute("target", "_blank"); expect(screen.getByTitle("Site location map")).toBeInTheDocument(); + expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); + expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); }); it("keeps Save disabled until something changes", async () => { @@ -107,13 +110,16 @@ describe("Work order Site dialog edits the site record", () => { fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" }, }); + fireEvent.change(screen.getAllByPlaceholderText("POC phone")[1], { + target: { value: "(421) 433-7777" }, + }); fireEvent.click(saveButton()); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(updateContactInfo).toHaveBeenCalledWith("1", { contacts: [ { id: 31, name: "Jane", phone: "(421) 433-9999" }, - { id: 32, name: "Bob", phone: "(421) 433-0033" }, + { id: 32, name: "Bob", phone: "(421) 433-7777" }, ], notes: "Gate code 9999", }); @@ -126,12 +132,52 @@ describe("Work order Site dialog edits the site record", () => { pocName: "Jane", pocPhone: "(421) 433-9999", pocNotes: "", - additionalContacts: [{ name: "Bob", phone: "(421) 433-0033", siteContactId: 32 }], + additionalContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }], }), ); expect(onOpenChange).toHaveBeenCalledWith(false); }); + it("a phone-only edit keeps the work order's own notes and extra contacts", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog({ + additionalContacts: [{ name: "Night guard", phone: "(555) 000-1111" }], + }); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith( + "1", + expect.objectContaining({ notes: "Gate code 1234" }), + ); + const patch = onSave.mock.calls[0][0]; + expect(patch).toMatchObject({ + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "Old work order note", + }); + expect(patch).not.toHaveProperty("additionalContacts"); + }); + + it("a notes-only edit keeps the work order's own contact override", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "Someone Else", + pocPhone: "(000) 000-0000", + pocNotes: "", + }); + }); + it("keeps the dialog open and the work order untouched when the site update fails", async () => { updateContactInfo.mockRejectedValue(new Error("Forbidden")); const { onSave, onOpenChange } = renderDialog();