From 7eaf6bca1872f429f21c45a2a1e11594e933d184 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:18:26 -0300 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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"); From c0e484275dbcc512ef36715aad403f144e4710b4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:39:25 -0300 Subject: [PATCH 5/8] fix(workorders): say Site dialog saves to the work order only when the site did not load When the site detail request fails, or the user types before it loads, the dialog falls back to saving this work order only and never calls updateContactInfo. The POC helper text still said contacts and notes were saved to the site record, so a dispatcher could believe every work order at the site now had the new contact. The helper text now follows the site-record sync state and says the change applies to this work order only whenever that fallback is active, including after a later refetch succeeds. --- .../table/cells/site-dialog-poc-section.tsx | 18 +++++++++++++----- .../list/table/cells/site-dialog.tsx | 1 + .../list/table/cells/use-site-dialog-state.ts | 4 ++++ .../site-dialog-site-record.test.tsx | 11 +++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) 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 684d4800..9b81279d 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 @@ -10,12 +10,23 @@ type SiteDialogPocSectionProps = { primaryRequired?: boolean; /** Edits go to the site record rather than only this work order. */ editsSiteRecord?: boolean; + /** The site record did not load, so Save writes this work order only. */ + siteRecordUnavailable?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void; }; +function helperText(editsSiteRecord: boolean, siteRecordUnavailable: boolean): string { + if (!editsSiteRecord) { + return "Auto-filled from site records when empty. Additional contacts persist on save."; + } + return siteRecordUnavailable + ? "Site record unavailable — changes apply to this work order only." + : "Contacts and notes are saved to the site record and apply to all its work orders."; +} + export function SiteDialogPocSection({ pocName, pocPhone, @@ -24,6 +35,7 @@ export function SiteDialogPocSection({ showPrimaryErrors = false, primaryRequired = false, editsSiteRecord = false, + siteRecordUnavailable = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -41,11 +53,7 @@ export function SiteDialogPocSection({ onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} onAdditionalContactsChange={onAdditionalContactsChange} - 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." - } + helperText={helperText(editsSiteRecord, siteRecordUnavailable)} /> ); } 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 2187a33a..6ee17752 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 @@ -139,6 +139,7 @@ export function SiteDialog({ showPrimaryErrors={s.requiresPoc && s.showErrors} primaryRequired={s.requiresPoc} editsSiteRecord={s.editsSiteRecord} + siteRecordUnavailable={s.siteRecordUnavailable} onPocNameChange={s.setPn} onPocPhoneChange={s.setPp} onPocNotesChange={s.setNotes} 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 e08a184e..f23b1b01 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 @@ -95,6 +95,9 @@ export function useSiteDialogState({ locationDetailError: liveLocationDetailError, fields, }); + // The site failed to load (or the user typed before it did), so Save writes this work order only. + const siteRecordUnavailable = + editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading; const updateSite = useUpdateSiteContactInfo(); const pocMissing = !pn.trim() || !pp.trim(); const requiresPoc = createMode || editsSiteRecord; @@ -166,6 +169,7 @@ export function useSiteDialogState({ saving: updateSite.isPending, siteRecordLoading: siteRecord.loading, editsSiteRecord, + siteRecordUnavailable, requiresPoc, setPn: fields.setPnDirty, setPp: fields.setPpDirty, 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 79dfb6a0..121e7dbd 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 @@ -34,6 +34,10 @@ const dallas: Location = { const sites = [{ id: "1", name: "DAL1" }]; +const SITE_RECORD_COPY = + "Contacts and notes are saved to the site record and apply to all its work orders."; +const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; + function renderDialog(overrides: Partial[0]> = {}) { const onSave = vi.fn(); const onOpenChange = vi.fn(); @@ -87,6 +91,8 @@ describe("Work order Site dialog edits the site record", () => { expect(screen.getByTitle("Site location map")).toBeInTheDocument(); expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); + expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_UNAVAILABLE_COPY)).not.toBeInTheDocument(); }); it("locks the contact fields until the site loads, so typed input is never replaced", async () => { @@ -118,12 +124,17 @@ describe("Work order Site dialog edits the site record", () => { const { onSave, queryClient } = renderDialog(); await waitFor(() => expect(primaryField("POC name")).toBeEnabled()); + expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument(); 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"); + // The typed input stays on the work order, so the copy still says so after the refetch. + expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument(); expect(primaryField("POC name")).toHaveValue("John"); expect(primaryField("POC name")).toBeEnabled(); expect(primaryField("POC phone")).toHaveValue("(000) 000-0000"); From f0b4ced660582337b38c159cf82d6e4c2151f3a2 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:39:29 -0300 Subject: [PATCH 6/8] feat(sites): show the site address map preview through one shared Maps helper Vendors and Sites now build the keyless embed URL and the Open in Google Maps link through one helper. The Site map shows the placeholder only in the Add/Edit forms; view mode and the Work Order Site dialog always render it for the saved address, the dialog in its compact form. --- .../_components/site-form-fields.tsx | 2 +- .../_components/site-map-preview.tsx | 88 +++++++++++++------ .../table/cells/site-dialog-detail-panel.tsx | 2 + src/domain/locations/utils/site-maps.ts | 21 +++-- src/lib/address/google-maps.ts | 28 ++++++ src/lib/address/vendor-address.ts | 10 +-- .../locations/add-site-dialog.test.tsx | 16 ++++ .../locations/site-detail-panel.test.tsx | 42 ++++++++- .../site-dialog-site-record.test.tsx | 8 +- .../domain/locations/utils/site-maps.test.ts | 26 ++++++ src/test/lib/address/google-maps.test.ts | 33 +++++++ 11 files changed, 230 insertions(+), 46 deletions(-) create mode 100644 src/lib/address/google-maps.ts create mode 100644 src/test/domain/locations/utils/site-maps.test.ts create mode 100644 src/test/lib/address/google-maps.test.ts diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index 1f94d7fa..dfc94ead 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -84,7 +84,7 @@ export function SiteFormFields({ /> - + ; - -/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ -export function SiteMapPreview(props: SiteMapPreviewProps) { - const complete = hasCompleteSiteAddress(props); +type SiteMapPreviewProps = Pick & { + /** + * Add/Edit forms: hold the map back behind a placeholder until Street, City + * and State are all filled, so a half-typed address never points somewhere + * misleading. Saved sites (view mode, Work Order Site dialog) leave this off + * and always render the map for whatever address they have. + */ + placeholderUntilComplete?: boolean; + /** Hide the "Location preview" caption where the map sits inside an info box. */ + hideLabel?: boolean; + height?: number; +}; +function MapPlaceholder({ height }: { height: number }) { return ( - - - Location preview + + + + Enter an address to preview the location - {complete && ( - - )} - {!complete && ( - - - Enter an address to preview the location - - - )} + + ); +} + +/** Read-only key-less Google Maps embed of a site's address. */ +export function SiteMapPreview({ + placeholderUntilComplete = false, + hideLabel = false, + height = 140, + ...address +}: SiteMapPreviewProps) { + const src = buildSiteMapEmbedUrl(address); + const showMap = placeholderUntilComplete ? hasCompleteSiteAddress(address) : Boolean(src); + + if (!showMap && !placeholderUntilComplete) { + return null; + } + + return ( + + {!hideLabel && ( + + Location preview + + )} + {showMap && ( + + )} + {!showMap && } ); } 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 da15ab57..253592ce 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 @@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({ city={locationDetail.city} state={locationDetail.state} zipCode={locationDetail.zipCode} + hideLabel + height={120} /> ) : ( diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts index 28a75792..bb4c1f18 100644 --- a/src/domain/locations/utils/site-maps.ts +++ b/src/domain/locations/utils/site-maps.ts @@ -1,23 +1,28 @@ import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import type { Location } from "@/domain/locations/types/location"; +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps"; +import { hasCompleteAddress } from "@/lib/address/vendor-address"; type SiteAddress = Pick; /** Google Maps search link for a site address; opens in a new tab. */ export function buildSiteMapsUrl(location: SiteAddress): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + return googleMapsSearchUrl(formatLocationAddressPreview(location)); } /** True once Street, City and State are all filled, so a map preview can render. */ export function hasCompleteSiteAddress(location: SiteAddress): boolean { - return [location.address, location.city, location.state].every( - (part) => (part?.trim() ?? "") !== "", - ); + return hasCompleteAddress({ + street: location.address, + city: location.city, + state: location.state, + }); } -/** Key-less Google Maps embed URL for the read-only preview. */ +/** + * Key-less Google Maps embed URL for the read-only preview, built from the same + * address line as the Street Address link. Empty when the site has no address. + */ export function buildSiteMapEmbedUrl(location: SiteAddress): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; + return googleMapsEmbedUrl(formatLocationAddressPreview(location)); } diff --git a/src/lib/address/google-maps.ts b/src/lib/address/google-maps.ts new file mode 100644 index 00000000..3022c356 --- /dev/null +++ b/src/lib/address/google-maps.ts @@ -0,0 +1,28 @@ +/** + * Google Maps URLs shared by every address preview and address link. + * + * Each caller composes its own one-line address (Vendors join the parts with + * spaces, Sites use the same line as their Street Address link); these helpers + * only encode it into the URL shape Google expects. Both return an empty string + * for a blank query, so callers can skip rendering instead of pointing at an + * empty search. + */ + +/** Link target that opens full Google Maps in a new tab. */ +export function googleMapsSearchUrl(query: string): string { + const trimmed = query.trim(); + return trimmed + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(trimmed)}` + : ""; +} + +/** + * Iframe source for the read-only inline preview. + * + * `output=embed` is Google's keyless form: no API key, no billing account and + * no Places request, and the embed brings its own "View larger map" link. + */ +export function googleMapsEmbedUrl(query: string): string { + const trimmed = query.trim(); + return trimmed ? `https://www.google.com/maps?q=${encodeURIComponent(trimmed)}&output=embed` : ""; +} diff --git a/src/lib/address/vendor-address.ts b/src/lib/address/vendor-address.ts index f98dbcbd..29834cdd 100644 --- a/src/lib/address/vendor-address.ts +++ b/src/lib/address/vendor-address.ts @@ -7,6 +7,8 @@ * design agree on what a dispatcher sees. */ +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "./google-maps"; + export type AddressParts = { street: string; city: string; @@ -112,10 +114,7 @@ export function buildAddressQuery(parts: Partial): string { /** Link target for the clickable Street Address. Empty string when unmappable. */ export function mapsSearchUrl(parts: Partial): string { - const query = buildAddressQuery(parts); - return query - ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}` - : ""; + return googleMapsSearchUrl(buildAddressQuery(parts)); } /** @@ -125,8 +124,7 @@ export function mapsSearchUrl(parts: Partial): string { * no billing account and no Places request, which is what SH-271 requires. */ export function mapsEmbedUrl(parts: Partial): string { - const query = buildAddressQuery(parts); - return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : ""; + return googleMapsEmbedUrl(buildAddressQuery(parts)); } /** A preview is only meaningful once all three parts are present. */ diff --git a/src/test/app/(protected)/locations/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx index a9b6c328..d416b5e6 100644 --- a/src/test/app/(protected)/locations/add-site-dialog.test.tsx +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -115,6 +115,22 @@ describe("AddSiteDialog", () => { expect(within(dialog).getByText("Area: West")).toBeInTheDocument(); }); + it("keeps the map placeholder while State is still empty", () => { + const { dialog } = renderDialog(); + + fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + expect(within(dialog).queryByTitle("Site location map")).not.toBeInTheDocument(); + }); + it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => { const user = userEvent.setup(); const { dialog } = renderDialog(); diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx index 27e0edc4..b04a77b4 100644 --- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -117,7 +117,11 @@ describe("Sites page detail panel", () => { "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", ); expect(address).toHaveAttribute("target", "_blank"); - expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + expect(within(region).getByTitle("Site location map")).toHaveAttribute("loading", "lazy"); expect(within(region).getByText("TX · Area: West")).toBeInTheDocument(); expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); @@ -126,6 +130,42 @@ describe("Sites page detail panel", () => { expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); }); + it("view mode always renders the map for a saved site, even one missing City and State", async () => { + getById.mockResolvedValue({ ...chicago, city: "", state: "" }); + renderWithProviders(, { withAuth: false }); + + const region = await openSite("CHI2"); + + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=100%20Main%20St&output=embed", + ); + expect( + within(region).queryByText("Enter an address to preview the location"), + ).not.toBeInTheDocument(); + }); + + it("edit mode swaps the map for the placeholder while City is cleared", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + + const city = within(region).getByRole("textbox", { name: "City" }); + fireEvent.change(city, { target: { value: "" } }); + + expect( + await within(region).findByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + expect(within(region).queryByTitle("Site location map")).not.toBeInTheDocument(); + + fireEvent.change(city, { target: { value: "Fort Worth" } }); + expect(await within(region).findByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX&output=embed", + ); + }); + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { update.mockResolvedValue({}); renderWithProviders(, { withAuth: false }); 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 79dfb6a0..1f7069c3 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 @@ -84,7 +84,13 @@ describe("Work order Site dialog edits the site record", () => { "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(); + const map = screen.getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + expect(map).toHaveAttribute("loading", "lazy"); + expect(screen.queryByText("Enter an address to preview the location")).not.toBeInTheDocument(); expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); }); diff --git a/src/test/domain/locations/utils/site-maps.test.ts b/src/test/domain/locations/utils/site-maps.test.ts new file mode 100644 index 00000000..c6dc040e --- /dev/null +++ b/src/test/domain/locations/utils/site-maps.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + buildSiteMapEmbedUrl, + buildSiteMapsUrl, + hasCompleteSiteAddress, +} from "@/domain/locations/utils/site-maps"; + +const site = { address: "12 Dock Rd #4", city: "Smith & Sons", state: "TX", zipCode: "75201" }; + +describe("site map URLs", () => { + it("embeds the same address line the Street Address link opens", () => { + const line = "12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX%2075201"; + expect(buildSiteMapsUrl(site)).toBe(`https://www.google.com/maps/search/?api=1&query=${line}`); + expect(buildSiteMapEmbedUrl(site)).toBe(`https://www.google.com/maps?q=${line}&output=embed`); + }); + + it("has no embed URL for a site without any address", () => { + expect(buildSiteMapEmbedUrl({})).toBe(""); + }); + + it("counts an address complete only when Street, City and State are all filled", () => { + expect(hasCompleteSiteAddress(site)).toBe(true); + expect(hasCompleteSiteAddress({ ...site, state: " " })).toBe(false); + expect(hasCompleteSiteAddress({ ...site, city: undefined })).toBe(false); + }); +}); diff --git a/src/test/lib/address/google-maps.test.ts b/src/test/lib/address/google-maps.test.ts new file mode 100644 index 00000000..5bdc4fd9 --- /dev/null +++ b/src/test/lib/address/google-maps.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps"; + +describe("googleMapsEmbedUrl", () => { + it("builds the keyless embed URL for an address", () => { + expect(googleMapsEmbedUrl("3811 Distribution Dr, Dallas, TX 75201")).toBe( + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); + }); + + it("encodes commas, # and & so they stay inside the query", () => { + expect(googleMapsEmbedUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe( + "https://www.google.com/maps?q=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX&output=embed", + ); + }); + + it("returns an empty string for a blank address", () => { + expect(googleMapsEmbedUrl("")).toBe(""); + expect(googleMapsEmbedUrl(" ")).toBe(""); + }); +}); + +describe("googleMapsSearchUrl", () => { + it("encodes commas, # and & in the search link", () => { + expect(googleMapsSearchUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe( + "https://www.google.com/maps/search/?api=1&query=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX", + ); + }); + + it("returns an empty string for a blank address", () => { + expect(googleMapsSearchUrl(" ")).toBe(""); + }); +}); From 35714a917d6d77d3eee9366bd6205f1ea780130f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 17:01:27 -0300 Subject: [PATCH 7/8] fix(workorders): Site dialog re-syncs a site after switching back and never syncs cached data Switching to another site and back now loads the site record again, so its extra contacts are not dropped on Save. The dialog also waits for the site request to settle before syncing, so a record cached before an earlier save is never shown or written back. Half-filled extra contacts stay off the site record, as they already stay off the work order. The save path moves into its own hook to keep the dialog state under the complexity limit. --- .../list/table/cells/use-site-dialog-save.ts | 82 ++++++++ .../list/table/cells/use-site-dialog-state.ts | 119 ++++------- .../list/table/cells/use-site-record-sync.ts | 61 +++--- .../site-dialog-site-record-resync.test.tsx | 190 ++++++++++++++++++ 4 files changed, 352 insertions(+), 100 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts create mode 100644 src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts new file mode 100644 index 00000000..cf2fdfc9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts @@ -0,0 +1,82 @@ +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 { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; +import { + workOrderPocAfterSiteSave, + type useSiteRecordSync, + type WorkOrderPoc, +} 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 type { LocationOption } from "@/domain/work-orders/types/work-order"; + +type UseSiteDialogSaveArgs = { + fields: ReturnType; + siteRecord: ReturnType; + /** The work order's values when the dialog opened. */ + original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] }; + createMode: boolean; + /** The first contact is required before Save (inline create, or editing the site record). */ + requiresPoc: boolean; + onSave: (patch: SitePatch) => void; + onOpenChange: (open: boolean) => void; +}; + +/** + * The Site dialog's Save: writes changed contacts and notes to the site record first when the + * dialog edits it, then patches the work order and closes. + */ +export function useSiteDialogSave({ + fields, + siteRecord, + original, + createMode, + requiresPoc, + onSave, + onOpenChange, +}: UseSiteDialogSaveArgs) { + const updateSite = useUpdateSiteContactInfo(); + const pocMissing = !fields.pn.trim() || !fields.pp.trim(); + const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing); + + const saveWorkOrder = () => { + const poc = workOrderPocAfterSiteSave( + siteRecord, + original, + { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, + fields.locId !== resolveLocationId(original.locationId, original.value, original.sites), + ); + onSave( + buildSiteDialogPatch({ + code: fields.code, + locId: fields.locId, + selected: fields.selected, + ...poc, + 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: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); + }; + + return { + attemptSave, + // Save is actionable only once something changed; inline create keeps its confirm step. + saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty), + saving: updateSite.isPending, + }; +} 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 f23b1b01..6d53ac87 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 @@ -1,15 +1,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 { 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 { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save"; +import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row"; @@ -32,6 +28,20 @@ type UseSiteDialogStateArgs = { onSave: (patch: SitePatch) => void; }; +/** A completed work order's frozen snapshot, shaped like the live site detail. */ +function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location { + return { + name: frozenSite.label, + address: frozenSite.address, + city: frozenSite.city, + state: frozenSite.state, + zipCode: frozenSite.zip, + phone: frozenSite.phone, + contact: undefined, + contactEmail: frozenSite.email, + }; +} + export function useSiteDialogState({ open, onOpenChange, @@ -58,96 +68,51 @@ export function useSiteDialogState({ sites, createMode, }); - const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields; - const { - data: liveLocationDetail, - isLoading: liveLocationDetailLoading, - isError: liveLocationDetailError, - } = useLocationDetail(frozenSite == null && open && locId ? locId : undefined); + const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields; + const live = frozenSite == null; + const liveDetail = useLocationDetail(live && open && locId ? locId : undefined); const frozenLocationDetail = useMemo( - () => - frozenSite == null - ? undefined - : { - name: frozenSite.label, - address: frozenSite.address, - city: frozenSite.city, - state: frozenSite.state, - zipCode: frozenSite.zip, - phone: frozenSite.phone, - contact: undefined, - contactEmail: frozenSite.email, - }, + () => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)), [frozenSite], ); - const locationDetail = frozenLocationDetail ?? liveLocationDetail; - const locationDetailLoading = frozenSite == null && liveLocationDetailLoading; - const locationDetailError = frozenSite == null && liveLocationDetailError; + const locationDetail = frozenLocationDetail ?? liveDetail.data; + const locationDetailLoading = live && liveDetail.isLoading; + const locationDetailError = live && liveDetail.isError; 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 editsSiteRecord = !createMode && !viewOnly && live; const siteRecord = useSiteRecordSync({ enabled: editsSiteRecord, open, locId, - locationDetail: liveLocationDetail, - locationDetailError: liveLocationDetailError, + locationDetail: liveDetail.data, + locationDetailFetching: liveDetail.isFetching, + locationDetailError: liveDetail.isError, fields, }); // The site failed to load (or the user typed before it did), so Save writes this work order only. const siteRecordUnavailable = editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading; - const updateSite = useUpdateSiteContactInfo(); - const pocMissing = !pn.trim() || !pp.trim(); const requiresPoc = createMode || editsSiteRecord; - const canConfirm = !siteMissing && (!requiresPoc || !pocMissing); + const save = useSiteDialogSave({ + fields, + siteRecord, + original: { pocName, pocPhone, pocNotes, locationId, value, sites }, + createMode, + requiresPoc, + onSave, + onOpenChange, + }); useEffect(() => { - if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) { + if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) { return; } setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? ""))); setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? ""))); setPocFilledFor(locId); - }, [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, - ...poc, - 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); + }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]); return { locId: fields.locId, @@ -164,9 +129,9 @@ export function useSiteDialogState({ showErrors: fields.showErrors, siteMissing: fields.siteMissing, handlePick: fields.handlePick, - attemptSave, - saveDisabled, - saving: updateSite.isPending, + attemptSave: save.attemptSave, + saveDisabled: save.saveDisabled, + saving: save.saving, siteRecordLoading: siteRecord.loading, editsSiteRecord, siteRecordUnavailable, 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 33b09eb0..cdb45f3e 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 @@ -22,6 +22,8 @@ type UseSiteRecordSyncArgs = { open: boolean; locId: string; locationDetail: Location | undefined; + /** A request for the site is in flight; cached data may predate the latest save. */ + locationDetailFetching: boolean; locationDetailError: boolean; fields: SiteRecordFields; }; @@ -30,6 +32,10 @@ export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string type SiteRecordEdits = { primary: boolean; notes: boolean }; +function isCompleteContact(contact: WorkOrderAdditionalContact): boolean { + return contact.name.trim() !== "" && contact.phone.trim() !== ""; +} + function toRequest( primaryId: number | undefined, fields: Pick, @@ -37,7 +43,8 @@ function toRequest( return { contacts: [ { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp }, - ...fields.extraContacts.map((contact) => ({ + // A half-filled extra is dropped here, the same as the work order's own copy drops it. + ...fields.extraContacts.filter(isCompleteContact).map((contact) => ({ ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }), name: contact.name, phone: contact.phone, @@ -92,6 +99,22 @@ export function workOrderPocAfterSiteSave( }; } +/** The site record's contacts and notes, in the dialog's field shape. */ +function siteRecordValues(location: Location) { + const [main, ...others] = location.contacts ?? []; + return { + primaryId: main?.id, + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes: location.notes ?? "", + extraContacts: others.map((contact) => ({ + name: contact.name, + phone: contact.phone, + ...(contact.id === undefined ? {} : { siteContactId: contact.id }), + })), + }; +} + /** * 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. @@ -101,6 +124,7 @@ export function useSiteRecordSync({ open, locId, locationDetail, + locationDetailFetching, locationDetailError, fields, }: UseSiteRecordSyncArgs) { @@ -109,43 +133,34 @@ export function useSiteRecordSync({ const [baseline, setBaseline] = useState(null); const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + // Picking another site clears its contacts, so coming back to a site must load it again. useEffect(() => { setSyncedFor(""); - }, [open, resetVersion]); + }, [open, resetVersion, locId]); useEffect(() => { const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; + // Cached site data can predate a save made since, so only a settled request is synced. + const detailCurrent = detailMatches && !locationDetailFetching; // 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) { + if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) { 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( - toRequest(main?.id, { - pn: main?.name ?? "", - pp: main?.phone ?? "", - notes, - extraContacts: extras, - }), - ); + const site = siteRecordValues(locationDetail); + setPn(site.pn); + setPp(site.pp); + setExtraContacts(site.extraContacts); + setNotes(site.notes); + setPrimaryId(site.primaryId); + setBaseline(toRequest(site.primaryId, site)); setSyncedFor(locId); }, [ enabled, open, locId, locationDetail, + locationDetailFetching, syncedFor, userEdited, setPn, diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx new file mode 100644 index 00000000..12be55f2 --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx @@ -0,0 +1,190 @@ +import { QueryClient } from "@tanstack/react-query"; +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(); +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", + sitePhone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const DALLAS_CONTACTS = [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, +]; + +const SITES = [ + { id: "1", name: "DAL1" }, + { id: "2", name: "CHI2" }, +]; + +const SITE_RECORD_COPY = + "Contacts and notes are saved to the site record and apply to all its work orders."; +const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; + +function renderDialog(queryClient?: QueryClient) { + const onSave = vi.fn(); + renderWithProviders( + , + { withAuth: false, ...(queryClient === undefined ? {} : { queryClient }) }, + ); + return { onSave }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +/** Opens the Site picker and chooses a site by its label. */ +function pickSite(label: string) { + const trigger = document.querySelector('[aria-haspopup="listbox"]'); + if (trigger === null) throw new Error("expected the Site picker"); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("button", { name: label })); +} + +function editNotesAndSave(notes: string) { + fireEvent.change(primaryField("Notes…"), { target: { value: notes } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); +} + +describe("Work order Site dialog keeps the site record in sync", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + updateContactInfo.mockResolvedValue(undefined); + }); + + it("re-syncs a site's extra contacts after switching away and back while the other site loads", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : new Promise(() => {}), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + await waitFor(() => expect(getById).toHaveBeenCalledWith("2")); + expect(screen.queryByDisplayValue("Bob")).not.toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("re-syncs a site's extra contacts after switching away and back when the other site failed to load", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : Promise.reject(new Error("Network error")), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("waits for the refetch instead of syncing a cached site record, so a save never reverts it", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + // What the cache still holds from before an earlier Save changed the site. + queryClient.setQueryData(queryKeys.locations.detail("1"), dallas); + const saved: Location = { + ...dallas, + notes: "Saved gate code", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-9999" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }; + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(queryClient); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC phone")).toBeDisabled(); + expect(screen.queryByDisplayValue("(421) 433-0032")).not.toBeInTheDocument(); + resolveSite(saved); + + expect(await screen.findByDisplayValue("(421) 433-9999")).toBeEnabled(); + expect(primaryField("Notes…")).toHaveValue("Saved gate code"); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: saved.contacts, + notes: "Gate code 9999", + }); + }); + + it("leaves a half-filled extra contact off the site record, as the work order does", async () => { + getById.mockResolvedValue(dallas); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Bob"); + + fireEvent.click(screen.getByRole("button", { name: /Add point of contact/ })); + const names = screen.getAllByPlaceholderText("POC name"); + fireEvent.change(names[names.length - 1], { target: { value: "Name Only" } }); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); +}); From db6027f9c5e870e090e74ec6752adbda286f5c97 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 17:27:32 -0300 Subject: [PATCH 8/8] fix(sites): Edit form map preview includes the stored ZIP The Edit form's preview now uses the same address line as the Street Address link and the view mode, ZIP included. --- .../locations/_components/site-form-fields.tsx | 14 ++++++++++++-- .../locations/site-detail-panel.test.tsx | 9 ++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx index dfc94ead..29be2a4f 100644 --- a/src/app/(protected)/locations/_components/site-form-fields.tsx +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -27,7 +27,11 @@ export function SiteFormFields({ register, formState: { errors }, } = form; - const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] }); + // ZIP is carried through unedited, so the preview shows the same line as the Street Address link. + const [address, city, state, zipCode] = useWatch({ + control, + name: ["address", "city", "state", "zipCode"], + }); const { field: addressField } = useController({ control, name: "address" }); const { field: cityField } = useController({ control, name: "city" }); const { field: stateField } = useController({ control, name: "state" }); @@ -84,7 +88,13 @@ export function SiteFormFields({ /> - + { ).not.toBeInTheDocument(); }); - it("edit mode swaps the map for the placeholder while City is cleared", async () => { + it("edit mode previews the stored ZIP and swaps the map for the placeholder while City is cleared", async () => { renderWithProviders(, { withAuth: false }); const region = await openSite("DAL1"); fireEvent.click(within(region).getByRole("button", { name: "Edit" })); - expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByTitle("Site location map")).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed", + ); const city = within(region).getByRole("textbox", { name: "City" }); fireEvent.change(city, { target: { value: "" } }); @@ -162,7 +165,7 @@ describe("Sites page detail panel", () => { fireEvent.change(city, { target: { value: "Fort Worth" } }); expect(await within(region).findByTitle("Site location map")).toHaveAttribute( "src", - "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX&output=embed", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX%2075201&output=embed", ); });