From 7eaf6bca1872f429f21c45a2a1e11594e933d184 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:18:26 -0300 Subject: [PATCH 1/4] Save Work Order Site dialog contact and notes edits to the site record --- .../table/cells/build-site-dialog-patch.ts | 8 +- .../table/cells/site-dialog-detail-panel.tsx | 34 +-- .../list/table/cells/site-dialog-footer.tsx | 18 +- .../table/cells/site-dialog-poc-section.tsx | 9 +- .../list/table/cells/site-dialog.tsx | 18 +- .../cells/use-site-dialog-form-fields.ts | 23 +- .../list/table/cells/use-site-dialog-state.ts | 44 +++- .../list/table/cells/use-site-record-sync.ts | 112 ++++++++++ src/domain/locations/api/locations-api.ts | 12 +- src/domain/locations/types/location.ts | 6 + .../use-cases/use-update-site-contact-info.ts | 29 +++ .../types/work-order-additional-contact.ts | 2 + .../site-dialog-site-record.test.tsx | 201 ++++++++++++++++++ .../workorders/use-site-dialog-state.test.tsx | 4 + 14 files changed, 489 insertions(+), 31 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts create mode 100644 src/domain/locations/use-cases/use-update-site-contact-info.ts create mode 100644 src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx 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 3fe930cd..8566c1cd 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 @@ -14,6 +14,8 @@ type BuildSiteDialogPatchArgs = { extraContacts: WorkOrderAdditionalContact[]; contactsDirty: boolean; baselineHadContacts: boolean; + /** The dialog saved these contacts and notes to the site record, so the work order follows it. */ + followsSiteRecord?: boolean; }; export function buildSiteDialogPatch({ @@ -26,6 +28,7 @@ export function buildSiteDialogPatch({ extraContacts, contactsDirty, baselineHadContacts, + followsSiteRecord = false, }: BuildSiteDialogPatchArgs): SitePatch { const patch: SitePatch = { site: code || (selected ? resolveSiteCode(selected) : ""), @@ -33,9 +36,10 @@ export function buildSiteDialogPatch({ locationId: locId, pocName, pocPhone, - pocNotes, + // Notes live on the site record; an empty override lets the site's notes show through. + pocNotes: followsSiteRecord ? "" : pocNotes, }; - if (contactsDirty || baselineHadContacts) { + if (followsSiteRecord || contactsDirty || baselineHadContacts) { 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 07b1bb94..93815ade 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 @@ -1,4 +1,5 @@ -import { ExternalLink, Mail, MapPin, Phone } from "lucide-react"; +import { Mail, MapPin, Phone } from "lucide-react"; +import { SiteMapPreview } from "@/app/(protected)/locations/_components/site-map-preview"; import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { Text } from "@/components/ui/text"; import type { Location } from "@/domain/locations/types/location"; @@ -44,24 +45,31 @@ export function SiteDialogDetailPanel({ > {locationDetail ? ( <> - + {Boolean(addressPreview) && ( + + + {addressPreview} + + )} + {!addressPreview && } {Boolean(locationDetail.phone) && ( )} {Boolean(locationDetail.contactEmail) && ( )} - {Boolean(addressPreview) && ( - - Open in Google Maps - - )} + ) : ( <> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx index 8492225e..37e87e58 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx @@ -4,9 +4,17 @@ type SiteDialogFooterProps = { onClose: () => void; onConfirm: () => void; viewOnly?: boolean; + saveDisabled?: boolean; + saving?: boolean; }; -export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) { +export function SiteDialogFooter({ + onClose, + onConfirm, + viewOnly = false, + saveDisabled = false, + saving = false, +}: SiteDialogFooterProps) { if (viewOnly) { return (
@@ -22,7 +30,13 @@ export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteD -
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 index bdccff61..684d4800 100644 --- 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 @@ -8,6 +8,8 @@ type SiteDialogPocSectionProps = { additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; primaryRequired?: boolean; + /** Edits go to the site record rather than only this work order. */ + editsSiteRecord?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -21,6 +23,7 @@ export function SiteDialogPocSection({ additionalContacts, showPrimaryErrors = false, primaryRequired = false, + editsSiteRecord = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -38,7 +41,11 @@ export function SiteDialogPocSection({ onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} onAdditionalContactsChange={onAdditionalContactsChange} - helperText="Auto-filled from site records when empty. Additional contacts persist on save." + helperText={ + editsSiteRecord + ? "Contacts and notes are saved to the site record and apply to all its work orders." + : "Auto-filled from site records when empty. Additional contacts persist on save." + } /> ); } 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 06c5e6bb..82f35a06 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 @@ -58,6 +58,7 @@ export function SiteDialog({ additionalContacts, sites, createMode, + viewOnly, frozenSite, onSave, }); @@ -86,7 +87,7 @@ export function SiteDialog({
- Site + Site Site is required @@ -129,8 +130,9 @@ export function SiteDialog({ pocPhone={s.pp} pocNotes={s.notes} additionalContacts={s.extraContacts} - showPrimaryErrors={createMode && s.showErrors} - primaryRequired={createMode} + showPrimaryErrors={s.requiresPoc && s.showErrors} + primaryRequired={s.requiresPoc} + editsSiteRecord={s.editsSiteRecord} onPocNameChange={s.setPn} onPocPhoneChange={s.setPp} onPocNotesChange={s.setNotes} @@ -138,7 +140,13 @@ export function SiteDialog({ />
- + ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts index 1b0a4f30..a5c585f8 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = { createMode: boolean; }; +function sameContacts(a: WorkOrderAdditionalContact[], b: WorkOrderAdditionalContact[]): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} + +/** Keeps one array identity while the contacts are unchanged, so a fresh `[]` does not reset. */ +function useStableContacts(contacts: WorkOrderAdditionalContact[]): WorkOrderAdditionalContact[] { + const [stable, setStable] = useState(contacts); + if (!sameContacts(stable, contacts)) { + setStable(contacts); + return contacts; + } + return stable; +} + export function useSiteDialogFormFields({ open, value, @@ -23,10 +37,11 @@ export function useSiteDialogFormFields({ pocName, pocPhone, pocNotes, - additionalContacts, + additionalContacts: incomingContacts, sites, createMode, }: UseSiteDialogFormFieldsArgs) { + const additionalContacts = useStableContacts(incomingContacts); const [code, setCode] = useState(value); const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites)); const [pn, setPn] = useState(pocName); @@ -40,6 +55,8 @@ export function useSiteDialogFormFields({ () => additionalContacts.length > 0, ); const [showErrors, setShowErrors] = useState(false); + // Bumped whenever the fields are reset from the row, so site-record sync can re-apply. + const [resetVersion, setResetVersion] = useState(0); useEffect(() => { if (!open) { @@ -62,6 +79,7 @@ export function useSiteDialogFormFields({ setExtraContacts(additionalContacts); setBaselineHadContacts(additionalContacts.length > 0); setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : ""); + setResetVersion((version) => version + 1); }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]); const handlePick = (id: string) => { @@ -100,6 +118,7 @@ export function useSiteDialogFormFields({ pocFilledFor, dirty, contactsDirty, + resetVersion, baselineHadContacts, showErrors, selected: sites.find((s) => String(s.id) === locId), @@ -108,6 +127,8 @@ export function useSiteDialogFormFields({ setPn, setPp, setShowErrors, + setNotes, + setExtraContacts, markDirty: () => setDirty(true), handlePick, setPnDirty: (next: string) => markPocDirty(() => setPn(next)), 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 5800cbf4..ecb98c03 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,6 +2,8 @@ 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 { 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"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; @@ -21,6 +23,7 @@ type UseSiteDialogStateArgs = { additionalContacts: WorkOrderAdditionalContact[]; sites: LocationOption[]; createMode: boolean; + viewOnly?: boolean; frozenSite?: WorkOrderFrozenSite; onSave: (patch: SitePatch) => void; }; @@ -36,6 +39,7 @@ export function useSiteDialogState({ additionalContacts = [], sites, createMode, + viewOnly = false, frozenSite, onSave, }: UseSiteDialogStateArgs) { @@ -77,8 +81,20 @@ export function useSiteDialogState({ const locationDetailError = frozenSite == null && liveLocationDetailError; const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; + // An existing, editable work order edits the site record itself (contacts and notes). + const editsSiteRecord = !createMode && !viewOnly && frozenSite == null; + const siteRecord = useSiteRecordSync({ + enabled: editsSiteRecord, + open, + locId, + locationDetail: liveLocationDetail, + locationDetailError: liveLocationDetailError, + fields, + }); + const updateSite = useUpdateSiteContactInfo(); const pocMissing = !pn.trim() || !pp.trim(); - const canConfirm = !siteMissing && (!createMode || !pocMissing); + const requiresPoc = createMode || editsSiteRecord; + const canConfirm = !siteMissing && (!requiresPoc || !pocMissing); useEffect(() => { if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) { @@ -89,11 +105,7 @@ export function useSiteDialogState({ setPocFilledFor(locId); }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); - const attemptSave = () => { - if (createMode && !canConfirm) { - fields.setShowErrors(true); - return; - } + const saveWorkOrder = () => { onSave( buildSiteDialogPatch({ code: fields.code, @@ -105,11 +117,27 @@ export function useSiteDialogState({ extraContacts: fields.extraContacts, contactsDirty: fields.contactsDirty, baselineHadContacts: fields.baselineHadContacts, + followsSiteRecord: siteRecord.synced, }), ); onOpenChange(false); }; + const attemptSave = () => { + if (requiresPoc && !canConfirm) { + fields.setShowErrors(true); + return; + } + if (!siteRecord.siteChanged) { + saveWorkOrder(); + return; + } + updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); + }; + + // Save is actionable only once something changed; inline create keeps its confirm step. + const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty); + return { locId: fields.locId, code: fields.code, @@ -126,6 +154,10 @@ export function useSiteDialogState({ siteMissing: fields.siteMissing, handlePick: fields.handlePick, attemptSave, + saveDisabled, + saving: updateSite.isPending, + editsSiteRecord, + requiresPoc, setPn: fields.setPnDirty, setPp: fields.setPpDirty, setNotes: fields.setNotesDirty, 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 new file mode 100644 index 00000000..2c5bcbad --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; + +type SiteRecordFields = { + pn: string; + pp: string; + notes: string; + extraContacts: WorkOrderAdditionalContact[]; + resetVersion: number; + setPn: (value: string) => void; + setPp: (value: string) => void; + setNotes: (value: string) => void; + setExtraContacts: (contacts: WorkOrderAdditionalContact[]) => void; +}; + +type UseSiteRecordSyncArgs = { + /** Existing, editable work order: the dialog edits the site record itself. */ + enabled: boolean; + open: boolean; + locId: string; + locationDetail: Location | undefined; + locationDetailError: boolean; + fields: SiteRecordFields; +}; + +function toRequest( + primaryId: number | undefined, + fields: Pick, +): SiteContactInfoInput { + return { + contacts: [ + { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp }, + ...fields.extraContacts.map((contact) => ({ + ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }), + name: contact.name, + phone: contact.phone, + })), + ], + notes: fields.notes, + }; +} + +function fingerprint(request: SiteContactInfoInput): string { + return JSON.stringify({ + contacts: request.contacts.map((contact) => [contact.name.trim(), contact.phone.trim()]), + notes: 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. + */ +export function useSiteRecordSync({ + enabled, + open, + locId, + locationDetail, + locationDetailError, + fields, +}: UseSiteRecordSyncArgs) { + const [syncedFor, setSyncedFor] = useState(""); + const [primaryId, setPrimaryId] = useState(undefined); + const [baseline, setBaseline] = useState(""); + const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + + useEffect(() => { + setSyncedFor(""); + }, [open, resetVersion]); + + useEffect(() => { + const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; + if (!enabled || !open || !locId || !detailMatches || syncedFor === locId) { + return; + } + const [main, ...others] = locationDetail.contacts ?? []; + const extras = others.map((contact) => ({ + name: contact.name, + phone: contact.phone, + ...(contact.id === undefined ? {} : { siteContactId: contact.id }), + })); + const notes = locationDetail.notes ?? ""; + setPn(main?.name ?? ""); + setPp(main?.phone ?? ""); + setExtraContacts(extras); + setNotes(notes); + setPrimaryId(main?.id); + setBaseline( + fingerprint( + toRequest(main?.id, { + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes, + extraContacts: extras, + }), + ), + ); + setSyncedFor(locId); + }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]); + + const request = toRequest(primaryId, fields); + const synced = enabled && syncedFor !== "" && syncedFor === locId; + + return { + 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, + request, + }; +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 2f9f1376..3c122e5a 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -1,8 +1,9 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapLocation, + mapContactsToBackend, mapLocationListItems, mapLocationSites, mapLocationToBackend, @@ -10,6 +11,7 @@ import { import type { Location, LocationSite, + SiteContactInfoInput, LocationsListParams, LocationsListResult, } from "@/domain/locations/types/location"; @@ -71,6 +73,14 @@ export const locationsApi = { return mapLocation(handleApiResponse(data)); }, + /** Saves the site's ordered contacts and notes (work-order Site dialog). */ + updateContactInfo: async (id: string | number, input: SiteContactInfoInput): Promise => { + await apiPatch(`${API_PATHS.rest.locations}/${id}/contact-info`, { + contacts: mapContactsToBackend(input.contacts), + notes: input.notes, + }); + }, + delete: async (id: string | number): Promise => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 77cadb17..b4ba7dc7 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -69,3 +69,9 @@ export interface LocationsListResult { page: number; pageSize: number; } + +/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */ +export interface SiteContactInfoInput { + contacts: LocationContact[]; + notes: string; +} 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 new file mode 100644 index 00000000..0f677c6b --- /dev/null +++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { SiteContactInfoInput } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateSiteContactInfoInput extends SiteContactInfoInput { + id: string | number; +} + +/** Writes contacts and notes to the site record, then refetches every site query. */ +export function useUpdateSiteContactInfo(): UseMutationResult< + void, + Error, + UpdateSiteContactInfoInput +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) => + locationsApi.updateContactInfo(id, { contacts, notes }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update the site"); + }, + }); +} diff --git a/src/domain/work-orders/types/work-order-additional-contact.ts b/src/domain/work-orders/types/work-order-additional-contact.ts index dd5d7abf..24c1d43d 100644 --- a/src/domain/work-orders/types/work-order-additional-contact.ts +++ b/src/domain/work-orders/types/work-order-additional-contact.ts @@ -2,6 +2,8 @@ export interface WorkOrderAdditionalContact { name: string; phone: string; notes?: string; + /** Site contact row this entry was loaded from (Site dialog); never sent on work-order patches. */ + siteContactId?: number; } export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = { 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 new file mode 100644 index 00000000..52d52fe8 --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import type { Location } from "@/domain/locations/types/location"; +import { renderWithProviders } from "@/test/test-utils"; + +const getById = vi.fn(); +const updateContactInfo = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getById: (...args: unknown[]) => getById(...args), + updateContactInfo: (...args: unknown[]) => updateContactInfo(...args), + }, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + phone: "(214) 555-0100", + contactEmail: "dal1@acme.test", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const sites = [{ id: "1", name: "DAL1" }]; + +function renderDialog(overrides: Partial[0]> = {}) { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + return { onSave, onOpenChange }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +function saveButton(): HTMLElement { + return screen.getByRole("button", { name: /^save$/i }); +} + +describe("Work order Site dialog edits the site record", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + getById.mockResolvedValue(dallas); + }); + + it("shows the site's contacts, notes, linked address and map preview", async () => { + renderDialog(); + + expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument(); + expect(screen.getByDisplayValue("(421) 433-0032")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Bob")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Gate code 1234")).toBeInTheDocument(); + const address = screen.getByRole("link", { name: /3811 Distribution Dr/ }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(screen.getByTitle("Site location map")).toBeInTheDocument(); + }); + + it("keeps Save disabled until something changes", async () => { + renderDialog(); + await screen.findByDisplayValue("Jane"); + + expect(saveButton()).toBeDisabled(); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + expect(saveButton()).toBeEnabled(); + }); + + it("saves contact and notes edits to the site, then points the work order at it", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave, onOpenChange } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { + target: { value: "(421) 433-9999" }, + }); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + 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" }, + ], + notes: "Gate code 9999", + }); + expect(updateContactInfo.mock.invocationCallOrder[0]).toBeLessThan( + onSave.mock.invocationCallOrder[0], + ); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + locationId: "1", + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "", + additionalContacts: [{ name: "Bob", phone: "(421) 433-0033", siteContactId: 32 }], + }), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + 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(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(saveButton()).toBeEnabled()); + expect(onSave).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("requires the first contact before saving", async () => { + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC name"), { target: { value: "" } }); + fireEvent.click(saveButton()); + + expect(await screen.findByText(/name is required/i)).toBeInTheDocument(); + expect(updateContactInfo).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("a completed work order shows its frozen snapshot read-only, without a site lookup", () => { + renderDialog({ + viewOnly: true, + frozenSite: { + code: "DAL1", + label: "DAL1", + address: "1 Old Rd", + city: "Dallas", + state: "TX", + zip: "75201", + }, + }); + + expect(screen.getByRole("link", { name: /1 Old Rd/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("Someone Else")).toBeDisabled(); + expect(getById).not.toHaveBeenCalled(); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + + it("inline create keeps the work-order-only path and never writes the site", async () => { + const { onSave } = renderDialog({ + createMode: true, + pocName: "Temp", + pocPhone: "(111) 111-1111", + pocNotes: "", + }); + await waitFor(() => expect(getById).toHaveBeenCalled()); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Bring ladder" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pocName: "Temp", pocNotes: "Bring ladder" }), + ); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); +}); 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 d527414d..b868415a 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 @@ -19,6 +19,10 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ }, })); +vi.mock("@/domain/locations/use-cases/use-update-site-contact-info", () => ({ + useUpdateSiteContactInfo: () => ({ mutate: vi.fn(), isPending: false }), +})); + const SITES: LocationOption[] = [ { id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" }, { id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" }, From cccdeceb10e37e555f7c8f866ce5ce48979a3602 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:32:24 -0300 Subject: [PATCH 2/4] fix(workorders): only edited Site dialog fields follow the site record --- .../table/cells/build-site-dialog-patch.ts | 7 +- .../table/cells/site-dialog-detail-panel.tsx | 7 +- .../list/table/cells/use-site-dialog-state.ts | 16 +++-- .../list/table/cells/use-site-record-sync.ts | 65 +++++++++++++++---- .../use-cases/use-update-site-contact-info.ts | 8 ++- .../site-dialog-site-record.test.tsx | 52 ++++++++++++++- 6 files changed, 128 insertions(+), 27 deletions(-) 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(); From ad500753e1c77be5c4de5cb2b91bd5148ab16c75 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:36:16 -0300 Subject: [PATCH 3/4] fix(workorders): lock Site dialog contacts until the site record loads --- .../list/table/cells/site-dialog.tsx | 33 +++++++++++-------- .../list/table/cells/use-site-dialog-state.ts | 1 + .../site-dialog-site-record.test.tsx | 23 +++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) 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 82f35a06..2187a33a 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 @@ -125,19 +125,26 @@ export function SiteDialog({ /> )} - + {/* Locked until the site record loads, so its values never replace typed input. */} +
+ +
{ expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); }); + it("locks the contact fields until the site loads, so typed input is never replaced", async () => { + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC name")).toBeDisabled(); + expect(primaryField("POC phone")).toBeDisabled(); + expect(primaryField("Notes…")).toBeDisabled(); + expect(saveButton()).toBeDisabled(); + + resolveSite(dallas); + + expect(await screen.findByDisplayValue("Jane")).toBeEnabled(); + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + expect(primaryField("POC phone")).toHaveValue("(421) 433-9999"); + expect(saveButton()).toBeEnabled(); + }); + it("keeps Save disabled until something changes", async () => { renderDialog(); await screen.findByDisplayValue("Jane"); From 7efc9e891431d05ca2ea3e5afedd82302ae00c3f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:12:27 -0300 Subject: [PATCH 4/4] fix(workorders): never let a late site load replace typed Site dialog input The contact lock only covered a pending load. After a failed getById the fields unlock, and a later successful refetch (focus, invalidation) still ran the apply effect and overwrote whatever the dispatcher had typed. The apply effect now skips once the user has typed since the last reset or site switch, so the dialog stays on the work order's values and saves them to the work order only; the site's unseen notes and contacts are never written back. Picking another site still clears the marker and adopts it. --- .../cells/use-site-dialog-form-fields.ts | 7 ++++ .../list/table/cells/use-site-record-sync.ts | 26 ++++++++++++--- .../site-dialog-site-record.test.tsx | 33 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts index a5c585f8..db1db3e7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -51,6 +51,8 @@ export function useSiteDialogFormFields({ const [pocFilledFor, setPocFilledFor] = useState(""); const [dirty, setDirty] = useState(false); const [contactsDirty, setContactsDirty] = useState(false); + // The user typed into the contact fields since the last reset or site switch. + const [userEdited, setUserEdited] = useState(false); const [baselineHadContacts, setBaselineHadContacts] = useState( () => additionalContacts.length > 0, ); @@ -62,6 +64,7 @@ export function useSiteDialogFormFields({ if (!open) { setDirty(false); setContactsDirty(false); + setUserEdited(false); setShowErrors(false); } }, [open]); @@ -92,6 +95,7 @@ export function useSiteDialogFormFields({ if (siteChanged) { setExtraContacts([]); setContactsDirty(true); + setUserEdited(false); setDirty(true); if (createMode) { setPn(""); @@ -105,6 +109,7 @@ export function useSiteDialogFormFields({ const markPocDirty = (apply: () => void) => { setDirty(true); + setUserEdited(true); apply(); }; @@ -118,6 +123,7 @@ export function useSiteDialogFormFields({ pocFilledFor, dirty, contactsDirty, + userEdited, resetVersion, baselineHadContacts, showErrors, @@ -136,6 +142,7 @@ export function useSiteDialogFormFields({ setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)), setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => { setDirty(true); + setUserEdited(true); setContactsDirty(true); setExtraContacts(contacts); }, 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 07a482f5..33b09eb0 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 @@ -7,6 +7,8 @@ type SiteRecordFields = { pp: string; notes: string; extraContacts: WorkOrderAdditionalContact[]; + /** The user typed into the contact fields since the last reset or site switch. */ + userEdited: boolean; resetVersion: number; setPn: (value: string) => void; setPp: (value: string) => void; @@ -105,7 +107,7 @@ export function useSiteRecordSync({ const [syncedFor, setSyncedFor] = useState(""); const [primaryId, setPrimaryId] = useState(undefined); const [baseline, setBaseline] = useState(null); - const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; useEffect(() => { setSyncedFor(""); @@ -113,7 +115,9 @@ export function useSiteRecordSync({ useEffect(() => { const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; - if (!enabled || !open || !locId || !detailMatches || syncedFor === locId) { + // Input typed while the site was unavailable (a failed load) is never replaced by a late + // response: the dialog stays on the work order's values and saves them to the work order only. + if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) { return; } const [main, ...others] = locationDetail.contacts ?? []; @@ -137,15 +141,27 @@ export function useSiteRecordSync({ }), ); setSyncedFor(locId); - }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]); + }, [ + enabled, + open, + locId, + locationDetail, + syncedFor, + userEdited, + setPn, + setPp, + setNotes, + setExtraContacts, + ]); const request = toRequest(primaryId, fields); const synced = enabled && syncedFor !== "" && syncedFor === locId; return { 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, + // Until the site loads the fields do not reflect it; a failed load falls back to the work order, + // which it keeps once the user has typed, even if a later refetch succeeds. + loading: enabled && Boolean(locId) && !synced && !locationDetailError && !userEdited, siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline), edits: diffEdits(synced ? baseline : null, request), request, 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 c3172331..79dfb6a0 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 @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; import type { Location } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; import { renderWithProviders } from "@/test/test-utils"; const getById = vi.fn(); @@ -36,7 +37,7 @@ const sites = [{ id: "1", name: "DAL1" }]; function renderDialog(overrides: Partial[0]> = {}) { const onSave = vi.fn(); const onOpenChange = vi.fn(); - renderWithProviders( + const { queryClient } = renderWithProviders( [0]> = {}) />, { withAuth: false }, ); - return { onSave, onOpenChange }; + return { onSave, onOpenChange, queryClient }; } /** The main contact's field; additional contact rows reuse the same placeholders after it. */ @@ -111,6 +112,34 @@ describe("Work order Site dialog edits the site record", () => { expect(saveButton()).toBeEnabled(); }); + it("keeps a POC typed after a failed site load when a later refetch succeeds", async () => { + getById.mockRejectedValueOnce(new Error("Network error")); + updateContactInfo.mockResolvedValue(undefined); + const { onSave, queryClient } = renderDialog(); + + await waitFor(() => expect(primaryField("POC name")).toBeEnabled()); + fireEvent.change(primaryField("POC name"), { target: { value: "John" } }); + + await queryClient.refetchQueries({ queryKey: queryKeys.locations.detail("1") }); + await waitFor(() => expect(getById).toHaveBeenCalledTimes(2)); + await screen.findByText("(214) 555-0100"); + + expect(primaryField("POC name")).toHaveValue("John"); + expect(primaryField("POC name")).toBeEnabled(); + expect(primaryField("POC phone")).toHaveValue("(000) 000-0000"); + expect(screen.queryByDisplayValue("Jane")).not.toBeInTheDocument(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "John", + pocPhone: "(000) 000-0000", + pocNotes: "Old work order note", + }); + // The site's notes and other contacts were never shown, so they are not written back. + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + it("keeps Save disabled until something changes", async () => { renderDialog(); await screen.findByDisplayValue("Jane");