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..8cdac978 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 @@ -1,5 +1,6 @@ import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; +import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; @@ -14,6 +15,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 +29,7 @@ export function buildSiteDialogPatch({ extraContacts, contactsDirty, baselineHadContacts, + followsSiteRecord = false, }: BuildSiteDialogPatchArgs): SitePatch { const patch: SitePatch = { site: code || (selected ? resolveSiteCode(selected) : ""), @@ -35,8 +39,11 @@ export function buildSiteDialogPatch({ pocPhone, pocNotes, }; - if (contactsDirty || baselineHadContacts) { - patch.additionalContacts = extraContacts; + // 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) { + // The work order keeps only complete name/phone rows; site contact ids stay on the site request. + patch.additionalContacts = normalizeAdditionalContacts(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 -
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..806092b3 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,9 @@ 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)} + // Site contacts carry only a name and phone, so a per-contact note could never reach the site. + additionalContactNotes={!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..46854cb8 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, }); @@ -84,9 +85,10 @@ export function SiteDialog({ Select a site and edit its point of contact -
+ {/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */} +
- 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({
-