- Site
+ Site
Site is required
@@ -124,21 +126,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..25ccb0d2 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,10 +95,13 @@ export function useSiteDialogFormFields({
if (siteChanged) {
setExtraContacts([]);
setContactsDirty(true);
+ setUserEdited(false);
setDirty(true);
- if (createMode) {
- setPn("");
- setPp("");
+ // The previous site's contact never carries over, even when the new site fails to load.
+ setPn("");
+ setPp("");
+ if (!createMode) {
+ setNotes("");
}
setPocFilledFor("");
} else if (id !== pocFilledFor) {
@@ -87,6 +111,7 @@ export function useSiteDialogFormFields({
const markPocDirty = (apply: () => void) => {
setDirty(true);
+ setUserEdited(true);
apply();
};
@@ -100,6 +125,8 @@ export function useSiteDialogFormFields({
pocFilledFor,
dirty,
contactsDirty,
+ userEdited,
+ resetVersion,
baselineHadContacts,
showErrors,
selected: sites.find((s) => String(s.id) === locId),
@@ -108,6 +135,8 @@ export function useSiteDialogFormFields({
setPn,
setPp,
setShowErrors,
+ setNotes,
+ setExtraContacts,
markDirty: () => setDirty(true),
handlePick,
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
@@ -115,6 +144,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..ade519fb
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts
@@ -0,0 +1,201 @@
+import { useEffect, useState } from "react";
+import type {
+ Location,
+ LocationContact,
+ 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's contacts in order. A site saved before the contacts list existed has none; its
+ * contact lives only in the legacy `contact`/`phone` fields, so that one is the main contact.
+ */
+function siteContacts(location: Location): LocationContact[] {
+ if (location.contacts && location.contacts.length > 0) {
+ return location.contacts;
+ }
+ const name = location.contact ?? "";
+ const phone = location.phone ?? "";
+ return name.trim() || phone.trim() ? [{ name, phone }] : [];
+}
+
+/** The site record's contacts and notes, in the dialog's field shape. */
+function siteRecordValues(location: Location) {
+ const [main, ...others] = siteContacts(location);
+ 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/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
index c12c2eab..548706c6 100644
--- a/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
+++ b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
@@ -26,6 +26,8 @@ type PocContactFieldsProps = {
showPrimaryErrors?: boolean;
/** Labels the primary name/phone inputs with a persistent required asterisk. */
primaryRequired?: boolean;
+ /** Offers a Notes box on each additional contact; off where contacts save without per-contact notes. */
+ additionalContactNotes?: boolean;
};
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
@@ -49,11 +51,13 @@ function updateContactAt(
function AdditionalPocCard({
contact,
index,
+ showNotes,
onChange,
onRemove,
}: {
contact: WorkOrderAdditionalContact;
index: number;
+ showNotes: boolean;
onChange: (patch: Partial) => void;
onRemove: () => void;
}) {
@@ -115,14 +119,16 @@ function AdditionalPocCard({