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..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
@@ -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) : ""),
@@ -35,7 +38,9 @@ export function buildSiteDialogPatch({
pocPhone,
pocNotes,
};
- if (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 07b1bb94..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
@@ -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";
@@ -34,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 (
{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..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..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
@@ -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,35 @@ 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-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
index 5800cbf4..e08a184e 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,12 @@ 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 { 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 +27,7 @@ type UseSiteDialogStateArgs = {
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
+ viewOnly?: boolean;
frozenSite?: WorkOrderFrozenSite;
onSave: (patch: SitePatch) => void;
};
@@ -36,6 +43,7 @@ export function useSiteDialogState({
additionalContacts = [],
sites,
createMode,
+ viewOnly = false,
frozenSite,
onSave,
}: UseSiteDialogStateArgs) {
@@ -77,8 +85,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,27 +109,43 @@ export function useSiteDialogState({
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
- const attemptSave = () => {
- if (createMode && !canConfirm) {
- fields.setShowErrors(true);
- return;
- }
+ 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,
+ 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 +162,11 @@ export function useSiteDialogState({
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
attemptSave,
+ saveDisabled,
+ saving: updateSite.isPending,
+ siteRecordLoading: siteRecord.loading,
+ 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..33b09eb0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts
@@ -0,0 +1,169 @@
+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;
+ locationDetailError: boolean;
+ fields: SiteRecordFields;
+};
+
+export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string };
+
+type SiteRecordEdits = { primary: boolean; notes: boolean };
+
+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(),
+ });
+}
+
+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,
+ };
+}
+
+/**
+ * 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,
+ 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;
+
+ useEffect(() => {
+ setSyncedFor("");
+ }, [open, resetVersion]);
+
+ useEffect(() => {
+ const detailMatches = locationDetail !== undefined && String(locationDetail.id) === 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 ?? [];
+ 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,
+ }),
+ );
+ setSyncedFor(locId);
+ }, [
+ 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,
+ // 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 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..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/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..79dfb6a0
--- /dev/null
+++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
@@ -0,0 +1,299 @@
+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" }];
+
+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");
+ expect(screen.getByTitle("Site location map")).toBeInTheDocument();
+ expect(screen.getByText("(214) 555-0100")).toBeInTheDocument();
+ 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 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");
+
+ 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" },