diff --git a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts
index 3fe930cd..8566c1cd 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts
+++ b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts
@@ -14,6 +14,8 @@ type BuildSiteDialogPatchArgs = {
extraContacts: WorkOrderAdditionalContact[];
contactsDirty: boolean;
baselineHadContacts: boolean;
+ /** The dialog saved these contacts and notes to the site record, so the work order follows it. */
+ followsSiteRecord?: boolean;
};
export function buildSiteDialogPatch({
@@ -26,6 +28,7 @@ export function buildSiteDialogPatch({
extraContacts,
contactsDirty,
baselineHadContacts,
+ followsSiteRecord = false,
}: BuildSiteDialogPatchArgs): SitePatch {
const patch: SitePatch = {
site: code || (selected ? resolveSiteCode(selected) : ""),
@@ -33,9 +36,10 @@ export function buildSiteDialogPatch({
locationId: locId,
pocName,
pocPhone,
- pocNotes,
+ // Notes live on the site record; an empty override lets the site's notes show through.
+ pocNotes: followsSiteRecord ? "" : pocNotes,
};
- if (contactsDirty || baselineHadContacts) {
+ if (followsSiteRecord || contactsDirty || baselineHadContacts) {
patch.additionalContacts = extraContacts;
}
return patch;
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
index 07b1bb94..93815ade 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel.tsx
@@ -1,4 +1,5 @@
-import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
+import { Mail, MapPin, Phone } from "lucide-react";
+import { SiteMapPreview } from "@/app/(protected)/locations/_components/site-map-preview";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { Text } from "@/components/ui/text";
import type { Location } from "@/domain/locations/types/location";
@@ -44,24 +45,31 @@ export function SiteDialogDetailPanel({
>
{locationDetail ? (
<>
-
+ {Boolean(addressPreview) && (
+
+
+ {addressPreview}
+
+ )}
+ {!addressPreview && }
{Boolean(locationDetail.phone) && (
)}
{Boolean(locationDetail.contactEmail) && (
)}
- {Boolean(addressPreview) && (
-
- Open in Google Maps
-
- )}
+
>
) : (
<>
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx
index 8492225e..37e87e58 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx
@@ -4,9 +4,17 @@ type SiteDialogFooterProps = {
onClose: () => void;
onConfirm: () => void;
viewOnly?: boolean;
+ saveDisabled?: boolean;
+ saving?: boolean;
};
-export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) {
+export function SiteDialogFooter({
+ onClose,
+ onConfirm,
+ viewOnly = false,
+ saveDisabled = false,
+ saving = false,
+}: SiteDialogFooterProps) {
if (viewOnly) {
return (
@@ -22,7 +30,13 @@ export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteD
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..82f35a06 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx
@@ -58,6 +58,7 @@ export function SiteDialog({
additionalContacts,
sites,
createMode,
+ viewOnly,
frozenSite,
onSave,
});
@@ -86,7 +87,7 @@ export function SiteDialog({
-
Site
+
Site
Site is required
@@ -129,8 +130,9 @@ export function SiteDialog({
pocPhone={s.pp}
pocNotes={s.notes}
additionalContacts={s.extraContacts}
- showPrimaryErrors={createMode && s.showErrors}
- primaryRequired={createMode}
+ showPrimaryErrors={s.requiresPoc && s.showErrors}
+ primaryRequired={s.requiresPoc}
+ editsSiteRecord={s.editsSiteRecord}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
@@ -138,7 +140,13 @@ export function SiteDialog({
/>
-
+
);
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts
index 1b0a4f30..a5c585f8 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts
@@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = {
createMode: boolean;
};
+function sameContacts(a: WorkOrderAdditionalContact[], b: WorkOrderAdditionalContact[]): boolean {
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
+}
+
+/** Keeps one array identity while the contacts are unchanged, so a fresh `[]` does not reset. */
+function useStableContacts(contacts: WorkOrderAdditionalContact[]): WorkOrderAdditionalContact[] {
+ const [stable, setStable] = useState(contacts);
+ if (!sameContacts(stable, contacts)) {
+ setStable(contacts);
+ return contacts;
+ }
+ return stable;
+}
+
export function useSiteDialogFormFields({
open,
value,
@@ -23,10 +37,11 @@ export function useSiteDialogFormFields({
pocName,
pocPhone,
pocNotes,
- additionalContacts,
+ additionalContacts: incomingContacts,
sites,
createMode,
}: UseSiteDialogFormFieldsArgs) {
+ const additionalContacts = useStableContacts(incomingContacts);
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
@@ -40,6 +55,8 @@ export function useSiteDialogFormFields({
() => additionalContacts.length > 0,
);
const [showErrors, setShowErrors] = useState(false);
+ // Bumped whenever the fields are reset from the row, so site-record sync can re-apply.
+ const [resetVersion, setResetVersion] = useState(0);
useEffect(() => {
if (!open) {
@@ -62,6 +79,7 @@ export function useSiteDialogFormFields({
setExtraContacts(additionalContacts);
setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
+ setResetVersion((version) => version + 1);
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
const handlePick = (id: string) => {
@@ -100,6 +118,7 @@ export function useSiteDialogFormFields({
pocFilledFor,
dirty,
contactsDirty,
+ resetVersion,
baselineHadContacts,
showErrors,
selected: sites.find((s) => String(s.id) === locId),
@@ -108,6 +127,8 @@ export function useSiteDialogFormFields({
setPn,
setPp,
setShowErrors,
+ setNotes,
+ setExtraContacts,
markDirty: () => setDirty(true),
handlePick,
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
index 5800cbf4..ecb98c03 100644
--- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts
@@ -2,6 +2,8 @@ import { useEffect, useMemo } from "react";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
+import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
+import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
@@ -21,6 +23,7 @@ type UseSiteDialogStateArgs = {
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
+ viewOnly?: boolean;
frozenSite?: WorkOrderFrozenSite;
onSave: (patch: SitePatch) => void;
};
@@ -36,6 +39,7 @@ export function useSiteDialogState({
additionalContacts = [],
sites,
createMode,
+ viewOnly = false,
frozenSite,
onSave,
}: UseSiteDialogStateArgs) {
@@ -77,8 +81,20 @@ export function useSiteDialogState({
const locationDetailError = frozenSite == null && liveLocationDetailError;
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
+ // An existing, editable work order edits the site record itself (contacts and notes).
+ const editsSiteRecord = !createMode && !viewOnly && frozenSite == null;
+ const siteRecord = useSiteRecordSync({
+ enabled: editsSiteRecord,
+ open,
+ locId,
+ locationDetail: liveLocationDetail,
+ locationDetailError: liveLocationDetailError,
+ fields,
+ });
+ const updateSite = useUpdateSiteContactInfo();
const pocMissing = !pn.trim() || !pp.trim();
- const canConfirm = !siteMissing && (!createMode || !pocMissing);
+ const requiresPoc = createMode || editsSiteRecord;
+ const canConfirm = !siteMissing && (!requiresPoc || !pocMissing);
useEffect(() => {
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
@@ -89,11 +105,7 @@ export function useSiteDialogState({
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
- const attemptSave = () => {
- if (createMode && !canConfirm) {
- fields.setShowErrors(true);
- return;
- }
+ const saveWorkOrder = () => {
onSave(
buildSiteDialogPatch({
code: fields.code,
@@ -105,11 +117,27 @@ export function useSiteDialogState({
extraContacts: fields.extraContacts,
contactsDirty: fields.contactsDirty,
baselineHadContacts: fields.baselineHadContacts,
+ followsSiteRecord: siteRecord.synced,
}),
);
onOpenChange(false);
};
+ const attemptSave = () => {
+ if (requiresPoc && !canConfirm) {
+ fields.setShowErrors(true);
+ return;
+ }
+ if (!siteRecord.siteChanged) {
+ saveWorkOrder();
+ return;
+ }
+ updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
+ };
+
+ // Save is actionable only once something changed; inline create keeps its confirm step.
+ const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty);
+
return {
locId: fields.locId,
code: fields.code,
@@ -126,6 +154,10 @@ export function useSiteDialogState({
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
attemptSave,
+ saveDisabled,
+ saving: updateSite.isPending,
+ editsSiteRecord,
+ requiresPoc,
setPn: fields.setPnDirty,
setPp: fields.setPpDirty,
setNotes: fields.setNotesDirty,
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts
new file mode 100644
index 00000000..2c5bcbad
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts
@@ -0,0 +1,112 @@
+import { useEffect, useState } from "react";
+import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+
+type SiteRecordFields = {
+ pn: string;
+ pp: string;
+ notes: string;
+ extraContacts: WorkOrderAdditionalContact[];
+ resetVersion: number;
+ setPn: (value: string) => void;
+ setPp: (value: string) => void;
+ setNotes: (value: string) => void;
+ setExtraContacts: (contacts: WorkOrderAdditionalContact[]) => void;
+};
+
+type UseSiteRecordSyncArgs = {
+ /** Existing, editable work order: the dialog edits the site record itself. */
+ enabled: boolean;
+ open: boolean;
+ locId: string;
+ locationDetail: Location | undefined;
+ locationDetailError: boolean;
+ fields: SiteRecordFields;
+};
+
+function toRequest(
+ primaryId: number | undefined,
+ fields: Pick,
+): SiteContactInfoInput {
+ return {
+ contacts: [
+ { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
+ ...fields.extraContacts.map((contact) => ({
+ ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
+ name: contact.name,
+ phone: contact.phone,
+ })),
+ ],
+ notes: fields.notes,
+ };
+}
+
+function fingerprint(request: SiteContactInfoInput): string {
+ return JSON.stringify({
+ contacts: request.contacts.map((contact) => [contact.name.trim(), contact.phone.trim()]),
+ notes: request.notes.trim(),
+ });
+}
+
+/**
+ * Loads the selected site's contacts and notes into the Site dialog and reports whether the
+ * user changed them, so Save can write them back to the site record.
+ */
+export function useSiteRecordSync({
+ enabled,
+ open,
+ locId,
+ locationDetail,
+ locationDetailError,
+ fields,
+}: UseSiteRecordSyncArgs) {
+ const [syncedFor, setSyncedFor] = useState("");
+ const [primaryId, setPrimaryId] = useState(undefined);
+ const [baseline, setBaseline] = useState("");
+ const { resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
+
+ useEffect(() => {
+ setSyncedFor("");
+ }, [open, resetVersion]);
+
+ useEffect(() => {
+ const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
+ if (!enabled || !open || !locId || !detailMatches || syncedFor === locId) {
+ return;
+ }
+ const [main, ...others] = locationDetail.contacts ?? [];
+ const extras = others.map((contact) => ({
+ name: contact.name,
+ phone: contact.phone,
+ ...(contact.id === undefined ? {} : { siteContactId: contact.id }),
+ }));
+ const notes = locationDetail.notes ?? "";
+ setPn(main?.name ?? "");
+ setPp(main?.phone ?? "");
+ setExtraContacts(extras);
+ setNotes(notes);
+ setPrimaryId(main?.id);
+ setBaseline(
+ fingerprint(
+ toRequest(main?.id, {
+ pn: main?.name ?? "",
+ pp: main?.phone ?? "",
+ notes,
+ extraContacts: extras,
+ }),
+ ),
+ );
+ setSyncedFor(locId);
+ }, [enabled, open, locId, locationDetail, syncedFor, setPn, setPp, setNotes, setExtraContacts]);
+
+ const request = toRequest(primaryId, fields);
+ const synced = enabled && syncedFor !== "" && syncedFor === locId;
+
+ return {
+ synced,
+ // Until the site loads the fields do not reflect it; a failed load falls back to the work order.
+ loading: enabled && Boolean(locId) && !synced && !locationDetailError,
+ siteChanged: synced && fingerprint(request) !== baseline,
+ request,
+ };
+}
diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts
index 2f9f1376..3c122e5a 100644
--- a/src/domain/locations/api/locations-api.ts
+++ b/src/domain/locations/api/locations-api.ts
@@ -1,8 +1,9 @@
import { API_PATHS } from "@/api/api-paths";
-import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
+import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapLocation,
+ mapContactsToBackend,
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
@@ -10,6 +11,7 @@ import {
import type {
Location,
LocationSite,
+ SiteContactInfoInput,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@@ -71,6 +73,14 @@ export const locationsApi = {
return mapLocation(handleApiResponse(data));
},
+ /** Saves the site's ordered contacts and notes (work-order Site dialog). */
+ updateContactInfo: async (id: string | number, input: SiteContactInfoInput): Promise => {
+ await apiPatch(`${API_PATHS.rest.locations}/${id}/contact-info`, {
+ contacts: mapContactsToBackend(input.contacts),
+ notes: input.notes,
+ });
+ },
+
delete: async (id: string | number): Promise => {
await apiDelete(`${API_PATHS.rest.locations}/${id}`);
},
diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts
index 77cadb17..b4ba7dc7 100644
--- a/src/domain/locations/types/location.ts
+++ b/src/domain/locations/types/location.ts
@@ -69,3 +69,9 @@ export interface LocationsListResult {
page: number;
pageSize: number;
}
+
+/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */
+export interface SiteContactInfoInput {
+ contacts: LocationContact[];
+ notes: string;
+}
diff --git a/src/domain/locations/use-cases/use-update-site-contact-info.ts b/src/domain/locations/use-cases/use-update-site-contact-info.ts
new file mode 100644
index 00000000..0f677c6b
--- /dev/null
+++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts
@@ -0,0 +1,29 @@
+import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { locationsApi } from "@/domain/locations/api/locations-api";
+import type { SiteContactInfoInput } from "@/domain/locations/types/location";
+import { queryKeys } from "@/infra/query-key/query-key";
+
+interface UpdateSiteContactInfoInput extends SiteContactInfoInput {
+ id: string | number;
+}
+
+/** Writes contacts and notes to the site record, then refetches every site query. */
+export function useUpdateSiteContactInfo(): UseMutationResult<
+ void,
+ Error,
+ UpdateSiteContactInfoInput
+> {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) =>
+ locationsApi.updateContactInfo(id, { contacts, notes }),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to update the site");
+ },
+ });
+}
diff --git a/src/domain/work-orders/types/work-order-additional-contact.ts b/src/domain/work-orders/types/work-order-additional-contact.ts
index dd5d7abf..24c1d43d 100644
--- a/src/domain/work-orders/types/work-order-additional-contact.ts
+++ b/src/domain/work-orders/types/work-order-additional-contact.ts
@@ -2,6 +2,8 @@ export interface WorkOrderAdditionalContact {
name: string;
phone: string;
notes?: string;
+ /** Site contact row this entry was loaded from (Site dialog); never sent on work-order patches. */
+ siteContactId?: number;
}
export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
new file mode 100644
index 00000000..52d52fe8
--- /dev/null
+++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx
@@ -0,0 +1,201 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
+import type { Location } from "@/domain/locations/types/location";
+import { renderWithProviders } from "@/test/test-utils";
+
+const getById = vi.fn();
+const updateContactInfo = vi.fn();
+
+vi.mock("@/domain/locations/api/locations-api", () => ({
+ locationsApi: {
+ getById: (...args: unknown[]) => getById(...args),
+ updateContactInfo: (...args: unknown[]) => updateContactInfo(...args),
+ },
+}));
+
+const dallas: Location = {
+ id: 1,
+ name: "DAL1",
+ address: "3811 Distribution Dr",
+ city: "Dallas",
+ state: "TX",
+ zipCode: "75201",
+ phone: "(214) 555-0100",
+ contactEmail: "dal1@acme.test",
+ notes: "Gate code 1234",
+ contacts: [
+ { id: 31, name: "Jane", phone: "(421) 433-0032" },
+ { id: 32, name: "Bob", phone: "(421) 433-0033" },
+ ],
+};
+
+const sites = [{ id: "1", name: "DAL1" }];
+
+function renderDialog(overrides: Partial[0]> = {}) {
+ const onSave = vi.fn();
+ const onOpenChange = vi.fn();
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+ return { onSave, onOpenChange };
+}
+
+/** The main contact's field; additional contact rows reuse the same placeholders after it. */
+function primaryField(placeholder: string): HTMLElement {
+ return screen.getAllByPlaceholderText(placeholder)[0];
+}
+
+function saveButton(): HTMLElement {
+ return screen.getByRole("button", { name: /^save$/i });
+}
+
+describe("Work order Site dialog edits the site record", () => {
+ beforeEach(() => {
+ getById.mockReset();
+ updateContactInfo.mockReset();
+ getById.mockResolvedValue(dallas);
+ });
+
+ it("shows the site's contacts, notes, linked address and map preview", async () => {
+ renderDialog();
+
+ expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("(421) 433-0032")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("Bob")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("Gate code 1234")).toBeInTheDocument();
+ const address = screen.getByRole("link", { name: /3811 Distribution Dr/ });
+ expect(address).toHaveAttribute(
+ "href",
+ "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201",
+ );
+ expect(address).toHaveAttribute("target", "_blank");
+ expect(screen.getByTitle("Site location map")).toBeInTheDocument();
+ });
+
+ it("keeps Save disabled until something changes", async () => {
+ renderDialog();
+ await screen.findByDisplayValue("Jane");
+
+ expect(saveButton()).toBeDisabled();
+ fireEvent.change(primaryField("Notes…"), {
+ target: { value: "Gate code 9999" },
+ });
+ expect(saveButton()).toBeEnabled();
+ });
+
+ it("saves contact and notes edits to the site, then points the work order at it", async () => {
+ updateContactInfo.mockResolvedValue(undefined);
+ const { onSave, onOpenChange } = renderDialog();
+ await screen.findByDisplayValue("Jane");
+
+ fireEvent.change(primaryField("POC phone"), {
+ target: { value: "(421) 433-9999" },
+ });
+ fireEvent.change(primaryField("Notes…"), {
+ target: { value: "Gate code 9999" },
+ });
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(updateContactInfo).toHaveBeenCalledWith("1", {
+ contacts: [
+ { id: 31, name: "Jane", phone: "(421) 433-9999" },
+ { id: 32, name: "Bob", phone: "(421) 433-0033" },
+ ],
+ notes: "Gate code 9999",
+ });
+ expect(updateContactInfo.mock.invocationCallOrder[0]).toBeLessThan(
+ onSave.mock.invocationCallOrder[0],
+ );
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({
+ locationId: "1",
+ pocName: "Jane",
+ pocPhone: "(421) 433-9999",
+ pocNotes: "",
+ additionalContacts: [{ name: "Bob", phone: "(421) 433-0033", siteContactId: 32 }],
+ }),
+ );
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("keeps the dialog open and the work order untouched when the site update fails", async () => {
+ updateContactInfo.mockRejectedValue(new Error("Forbidden"));
+ const { onSave, onOpenChange } = renderDialog();
+ await screen.findByDisplayValue("Jane");
+
+ fireEvent.change(primaryField("Notes…"), {
+ target: { value: "Gate code 9999" },
+ });
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(saveButton()).toBeEnabled());
+ expect(onSave).not.toHaveBeenCalled();
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it("requires the first contact before saving", async () => {
+ const { onSave } = renderDialog();
+ await screen.findByDisplayValue("Jane");
+
+ fireEvent.change(primaryField("POC name"), { target: { value: "" } });
+ fireEvent.click(saveButton());
+
+ expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
+ expect(updateContactInfo).not.toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("a completed work order shows its frozen snapshot read-only, without a site lookup", () => {
+ renderDialog({
+ viewOnly: true,
+ frozenSite: {
+ code: "DAL1",
+ label: "DAL1",
+ address: "1 Old Rd",
+ city: "Dallas",
+ state: "TX",
+ zip: "75201",
+ },
+ });
+
+ expect(screen.getByRole("link", { name: /1 Old Rd/ })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
+ expect(screen.getByDisplayValue("Someone Else")).toBeDisabled();
+ expect(getById).not.toHaveBeenCalled();
+ expect(updateContactInfo).not.toHaveBeenCalled();
+ });
+
+ it("inline create keeps the work-order-only path and never writes the site", async () => {
+ const { onSave } = renderDialog({
+ createMode: true,
+ pocName: "Temp",
+ pocPhone: "(111) 111-1111",
+ pocNotes: "",
+ });
+ await waitFor(() => expect(getById).toHaveBeenCalled());
+
+ fireEvent.change(primaryField("Notes…"), { target: { value: "Bring ladder" } });
+ fireEvent.click(saveButton());
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ pocName: "Temp", pocNotes: "Bring ladder" }),
+ );
+ expect(updateContactInfo).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
index d527414d..b868415a 100644
--- a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
@@ -19,6 +19,10 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
},
}));
+vi.mock("@/domain/locations/use-cases/use-update-site-contact-info", () => ({
+ useUpdateSiteContactInfo: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
const SITES: LocationOption[] = [
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },