fix(workorders): only edited Site dialog fields follow the site record

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:32:24 -03:00
parent bea3540409
commit cccdeceb10
6 changed files with 128 additions and 27 deletions

View file

@ -36,10 +36,11 @@ export function buildSiteDialogPatch({
locationId: locId, locationId: locId,
pocName, pocName,
pocPhone, pocPhone,
// Notes live on the site record; an empty override lets the site's notes show through. pocNotes,
pocNotes: followsSiteRecord ? "" : 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; patch.additionalContacts = extraContacts;
} }
return patch; return patch;

View file

@ -35,6 +35,9 @@ export function SiteDialogDetailPanel({
addressPreview, addressPreview,
fallbackLabel, fallbackLabel,
}: SiteDialogDetailPanelProps) { }: SiteDialogDetailPanelProps) {
// The site's own phone; a frozen snapshot carries it as `phone`.
const sitePhone = locationDetail?.sitePhone ?? locationDetail?.phone ?? "";
return ( return (
<div <div
className="space-y-1.5 rounded-lg p-3" className="space-y-1.5 rounded-lg p-3"
@ -58,9 +61,7 @@ export function SiteDialogDetailPanel({
</a> </a>
)} )}
{!addressPreview && <Detail icon={MapPin} text={fallbackLabel} />} {!addressPreview && <Detail icon={MapPin} text={fallbackLabel} />}
{Boolean(locationDetail.phone) && ( {Boolean(sitePhone) && <Detail icon={Phone} text={sitePhone} />}
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && ( {Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} /> <Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)} )}

View file

@ -2,7 +2,11 @@ import { useEffect, useMemo } from "react";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; 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 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 { 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 { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
@ -106,14 +110,18 @@ export function useSiteDialogState({
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
const saveWorkOrder = () => { 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( onSave(
buildSiteDialogPatch({ buildSiteDialogPatch({
code: fields.code, code: fields.code,
locId: fields.locId, locId: fields.locId,
selected: fields.selected, selected: fields.selected,
pocName: fields.pn, ...poc,
pocPhone: fields.pp,
pocNotes: fields.notes,
extraContacts: fields.extraContacts, extraContacts: fields.extraContacts,
contactsDirty: fields.contactsDirty, contactsDirty: fields.contactsDirty,
baselineHadContacts: fields.baselineHadContacts, baselineHadContacts: fields.baselineHadContacts,

View file

@ -24,6 +24,10 @@ type UseSiteRecordSyncArgs = {
fields: SiteRecordFields; fields: SiteRecordFields;
}; };
export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string };
type SiteRecordEdits = { primary: boolean; notes: boolean };
function toRequest( function toRequest(
primaryId: number | undefined, primaryId: number | undefined,
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">, fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
@ -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 * The work order's own POC values once the site record owns them: a field follows the site only
* user changed them, so Save can write them back to the site record. * 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({ export function useSiteRecordSync({
enabled, enabled,
@ -62,7 +104,7 @@ export function useSiteRecordSync({
}: UseSiteRecordSyncArgs) { }: UseSiteRecordSyncArgs) {
const [syncedFor, setSyncedFor] = useState(""); const [syncedFor, setSyncedFor] = useState("");
const [primaryId, setPrimaryId] = useState<number | undefined>(undefined); const [primaryId, setPrimaryId] = useState<number | undefined>(undefined);
const [baseline, setBaseline] = useState(""); const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
useEffect(() => { useEffect(() => {
@ -87,14 +129,12 @@ export function useSiteRecordSync({
setNotes(notes); setNotes(notes);
setPrimaryId(main?.id); setPrimaryId(main?.id);
setBaseline( setBaseline(
fingerprint(
toRequest(main?.id, { toRequest(main?.id, {
pn: main?.name ?? "", pn: main?.name ?? "",
pp: main?.phone ?? "", pp: main?.phone ?? "",
notes, notes,
extraContacts: extras, extraContacts: extras,
}), }),
),
); );
setSyncedFor(locId); setSyncedFor(locId);
}, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]); }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]);
@ -106,7 +146,8 @@ export function useSiteRecordSync({
synced, synced,
// Until the site loads the fields do not reflect it; a failed load falls back to the work order. // 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, 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, request,
}; };
} }

View file

@ -8,7 +8,7 @@ interface UpdateSiteContactInfoInput extends SiteContactInfoInput {
id: string | number; 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< export function useUpdateSiteContactInfo(): UseMutationResult<
void, void,
Error, Error,
@ -20,7 +20,11 @@ export function useUpdateSiteContactInfo(): UseMutationResult<
mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) => mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) =>
locationsApi.updateContactInfo(id, { contacts, notes }), locationsApi.updateContactInfo(id, { contacts, notes }),
onSuccess: async () => { 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) => { onError: (error: Error) => {
toast.error(error.message || "Failed to update the site"); toast.error(error.message || "Failed to update the site");

View file

@ -21,7 +21,8 @@ const dallas: Location = {
city: "Dallas", city: "Dallas",
state: "TX", state: "TX",
zipCode: "75201", zipCode: "75201",
phone: "(214) 555-0100", sitePhone: "(214) 555-0100",
phone: "(421) 433-0032",
contactEmail: "dal1@acme.test", contactEmail: "dal1@acme.test",
notes: "Gate code 1234", notes: "Gate code 1234",
contacts: [ contacts: [
@ -83,6 +84,8 @@ describe("Work order Site dialog edits the site record", () => {
); );
expect(address).toHaveAttribute("target", "_blank"); expect(address).toHaveAttribute("target", "_blank");
expect(screen.getByTitle("Site location map")).toBeInTheDocument(); 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 () => { 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…"), { fireEvent.change(primaryField("Notes…"), {
target: { value: "Gate code 9999" }, target: { value: "Gate code 9999" },
}); });
fireEvent.change(screen.getAllByPlaceholderText("POC phone")[1], {
target: { value: "(421) 433-7777" },
});
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(updateContactInfo).toHaveBeenCalledWith("1", { expect(updateContactInfo).toHaveBeenCalledWith("1", {
contacts: [ contacts: [
{ id: 31, name: "Jane", phone: "(421) 433-9999" }, { 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", notes: "Gate code 9999",
}); });
@ -126,12 +132,52 @@ describe("Work order Site dialog edits the site record", () => {
pocName: "Jane", pocName: "Jane",
pocPhone: "(421) 433-9999", pocPhone: "(421) 433-9999",
pocNotes: "", pocNotes: "",
additionalContacts: [{ name: "Bob", phone: "(421) 433-0033", siteContactId: 32 }], additionalContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
}), }),
); );
expect(onOpenChange).toHaveBeenCalledWith(false); 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 () => { it("keeps the dialog open and the work order untouched when the site update fails", async () => {
updateContactInfo.mockRejectedValue(new Error("Forbidden")); updateContactInfo.mockRejectedValue(new Error("Forbidden"));
const { onSave, onOpenChange } = renderDialog(); const { onSave, onOpenChange } = renderDialog();