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 -
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx index bdccff61..9b81279d 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -8,12 +8,25 @@ type SiteDialogPocSectionProps = { additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; primaryRequired?: boolean; + /** Edits go to the site record rather than only this work order. */ + editsSiteRecord?: boolean; + /** The site record did not load, so Save writes this work order only. */ + siteRecordUnavailable?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void; }; +function helperText(editsSiteRecord: boolean, siteRecordUnavailable: boolean): string { + if (!editsSiteRecord) { + return "Auto-filled from site records when empty. Additional contacts persist on save."; + } + return siteRecordUnavailable + ? "Site record unavailable — changes apply to this work order only." + : "Contacts and notes are saved to the site record and apply to all its work orders."; +} + export function SiteDialogPocSection({ pocName, pocPhone, @@ -21,6 +34,8 @@ export function SiteDialogPocSection({ additionalContacts, showPrimaryErrors = false, primaryRequired = false, + editsSiteRecord = false, + siteRecordUnavailable = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -38,7 +53,7 @@ export function SiteDialogPocSection({ onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} onAdditionalContactsChange={onAdditionalContactsChange} - helperText="Auto-filled from site records when empty. Additional contacts persist on save." + helperText={helperText(editsSiteRecord, siteRecordUnavailable)} /> ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 06c5e6bb..6ee17752 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -58,6 +58,7 @@ export function SiteDialog({ additionalContacts, sites, createMode, + viewOnly, frozenSite, onSave, }); @@ -86,7 +87,7 @@ export function SiteDialog({
- Site + Site Site is required @@ -124,21 +125,36 @@ export function SiteDialog({ /> )} - + {/* Locked until the site record loads, so its values never replace typed input. */} +
+ +
- + ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts index 1b0a4f30..db1db3e7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = { createMode: boolean; }; +function sameContacts(a: WorkOrderAdditionalContact[], b: WorkOrderAdditionalContact[]): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} + +/** Keeps one array identity while the contacts are unchanged, so a fresh `[]` does not reset. */ +function useStableContacts(contacts: WorkOrderAdditionalContact[]): WorkOrderAdditionalContact[] { + const [stable, setStable] = useState(contacts); + if (!sameContacts(stable, contacts)) { + setStable(contacts); + return contacts; + } + return stable; +} + export function useSiteDialogFormFields({ open, value, @@ -23,10 +37,11 @@ export function useSiteDialogFormFields({ pocName, pocPhone, pocNotes, - additionalContacts, + additionalContacts: incomingContacts, sites, createMode, }: UseSiteDialogFormFieldsArgs) { + const additionalContacts = useStableContacts(incomingContacts); const [code, setCode] = useState(value); const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites)); const [pn, setPn] = useState(pocName); @@ -36,15 +51,20 @@ export function useSiteDialogFormFields({ const [pocFilledFor, setPocFilledFor] = useState(""); const [dirty, setDirty] = useState(false); const [contactsDirty, setContactsDirty] = useState(false); + // The user typed into the contact fields since the last reset or site switch. + const [userEdited, setUserEdited] = useState(false); const [baselineHadContacts, setBaselineHadContacts] = useState( () => additionalContacts.length > 0, ); const [showErrors, setShowErrors] = useState(false); + // Bumped whenever the fields are reset from the row, so site-record sync can re-apply. + const [resetVersion, setResetVersion] = useState(0); useEffect(() => { if (!open) { setDirty(false); setContactsDirty(false); + setUserEdited(false); setShowErrors(false); } }, [open]); @@ -62,6 +82,7 @@ export function useSiteDialogFormFields({ setExtraContacts(additionalContacts); setBaselineHadContacts(additionalContacts.length > 0); setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : ""); + setResetVersion((version) => version + 1); }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]); const handlePick = (id: string) => { @@ -74,6 +95,7 @@ export function useSiteDialogFormFields({ if (siteChanged) { setExtraContacts([]); setContactsDirty(true); + setUserEdited(false); setDirty(true); if (createMode) { setPn(""); @@ -87,6 +109,7 @@ export function useSiteDialogFormFields({ const markPocDirty = (apply: () => void) => { setDirty(true); + setUserEdited(true); apply(); }; @@ -100,6 +123,8 @@ export function useSiteDialogFormFields({ pocFilledFor, dirty, contactsDirty, + userEdited, + resetVersion, baselineHadContacts, showErrors, selected: sites.find((s) => String(s.id) === locId), @@ -108,6 +133,8 @@ export function useSiteDialogFormFields({ setPn, setPp, setShowErrors, + setNotes, + setExtraContacts, markDirty: () => setDirty(true), handlePick, setPnDirty: (next: string) => markPocDirty(() => setPn(next)), @@ -115,6 +142,7 @@ export function useSiteDialogFormFields({ setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)), setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => { setDirty(true); + setUserEdited(true); setContactsDirty(true); setExtraContacts(contacts); }, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts new file mode 100644 index 00000000..cf2fdfc9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save.ts @@ -0,0 +1,82 @@ +import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; +import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; +import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; +import { + workOrderPocAfterSiteSave, + type useSiteRecordSync, + type WorkOrderPoc, +} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; +import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +type UseSiteDialogSaveArgs = { + fields: ReturnType; + siteRecord: ReturnType; + /** The work order's values when the dialog opened. */ + original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] }; + createMode: boolean; + /** The first contact is required before Save (inline create, or editing the site record). */ + requiresPoc: boolean; + onSave: (patch: SitePatch) => void; + onOpenChange: (open: boolean) => void; +}; + +/** + * The Site dialog's Save: writes changed contacts and notes to the site record first when the + * dialog edits it, then patches the work order and closes. + */ +export function useSiteDialogSave({ + fields, + siteRecord, + original, + createMode, + requiresPoc, + onSave, + onOpenChange, +}: UseSiteDialogSaveArgs) { + const updateSite = useUpdateSiteContactInfo(); + const pocMissing = !fields.pn.trim() || !fields.pp.trim(); + const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing); + + const saveWorkOrder = () => { + const poc = workOrderPocAfterSiteSave( + siteRecord, + original, + { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, + fields.locId !== resolveLocationId(original.locationId, original.value, original.sites), + ); + onSave( + buildSiteDialogPatch({ + code: fields.code, + locId: fields.locId, + selected: fields.selected, + ...poc, + extraContacts: fields.extraContacts, + contactsDirty: fields.contactsDirty, + baselineHadContacts: fields.baselineHadContacts, + followsSiteRecord: siteRecord.synced, + }), + ); + onOpenChange(false); + }; + + const attemptSave = () => { + if (requiresPoc && !canConfirm) { + fields.setShowErrors(true); + return; + } + if (!siteRecord.siteChanged) { + saveWorkOrder(); + return; + } + updateSite.mutate({ id: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); + }; + + return { + attemptSave, + // Save is actionable only once something changed; inline create keeps its confirm step. + saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty), + saving: updateSite.isPending, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index 5800cbf4..6d53ac87 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -1,9 +1,11 @@ import { useEffect, useMemo } from "react"; -import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; +import { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save"; +import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row"; @@ -21,10 +23,25 @@ type UseSiteDialogStateArgs = { additionalContacts: WorkOrderAdditionalContact[]; sites: LocationOption[]; createMode: boolean; + viewOnly?: boolean; frozenSite?: WorkOrderFrozenSite; onSave: (patch: SitePatch) => void; }; +/** A completed work order's frozen snapshot, shaped like the live site detail. */ +function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location { + return { + name: frozenSite.label, + address: frozenSite.address, + city: frozenSite.city, + state: frozenSite.state, + zipCode: frozenSite.zip, + phone: frozenSite.phone, + contact: undefined, + contactEmail: frozenSite.email, + }; +} + export function useSiteDialogState({ open, onOpenChange, @@ -36,6 +53,7 @@ export function useSiteDialogState({ additionalContacts = [], sites, createMode, + viewOnly = false, frozenSite, onSave, }: UseSiteDialogStateArgs) { @@ -50,65 +68,51 @@ export function useSiteDialogState({ sites, createMode, }); - const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields; - const { - data: liveLocationDetail, - isLoading: liveLocationDetailLoading, - isError: liveLocationDetailError, - } = useLocationDetail(frozenSite == null && open && locId ? locId : undefined); + const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields; + const live = frozenSite == null; + const liveDetail = useLocationDetail(live && open && locId ? locId : undefined); const frozenLocationDetail = useMemo( - () => - frozenSite == null - ? undefined - : { - name: frozenSite.label, - address: frozenSite.address, - city: frozenSite.city, - state: frozenSite.state, - zipCode: frozenSite.zip, - phone: frozenSite.phone, - contact: undefined, - contactEmail: frozenSite.email, - }, + () => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)), [frozenSite], ); - const locationDetail = frozenLocationDetail ?? liveLocationDetail; - const locationDetailLoading = frozenSite == null && liveLocationDetailLoading; - const locationDetailError = frozenSite == null && liveLocationDetailError; + const locationDetail = frozenLocationDetail ?? liveDetail.data; + const locationDetailLoading = live && liveDetail.isLoading; + const locationDetailError = live && liveDetail.isError; const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; - const pocMissing = !pn.trim() || !pp.trim(); - const canConfirm = !siteMissing && (!createMode || !pocMissing); + // An existing, editable work order edits the site record itself (contacts and notes). + const editsSiteRecord = !createMode && !viewOnly && live; + const siteRecord = useSiteRecordSync({ + enabled: editsSiteRecord, + open, + locId, + locationDetail: liveDetail.data, + locationDetailFetching: liveDetail.isFetching, + locationDetailError: liveDetail.isError, + fields, + }); + // The site failed to load (or the user typed before it did), so Save writes this work order only. + const siteRecordUnavailable = + editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading; + const requiresPoc = createMode || editsSiteRecord; + const save = useSiteDialogSave({ + fields, + siteRecord, + original: { pocName, pocPhone, pocNotes, locationId, value, sites }, + createMode, + requiresPoc, + onSave, + onOpenChange, + }); useEffect(() => { - if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) { + if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) { return; } setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? ""))); setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? ""))); setPocFilledFor(locId); - }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); - - const attemptSave = () => { - if (createMode && !canConfirm) { - fields.setShowErrors(true); - return; - } - onSave( - buildSiteDialogPatch({ - code: fields.code, - locId: fields.locId, - selected: fields.selected, - pocName: fields.pn, - pocPhone: fields.pp, - pocNotes: fields.notes, - extraContacts: fields.extraContacts, - contactsDirty: fields.contactsDirty, - baselineHadContacts: fields.baselineHadContacts, - }), - ); - onOpenChange(false); - }; + }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]); return { locId: fields.locId, @@ -125,7 +129,13 @@ export function useSiteDialogState({ showErrors: fields.showErrors, siteMissing: fields.siteMissing, handlePick: fields.handlePick, - attemptSave, + attemptSave: save.attemptSave, + saveDisabled: save.saveDisabled, + saving: save.saving, + siteRecordLoading: siteRecord.loading, + editsSiteRecord, + siteRecordUnavailable, + requiresPoc, setPn: fields.setPnDirty, setPp: fields.setPpDirty, setNotes: fields.setNotesDirty, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts new file mode 100644 index 00000000..cdb45f3e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -0,0 +1,184 @@ +import { useEffect, useState } from "react"; +import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; + +type SiteRecordFields = { + pn: string; + pp: string; + notes: string; + extraContacts: WorkOrderAdditionalContact[]; + /** The user typed into the contact fields since the last reset or site switch. */ + userEdited: boolean; + resetVersion: number; + setPn: (value: string) => void; + setPp: (value: string) => void; + setNotes: (value: string) => void; + setExtraContacts: (contacts: WorkOrderAdditionalContact[]) => void; +}; + +type UseSiteRecordSyncArgs = { + /** Existing, editable work order: the dialog edits the site record itself. */ + enabled: boolean; + open: boolean; + locId: string; + locationDetail: Location | undefined; + /** A request for the site is in flight; cached data may predate the latest save. */ + locationDetailFetching: boolean; + locationDetailError: boolean; + fields: SiteRecordFields; +}; + +export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string }; + +type SiteRecordEdits = { primary: boolean; notes: boolean }; + +function isCompleteContact(contact: WorkOrderAdditionalContact): boolean { + return contact.name.trim() !== "" && contact.phone.trim() !== ""; +} + +function toRequest( + primaryId: number | undefined, + fields: Pick, +): SiteContactInfoInput { + return { + contacts: [ + { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp }, + // A half-filled extra is dropped here, the same as the work order's own copy drops it. + ...fields.extraContacts.filter(isCompleteContact).map((contact) => ({ + ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }), + name: contact.name, + phone: contact.phone, + })), + ], + notes: fields.notes, + }; +} + +function fingerprint(request: SiteContactInfoInput): string { + return JSON.stringify({ + contacts: request.contacts.map((contact) => [contact.name.trim(), contact.phone.trim()]), + notes: request.notes.trim(), + }); +} + +function diffEdits( + baseline: SiteContactInfoInput | null, + request: SiteContactInfoInput, +): SiteRecordEdits { + if (baseline === null) { + return { primary: false, notes: false }; + } + const before = baseline.contacts[0]; + const after = request.contacts[0]; + return { + primary: before.name.trim() !== after.name.trim() || before.phone.trim() !== after.phone.trim(), + notes: baseline.notes.trim() !== request.notes.trim(), + }; +} + +/** + * The work order's own POC values once the site record owns them: a field follows the site only + * when the user edited it (or picked another site); untouched fields keep the work order's values. + */ +export function workOrderPocAfterSiteSave( + siteRecord: { synced: boolean; edits: SiteRecordEdits }, + original: WorkOrderPoc, + current: WorkOrderPoc, + siteSwitched: boolean, +): WorkOrderPoc { + if (!siteRecord.synced) { + return current; + } + const primary = siteSwitched || siteRecord.edits.primary; + const notes = siteSwitched || siteRecord.edits.notes; + return { + pocName: primary ? current.pocName : original.pocName, + pocPhone: primary ? current.pocPhone : original.pocPhone, + // Notes live on the site record; an empty override lets the site's notes show through. + pocNotes: notes ? "" : original.pocNotes, + }; +} + +/** The site record's contacts and notes, in the dialog's field shape. */ +function siteRecordValues(location: Location) { + const [main, ...others] = location.contacts ?? []; + return { + primaryId: main?.id, + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes: location.notes ?? "", + extraContacts: others.map((contact) => ({ + name: contact.name, + phone: contact.phone, + ...(contact.id === undefined ? {} : { siteContactId: contact.id }), + })), + }; +} + +/** + * Loads the selected site's contacts and notes into the Site dialog and reports what the user + * changed, so Save can write them back to the site record. + */ +export function useSiteRecordSync({ + enabled, + open, + locId, + locationDetail, + locationDetailFetching, + locationDetailError, + fields, +}: UseSiteRecordSyncArgs) { + const [syncedFor, setSyncedFor] = useState(""); + const [primaryId, setPrimaryId] = useState(undefined); + const [baseline, setBaseline] = useState(null); + const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + + // Picking another site clears its contacts, so coming back to a site must load it again. + useEffect(() => { + setSyncedFor(""); + }, [open, resetVersion, locId]); + + useEffect(() => { + const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; + // Cached site data can predate a save made since, so only a settled request is synced. + const detailCurrent = detailMatches && !locationDetailFetching; + // Input typed while the site was unavailable (a failed load) is never replaced by a late + // response: the dialog stays on the work order's values and saves them to the work order only. + if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) { + return; + } + const site = siteRecordValues(locationDetail); + setPn(site.pn); + setPp(site.pp); + setExtraContacts(site.extraContacts); + setNotes(site.notes); + setPrimaryId(site.primaryId); + setBaseline(toRequest(site.primaryId, site)); + setSyncedFor(locId); + }, [ + enabled, + open, + locId, + locationDetail, + locationDetailFetching, + syncedFor, + userEdited, + setPn, + setPp, + setNotes, + setExtraContacts, + ]); + + const request = toRequest(primaryId, fields); + const synced = enabled && syncedFor !== "" && syncedFor === locId; + + return { + synced, + // Until the site loads the fields do not reflect it; a failed load falls back to the work order, + // which it keeps once the user has typed, even if a later refetch succeeds. + loading: enabled && Boolean(locId) && !synced && !locationDetailError && !userEdited, + siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline), + edits: diffEdits(synced ? baseline : null, request), + request, + }; +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 728980e8..fc1a5f60 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -1,8 +1,9 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapLocation, + mapContactsToBackend, mapLocationListItems, mapLocationSites, mapLocationToBackend, @@ -11,6 +12,7 @@ import { import type { Location, LocationSite, + SiteContactInfoInput, SiteOpenWorkOrders, LocationsListParams, LocationsListResult, @@ -73,6 +75,14 @@ export const locationsApi = { return mapLocation(handleApiResponse(data)); }, + /** Saves the site's ordered contacts and notes (work-order Site dialog). */ + updateContactInfo: async (id: string | number, input: SiteContactInfoInput): Promise => { + await apiPatch(`${API_PATHS.rest.locations}/${id}/contact-info`, { + contacts: mapContactsToBackend(input.contacts), + notes: input.notes, + }); + }, + delete: async (id: string | number): Promise => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 16829026..d9d30fbb 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -70,6 +70,12 @@ export interface LocationsListResult { pageSize: number; } +/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */ +export interface SiteContactInfoInput { + contacts: LocationContact[]; + notes: string; +} + /** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */ export interface SiteOpenWorkOrders { count: number; diff --git a/src/domain/locations/use-cases/use-update-site-contact-info.ts b/src/domain/locations/use-cases/use-update-site-contact-info.ts new file mode 100644 index 00000000..4f218a42 --- /dev/null +++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { SiteContactInfoInput } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateSiteContactInfoInput extends SiteContactInfoInput { + id: string | number; +} + +/** Writes contacts and notes to the site record, then refetches sites and work orders. */ +export function useUpdateSiteContactInfo(): UseMutationResult< + void, + Error, + UpdateSiteContactInfoInput +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) => + locationsApi.updateContactInfo(id, { contacts, notes }), + onSuccess: async () => { + // Other work orders at this site show its contacts and notes too. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }), + ]); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update the site"); + }, + }); +} diff --git a/src/domain/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-resync.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx new file mode 100644 index 00000000..12be55f2 --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record-resync.test.tsx @@ -0,0 +1,190 @@ +import { QueryClient } from "@tanstack/react-query"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import type { Location } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const getById = vi.fn(); +const updateContactInfo = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getById: (...args: unknown[]) => getById(...args), + updateContactInfo: (...args: unknown[]) => updateContactInfo(...args), + }, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const DALLAS_CONTACTS = [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, +]; + +const SITES = [ + { id: "1", name: "DAL1" }, + { id: "2", name: "CHI2" }, +]; + +const SITE_RECORD_COPY = + "Contacts and notes are saved to the site record and apply to all its work orders."; +const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; + +function renderDialog(queryClient?: QueryClient) { + const onSave = vi.fn(); + renderWithProviders( + , + { withAuth: false, ...(queryClient === undefined ? {} : { queryClient }) }, + ); + return { onSave }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +/** Opens the Site picker and chooses a site by its label. */ +function pickSite(label: string) { + const trigger = document.querySelector('[aria-haspopup="listbox"]'); + if (trigger === null) throw new Error("expected the Site picker"); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("button", { name: label })); +} + +function editNotesAndSave(notes: string) { + fireEvent.change(primaryField("Notes…"), { target: { value: notes } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); +} + +describe("Work order Site dialog keeps the site record in sync", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + updateContactInfo.mockResolvedValue(undefined); + }); + + it("re-syncs a site's extra contacts after switching away and back while the other site loads", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : new Promise(() => {}), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + await waitFor(() => expect(getById).toHaveBeenCalledWith("2")); + expect(screen.queryByDisplayValue("Bob")).not.toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("re-syncs a site's extra contacts after switching away and back when the other site failed to load", async () => { + getById.mockImplementation((id: string) => + String(id) === "1" ? Promise.resolve(dallas) : Promise.reject(new Error("Network error")), + ); + renderDialog(); + expect(await screen.findByDisplayValue("Bob")).toBeInTheDocument(); + + pickSite("CHI2"); + expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + pickSite("DAL1"); + + expect(await screen.findByDisplayValue("Bob")).toBeEnabled(); + expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); + + it("waits for the refetch instead of syncing a cached site record, so a save never reverts it", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + // What the cache still holds from before an earlier Save changed the site. + queryClient.setQueryData(queryKeys.locations.detail("1"), dallas); + const saved: Location = { + ...dallas, + notes: "Saved gate code", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-9999" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }; + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(queryClient); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC phone")).toBeDisabled(); + expect(screen.queryByDisplayValue("(421) 433-0032")).not.toBeInTheDocument(); + resolveSite(saved); + + expect(await screen.findByDisplayValue("(421) 433-9999")).toBeEnabled(); + expect(primaryField("Notes…")).toHaveValue("Saved gate code"); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: saved.contacts, + notes: "Gate code 9999", + }); + }); + + it("leaves a half-filled extra contact off the site record, as the work order does", async () => { + getById.mockResolvedValue(dallas); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Bob"); + + fireEvent.click(screen.getByRole("button", { name: /Add point of contact/ })); + const names = screen.getAllByPlaceholderText("POC name"); + fireEvent.change(names[names.length - 1], { target: { value: "Name Only" } }); + editNotesAndSave("Gate code 9999"); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: DALLAS_CONTACTS, + notes: "Gate code 9999", + }); + }); +}); diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx new file mode 100644 index 00000000..121e7dbd --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -0,0 +1,310 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import type { Location } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const getById = vi.fn(); +const updateContactInfo = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getById: (...args: unknown[]) => getById(...args), + updateContactInfo: (...args: unknown[]) => updateContactInfo(...args), + }, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + phone: "(421) 433-0032", + contactEmail: "dal1@acme.test", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const sites = [{ id: "1", name: "DAL1" }]; + +const SITE_RECORD_COPY = + "Contacts and notes are saved to the site record and apply to all its work orders."; +const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only."; + +function renderDialog(overrides: Partial[0]> = {}) { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + const { queryClient } = renderWithProviders( + , + { withAuth: false }, + ); + return { onSave, onOpenChange, queryClient }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +function saveButton(): HTMLElement { + return screen.getByRole("button", { name: /^save$/i }); +} + +describe("Work order Site dialog edits the site record", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + getById.mockResolvedValue(dallas); + }); + + it("shows the site's contacts, notes, linked address and map preview", async () => { + renderDialog(); + + expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument(); + expect(screen.getByDisplayValue("(421) 433-0032")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Bob")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Gate code 1234")).toBeInTheDocument(); + const address = screen.getByRole("link", { name: /3811 Distribution Dr/ }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(screen.getByTitle("Site location map")).toBeInTheDocument(); + expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); + expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); + expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_UNAVAILABLE_COPY)).not.toBeInTheDocument(); + }); + + it("locks the contact fields until the site loads, so typed input is never replaced", async () => { + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC name")).toBeDisabled(); + expect(primaryField("POC phone")).toBeDisabled(); + expect(primaryField("Notes…")).toBeDisabled(); + expect(saveButton()).toBeDisabled(); + + resolveSite(dallas); + + expect(await screen.findByDisplayValue("Jane")).toBeEnabled(); + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + expect(primaryField("POC phone")).toHaveValue("(421) 433-9999"); + expect(saveButton()).toBeEnabled(); + }); + + it("keeps a POC typed after a failed site load when a later refetch succeeds", async () => { + getById.mockRejectedValueOnce(new Error("Network error")); + updateContactInfo.mockResolvedValue(undefined); + const { onSave, queryClient } = renderDialog(); + + await waitFor(() => expect(primaryField("POC name")).toBeEnabled()); + expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument(); + fireEvent.change(primaryField("POC name"), { target: { value: "John" } }); + + await queryClient.refetchQueries({ queryKey: queryKeys.locations.detail("1") }); + await waitFor(() => expect(getById).toHaveBeenCalledTimes(2)); + await screen.findByText("(214) 555-0100"); + + // The typed input stays on the work order, so the copy still says so after the refetch. + expect(screen.getByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SITE_RECORD_COPY)).not.toBeInTheDocument(); + expect(primaryField("POC name")).toHaveValue("John"); + expect(primaryField("POC name")).toBeEnabled(); + expect(primaryField("POC phone")).toHaveValue("(000) 000-0000"); + expect(screen.queryByDisplayValue("Jane")).not.toBeInTheDocument(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "John", + pocPhone: "(000) 000-0000", + pocNotes: "Old work order note", + }); + // The site's notes and other contacts were never shown, so they are not written back. + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + + it("keeps Save disabled until something changes", async () => { + renderDialog(); + await screen.findByDisplayValue("Jane"); + + expect(saveButton()).toBeDisabled(); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + expect(saveButton()).toBeEnabled(); + }); + + it("saves contact and notes edits to the site, then points the work order at it", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave, onOpenChange } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { + target: { value: "(421) 433-9999" }, + }); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + fireEvent.change(screen.getAllByPlaceholderText("POC phone")[1], { + target: { value: "(421) 433-7777" }, + }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-9999" }, + { id: 32, name: "Bob", phone: "(421) 433-7777" }, + ], + notes: "Gate code 9999", + }); + expect(updateContactInfo.mock.invocationCallOrder[0]).toBeLessThan( + onSave.mock.invocationCallOrder[0], + ); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + locationId: "1", + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "", + additionalContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }], + }), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("a phone-only edit keeps the work order's own notes and extra contacts", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog({ + additionalContacts: [{ name: "Night guard", phone: "(555) 000-1111" }], + }); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith( + "1", + expect.objectContaining({ notes: "Gate code 1234" }), + ); + const patch = onSave.mock.calls[0][0]; + expect(patch).toMatchObject({ + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "Old work order note", + }); + expect(patch).not.toHaveProperty("additionalContacts"); + }); + + it("a notes-only edit keeps the work order's own contact override", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "Someone Else", + pocPhone: "(000) 000-0000", + pocNotes: "", + }); + }); + + it("keeps the dialog open and the work order untouched when the site update fails", async () => { + updateContactInfo.mockRejectedValue(new Error("Forbidden")); + const { onSave, onOpenChange } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(saveButton()).toBeEnabled()); + expect(onSave).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("requires the first contact before saving", async () => { + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC name"), { target: { value: "" } }); + fireEvent.click(saveButton()); + + expect(await screen.findByText(/name is required/i)).toBeInTheDocument(); + expect(updateContactInfo).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("a completed work order shows its frozen snapshot read-only, without a site lookup", () => { + renderDialog({ + viewOnly: true, + frozenSite: { + code: "DAL1", + label: "DAL1", + address: "1 Old Rd", + city: "Dallas", + state: "TX", + zip: "75201", + }, + }); + + expect(screen.getByRole("link", { name: /1 Old Rd/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("Someone Else")).toBeDisabled(); + expect(getById).not.toHaveBeenCalled(); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + + it("inline create keeps the work-order-only path and never writes the site", async () => { + const { onSave } = renderDialog({ + createMode: true, + pocName: "Temp", + pocPhone: "(111) 111-1111", + pocNotes: "", + }); + await waitFor(() => expect(getById).toHaveBeenCalled()); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Bring ladder" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pocName: "Temp", pocNotes: "Bring ladder" }), + ); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx index d527414d..b868415a 100644 --- a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx @@ -19,6 +19,10 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ }, })); +vi.mock("@/domain/locations/use-cases/use-update-site-contact-info", () => ({ + useUpdateSiteContactInfo: () => ({ mutate: vi.fn(), isPending: false }), +})); + const SITES: LocationOption[] = [ { id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" }, { id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },