mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
fix(workorders): only edited Site dialog fields follow the site record
This commit is contained in:
parent
bea3540409
commit
cccdeceb10
6 changed files with 128 additions and 27 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ?? ""} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue