{locationDetail ? (
<>
-
- {Boolean(locationDetail.phone) && (
-
- )}
- {Boolean(locationDetail.contactEmail) && (
-
- )}
{Boolean(addressPreview) && (
- Open in Google Maps
+
+ {addressPreview}
)}
+ {!addressPreview &&
}
+ {Boolean(sitePhone) &&
}
+ {Boolean(locationDetail.contactEmail) && (
+
+ )}
+
>
) : (
<>
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
Cancel
-
+
Save
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..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
@@ -8,12 +8,25 @@ type SiteDialogPocSectionProps = {
additionalContacts: WorkOrderAdditionalContact[];
showPrimaryErrors?: boolean;
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,
@@ -21,6 +34,8 @@ export function SiteDialogPocSection({
additionalContacts,
showPrimaryErrors = false,
primaryRequired = false,
+ editsSiteRecord = false,
+ siteRecordUnavailable = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
@@ -38,7 +53,7 @@ export function SiteDialogPocSection({
onPocPhoneChange={onPocPhoneChange}
onPocNotesChange={onPocNotesChange}
onAdditionalContactsChange={onAdditionalContactsChange}
- helperText="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 06c5e6bb..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
@@ -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
@@ -124,21 +125,36 @@ export function SiteDialog({
/>
)}
-
+ {/* Locked until the site record loads, so its values never replace typed input. */}
+
+
+
-
+
);
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..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
@@ -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);
@@ -36,15 +51,20 @@ 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,
);
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) {
setDirty(false);
setContactsDirty(false);
+ setUserEdited(false);
setShowErrors(false);
}
}, [open]);
@@ -62,6 +82,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) => {
@@ -74,6 +95,7 @@ export function useSiteDialogFormFields({
if (siteChanged) {
setExtraContacts([]);
setContactsDirty(true);
+ setUserEdited(false);
setDirty(true);
if (createMode) {
setPn("");
@@ -87,6 +109,7 @@ export function useSiteDialogFormFields({
const markPocDirty = (apply: () => void) => {
setDirty(true);
+ setUserEdited(true);
apply();
};
@@ -100,6 +123,8 @@ export function useSiteDialogFormFields({
pocFilledFor,
dirty,
contactsDirty,
+ userEdited,
+ resetVersion,
baselineHadContacts,
showErrors,
selected: sites.find((s) => String(s.id) === locId),
@@ -108,6 +133,8 @@ export function useSiteDialogFormFields({
setPn,
setPp,
setShowErrors,
+ setNotes,
+ setExtraContacts,
markDirty: () => setDirty(true),
handlePick,
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
@@ -115,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-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 5800cbf4..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,9 +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 { 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";
@@ -21,10 +23,25 @@ type UseSiteDialogStateArgs = {
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
+ viewOnly?: boolean;
frozenSite?: WorkOrderFrozenSite;
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,
@@ -36,6 +53,7 @@ export function useSiteDialogState({
additionalContacts = [],
sites,
createMode,
+ viewOnly = false,
frozenSite,
onSave,
}: UseSiteDialogStateArgs) {
@@ -50,65 +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) : "";
- const pocMissing = !pn.trim() || !pp.trim();
- const canConfirm = !siteMissing && (!createMode || !pocMissing);
+ // An existing, editable work order edits the site record itself (contacts and notes).
+ const editsSiteRecord = !createMode && !viewOnly && live;
+ const siteRecord = useSiteRecordSync({
+ enabled: editsSiteRecord,
+ open,
+ locId,
+ 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 requiresPoc = createMode || editsSiteRecord;
+ 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 attemptSave = () => {
- if (createMode && !canConfirm) {
- fields.setShowErrors(true);
- return;
- }
- onSave(
- buildSiteDialogPatch({
- code: fields.code,
- locId: fields.locId,
- selected: fields.selected,
- pocName: fields.pn,
- pocPhone: fields.pp,
- pocNotes: fields.notes,
- extraContacts: fields.extraContacts,
- contactsDirty: fields.contactsDirty,
- baselineHadContacts: fields.baselineHadContacts,
- }),
- );
- onOpenChange(false);
- };
+ }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
return {
locId: fields.locId,
@@ -125,7 +129,13 @@ export function useSiteDialogState({
showErrors: fields.showErrors,
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
- attemptSave,
+ attemptSave: save.attemptSave,
+ saveDisabled: save.saveDisabled,
+ saving: save.saving,
+ siteRecordLoading: siteRecord.loading,
+ editsSiteRecord,
+ siteRecordUnavailable,
+ 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..cdb45f3e
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts
@@ -0,0 +1,184 @@
+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[];
+ /** 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;
+ 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;
+ /** A request for the site is in flight; cached data may predate the latest save. */
+ locationDetailFetching: boolean;
+ locationDetailError: boolean;
+ fields: SiteRecordFields;
+};
+
+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,
+): SiteContactInfoInput {
+ return {
+ contacts: [
+ { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
+ // 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,
+ })),
+ ],
+ 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(),
+ });
+}
+
+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(),
+ };
+}
+
+/**
+ * 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,
+ };
+}
+
+/** 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.
+ */
+export function useSiteRecordSync({
+ enabled,
+ open,
+ locId,
+ locationDetail,
+ locationDetailFetching,
+ locationDetailError,
+ fields,
+}: UseSiteRecordSyncArgs) {
+ const [syncedFor, setSyncedFor] = useState("");
+ const [primaryId, setPrimaryId] = useState(undefined);
+ 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, 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 || !detailCurrent || syncedFor === locId || userEdited) {
+ return;
+ }
+ 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,
+ 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,
+ // 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/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts
index 728980e8..fc1a5f60 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,
@@ -11,6 +12,7 @@ import {
import type {
Location,
LocationSite,
+ SiteContactInfoInput,
SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
@@ -73,6 +75,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 16829026..d9d30fbb 100644
--- a/src/domain/locations/types/location.ts
+++ b/src/domain/locations/types/location.ts
@@ -70,6 +70,12 @@ export interface LocationsListResult {
pageSize: number;
}
+/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */
+export interface SiteContactInfoInput {
+ contacts: LocationContact[];
+ notes: string;
+}
+
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
export interface SiteOpenWorkOrders {
count: number;
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..4f218a42
--- /dev/null
+++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts
@@ -0,0 +1,33 @@
+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 sites and work orders. */
+export function useUpdateSiteContactInfo(): UseMutationResult<
+ void,
+ Error,
+ UpdateSiteContactInfoInput
+> {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) =>
+ locationsApi.updateContactInfo(id, { contacts, notes }),
+ onSuccess: async () => {
+ // 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/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/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/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 8b5c2885..fd5cf055 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,45 @@ 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 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")).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: "" } });
+
+ 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%2075201&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-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",
+ });
+ });
+});
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..fc580260
--- /dev/null
+++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
@@ -0,0 +1,316 @@
+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",
+ phone: "(421) 433-0032",
+ 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" }];
+
+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();
+ const { queryClient } = renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+ return { onSave, onOpenChange, queryClient };
+}
+
+/** 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");
+ 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();
+ 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 () => {
+ 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 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());
+ 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");
+ 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");
+
+ 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.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-7777" },
+ ],
+ 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-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();
+ 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" },
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("");
+ });
+});