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..684d4800 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -8,6 +8,8 @@ type SiteDialogPocSectionProps = { additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; primaryRequired?: boolean; + /** Edits go to the site record rather than only this work order. */ + editsSiteRecord?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -21,6 +23,7 @@ export function SiteDialogPocSection({ additionalContacts, showPrimaryErrors = false, primaryRequired = false, + editsSiteRecord = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -38,7 +41,11 @@ export function SiteDialogPocSection({ onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} onAdditionalContactsChange={onAdditionalContactsChange} - helperText="Auto-filled from site records when empty. Additional contacts persist on save." + helperText={ + editsSiteRecord + ? "Contacts and notes are saved to the site record and apply to all its work orders." + : "Auto-filled from site records when empty. Additional contacts persist on save." + } /> ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 06c5e6bb..2187a33a 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -58,6 +58,7 @@ export function SiteDialog({ additionalContacts, sites, createMode, + viewOnly, frozenSite, onSave, }); @@ -86,7 +87,7 @@ export function SiteDialog({
- Site + Site Site is required @@ -124,21 +125,35 @@ export function SiteDialog({ /> )} - + {/* Locked until the site record loads, so its values never replace typed input. */} +
+ +
- + ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts index 1b0a4f30..db1db3e7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = { createMode: boolean; }; +function sameContacts(a: WorkOrderAdditionalContact[], b: WorkOrderAdditionalContact[]): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} + +/** Keeps one array identity while the contacts are unchanged, so a fresh `[]` does not reset. */ +function useStableContacts(contacts: WorkOrderAdditionalContact[]): WorkOrderAdditionalContact[] { + const [stable, setStable] = useState(contacts); + if (!sameContacts(stable, contacts)) { + setStable(contacts); + return contacts; + } + return stable; +} + export function useSiteDialogFormFields({ open, value, @@ -23,10 +37,11 @@ export function useSiteDialogFormFields({ pocName, pocPhone, pocNotes, - additionalContacts, + additionalContacts: incomingContacts, sites, createMode, }: UseSiteDialogFormFieldsArgs) { + const additionalContacts = useStableContacts(incomingContacts); const [code, setCode] = useState(value); const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites)); const [pn, setPn] = useState(pocName); @@ -36,15 +51,20 @@ export function useSiteDialogFormFields({ const [pocFilledFor, setPocFilledFor] = useState(""); const [dirty, setDirty] = useState(false); const [contactsDirty, setContactsDirty] = useState(false); + // The user typed into the contact fields since the last reset or site switch. + const [userEdited, setUserEdited] = useState(false); const [baselineHadContacts, setBaselineHadContacts] = useState( () => additionalContacts.length > 0, ); const [showErrors, setShowErrors] = useState(false); + // Bumped whenever the fields are reset from the row, so site-record sync can re-apply. + const [resetVersion, setResetVersion] = useState(0); useEffect(() => { if (!open) { setDirty(false); setContactsDirty(false); + setUserEdited(false); setShowErrors(false); } }, [open]); @@ -62,6 +82,7 @@ export function useSiteDialogFormFields({ setExtraContacts(additionalContacts); setBaselineHadContacts(additionalContacts.length > 0); setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : ""); + setResetVersion((version) => version + 1); }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]); const handlePick = (id: string) => { @@ -74,6 +95,7 @@ export function useSiteDialogFormFields({ if (siteChanged) { setExtraContacts([]); setContactsDirty(true); + setUserEdited(false); setDirty(true); if (createMode) { setPn(""); @@ -87,6 +109,7 @@ export function useSiteDialogFormFields({ const markPocDirty = (apply: () => void) => { setDirty(true); + setUserEdited(true); apply(); }; @@ -100,6 +123,8 @@ export function useSiteDialogFormFields({ pocFilledFor, dirty, contactsDirty, + userEdited, + resetVersion, baselineHadContacts, showErrors, selected: sites.find((s) => String(s.id) === locId), @@ -108,6 +133,8 @@ export function useSiteDialogFormFields({ setPn, setPp, setShowErrors, + setNotes, + setExtraContacts, markDirty: () => setDirty(true), handlePick, setPnDirty: (next: string) => markPocDirty(() => setPn(next)), @@ -115,6 +142,7 @@ export function useSiteDialogFormFields({ setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)), setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => { setDirty(true); + setUserEdited(true); setContactsDirty(true); setExtraContacts(contacts); }, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index 5800cbf4..e08a184e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -2,6 +2,12 @@ import { useEffect, useMemo } from "react"; import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch"; import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types"; import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields"; +import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import { + useSiteRecordSync, + workOrderPocAfterSiteSave, +} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync"; +import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; @@ -21,6 +27,7 @@ type UseSiteDialogStateArgs = { additionalContacts: WorkOrderAdditionalContact[]; sites: LocationOption[]; createMode: boolean; + viewOnly?: boolean; frozenSite?: WorkOrderFrozenSite; onSave: (patch: SitePatch) => void; }; @@ -36,6 +43,7 @@ export function useSiteDialogState({ additionalContacts = [], sites, createMode, + viewOnly = false, frozenSite, onSave, }: UseSiteDialogStateArgs) { @@ -77,8 +85,20 @@ export function useSiteDialogState({ const locationDetailError = frozenSite == null && liveLocationDetailError; const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; + // An existing, editable work order edits the site record itself (contacts and notes). + const editsSiteRecord = !createMode && !viewOnly && frozenSite == null; + const siteRecord = useSiteRecordSync({ + enabled: editsSiteRecord, + open, + locId, + locationDetail: liveLocationDetail, + locationDetailError: liveLocationDetailError, + fields, + }); + const updateSite = useUpdateSiteContactInfo(); const pocMissing = !pn.trim() || !pp.trim(); - const canConfirm = !siteMissing && (!createMode || !pocMissing); + const requiresPoc = createMode || editsSiteRecord; + const canConfirm = !siteMissing && (!requiresPoc || !pocMissing); useEffect(() => { if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) { @@ -89,27 +109,43 @@ export function useSiteDialogState({ setPocFilledFor(locId); }, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]); - const attemptSave = () => { - if (createMode && !canConfirm) { - fields.setShowErrors(true); - return; - } + const saveWorkOrder = () => { + const poc = workOrderPocAfterSiteSave( + siteRecord, + { pocName, pocPhone, pocNotes }, + { pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes }, + fields.locId !== resolveLocationId(locationId, value, sites), + ); onSave( buildSiteDialogPatch({ code: fields.code, locId: fields.locId, selected: fields.selected, - pocName: fields.pn, - pocPhone: fields.pp, - pocNotes: fields.notes, + ...poc, extraContacts: fields.extraContacts, contactsDirty: fields.contactsDirty, baselineHadContacts: fields.baselineHadContacts, + followsSiteRecord: siteRecord.synced, }), ); onOpenChange(false); }; + const attemptSave = () => { + if (requiresPoc && !canConfirm) { + fields.setShowErrors(true); + return; + } + if (!siteRecord.siteChanged) { + saveWorkOrder(); + return; + } + updateSite.mutate({ id: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder }); + }; + + // Save is actionable only once something changed; inline create keeps its confirm step. + const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty); + return { locId: fields.locId, code: fields.code, @@ -126,6 +162,11 @@ export function useSiteDialogState({ siteMissing: fields.siteMissing, handlePick: fields.handlePick, attemptSave, + saveDisabled, + saving: updateSite.isPending, + siteRecordLoading: siteRecord.loading, + editsSiteRecord, + requiresPoc, setPn: fields.setPnDirty, setPp: fields.setPpDirty, setNotes: fields.setNotesDirty, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts new file mode 100644 index 00000000..33b09eb0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync.ts @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; + +type SiteRecordFields = { + pn: string; + pp: string; + notes: string; + extraContacts: WorkOrderAdditionalContact[]; + /** The user typed into the contact fields since the last reset or site switch. */ + userEdited: boolean; + resetVersion: number; + setPn: (value: string) => void; + setPp: (value: string) => void; + setNotes: (value: string) => void; + setExtraContacts: (contacts: WorkOrderAdditionalContact[]) => void; +}; + +type UseSiteRecordSyncArgs = { + /** Existing, editable work order: the dialog edits the site record itself. */ + enabled: boolean; + open: boolean; + locId: string; + locationDetail: Location | undefined; + locationDetailError: boolean; + fields: SiteRecordFields; +}; + +export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string }; + +type SiteRecordEdits = { primary: boolean; notes: boolean }; + +function toRequest( + primaryId: number | undefined, + fields: Pick, +): SiteContactInfoInput { + return { + contacts: [ + { ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp }, + ...fields.extraContacts.map((contact) => ({ + ...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }), + name: contact.name, + phone: contact.phone, + })), + ], + notes: fields.notes, + }; +} + +function fingerprint(request: SiteContactInfoInput): string { + return JSON.stringify({ + contacts: request.contacts.map((contact) => [contact.name.trim(), contact.phone.trim()]), + notes: request.notes.trim(), + }); +} + +function diffEdits( + baseline: SiteContactInfoInput | null, + request: SiteContactInfoInput, +): SiteRecordEdits { + if (baseline === null) { + return { primary: false, notes: false }; + } + const before = baseline.contacts[0]; + const after = request.contacts[0]; + return { + primary: before.name.trim() !== after.name.trim() || before.phone.trim() !== after.phone.trim(), + notes: baseline.notes.trim() !== request.notes.trim(), + }; +} + +/** + * The work order's own POC values once the site record owns them: a field follows the site only + * when the user edited it (or picked another site); untouched fields keep the work order's values. + */ +export function workOrderPocAfterSiteSave( + siteRecord: { synced: boolean; edits: SiteRecordEdits }, + original: WorkOrderPoc, + current: WorkOrderPoc, + siteSwitched: boolean, +): WorkOrderPoc { + if (!siteRecord.synced) { + return current; + } + const primary = siteSwitched || siteRecord.edits.primary; + const notes = siteSwitched || siteRecord.edits.notes; + return { + pocName: primary ? current.pocName : original.pocName, + pocPhone: primary ? current.pocPhone : original.pocPhone, + // Notes live on the site record; an empty override lets the site's notes show through. + pocNotes: notes ? "" : original.pocNotes, + }; +} + +/** + * Loads the selected site's contacts and notes into the Site dialog and reports what the user + * changed, so Save can write them back to the site record. + */ +export function useSiteRecordSync({ + enabled, + open, + locId, + locationDetail, + locationDetailError, + fields, +}: UseSiteRecordSyncArgs) { + const [syncedFor, setSyncedFor] = useState(""); + const [primaryId, setPrimaryId] = useState(undefined); + const [baseline, setBaseline] = useState(null); + const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields; + + useEffect(() => { + setSyncedFor(""); + }, [open, resetVersion]); + + useEffect(() => { + const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId; + // Input typed while the site was unavailable (a failed load) is never replaced by a late + // response: the dialog stays on the work order's values and saves them to the work order only. + if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) { + return; + } + const [main, ...others] = locationDetail.contacts ?? []; + const extras = others.map((contact) => ({ + name: contact.name, + phone: contact.phone, + ...(contact.id === undefined ? {} : { siteContactId: contact.id }), + })); + const notes = locationDetail.notes ?? ""; + setPn(main?.name ?? ""); + setPp(main?.phone ?? ""); + setExtraContacts(extras); + setNotes(notes); + setPrimaryId(main?.id); + setBaseline( + toRequest(main?.id, { + pn: main?.name ?? "", + pp: main?.phone ?? "", + notes, + extraContacts: extras, + }), + ); + setSyncedFor(locId); + }, [ + enabled, + open, + locId, + locationDetail, + syncedFor, + userEdited, + setPn, + setPp, + setNotes, + setExtraContacts, + ]); + + const request = toRequest(primaryId, fields); + const synced = enabled && syncedFor !== "" && syncedFor === locId; + + return { + synced, + // Until the site loads the fields do not reflect it; a failed load falls back to the work order, + // which it keeps once the user has typed, even if a later refetch succeeds. + loading: enabled && Boolean(locId) && !synced && !locationDetailError && !userEdited, + siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline), + edits: diffEdits(synced ? baseline : null, request), + request, + }; +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 2f9f1376..3c122e5a 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -1,8 +1,9 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapLocation, + mapContactsToBackend, mapLocationListItems, mapLocationSites, mapLocationToBackend, @@ -10,6 +11,7 @@ import { import type { Location, LocationSite, + SiteContactInfoInput, LocationsListParams, LocationsListResult, } from "@/domain/locations/types/location"; @@ -71,6 +73,14 @@ export const locationsApi = { return mapLocation(handleApiResponse(data)); }, + /** Saves the site's ordered contacts and notes (work-order Site dialog). */ + updateContactInfo: async (id: string | number, input: SiteContactInfoInput): Promise => { + await apiPatch(`${API_PATHS.rest.locations}/${id}/contact-info`, { + contacts: mapContactsToBackend(input.contacts), + notes: input.notes, + }); + }, + delete: async (id: string | number): Promise => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 77cadb17..b4ba7dc7 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -69,3 +69,9 @@ export interface LocationsListResult { page: number; pageSize: number; } + +/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */ +export interface SiteContactInfoInput { + contacts: LocationContact[]; + notes: string; +} diff --git a/src/domain/locations/use-cases/use-update-site-contact-info.ts b/src/domain/locations/use-cases/use-update-site-contact-info.ts new file mode 100644 index 00000000..4f218a42 --- /dev/null +++ b/src/domain/locations/use-cases/use-update-site-contact-info.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { SiteContactInfoInput } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; + +interface UpdateSiteContactInfoInput extends SiteContactInfoInput { + id: string | number; +} + +/** Writes contacts and notes to the site record, then refetches sites and work orders. */ +export function useUpdateSiteContactInfo(): UseMutationResult< + void, + Error, + UpdateSiteContactInfoInput +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) => + locationsApi.updateContactInfo(id, { contacts, notes }), + onSuccess: async () => { + // Other work orders at this site show its contacts and notes too. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }), + ]); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update the site"); + }, + }); +} diff --git a/src/domain/work-orders/types/work-order-additional-contact.ts b/src/domain/work-orders/types/work-order-additional-contact.ts index dd5d7abf..24c1d43d 100644 --- a/src/domain/work-orders/types/work-order-additional-contact.ts +++ b/src/domain/work-orders/types/work-order-additional-contact.ts @@ -2,6 +2,8 @@ export interface WorkOrderAdditionalContact { name: string; phone: string; notes?: string; + /** Site contact row this entry was loaded from (Site dialog); never sent on work-order patches. */ + siteContactId?: number; } export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = { diff --git a/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx new file mode 100644 index 00000000..79dfb6a0 --- /dev/null +++ b/src/test/app/(protected)/workorders/site-dialog-site-record.test.tsx @@ -0,0 +1,299 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import type { Location } from "@/domain/locations/types/location"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const getById = vi.fn(); +const updateContactInfo = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getById: (...args: unknown[]) => getById(...args), + updateContactInfo: (...args: unknown[]) => updateContactInfo(...args), + }, +})); + +const dallas: Location = { + id: 1, + name: "DAL1", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + phone: "(421) 433-0032", + contactEmail: "dal1@acme.test", + notes: "Gate code 1234", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const sites = [{ id: "1", name: "DAL1" }]; + +function renderDialog(overrides: Partial[0]> = {}) { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + const { queryClient } = renderWithProviders( + , + { withAuth: false }, + ); + return { onSave, onOpenChange, queryClient }; +} + +/** The main contact's field; additional contact rows reuse the same placeholders after it. */ +function primaryField(placeholder: string): HTMLElement { + return screen.getAllByPlaceholderText(placeholder)[0]; +} + +function saveButton(): HTMLElement { + return screen.getByRole("button", { name: /^save$/i }); +} + +describe("Work order Site dialog edits the site record", () => { + beforeEach(() => { + getById.mockReset(); + updateContactInfo.mockReset(); + getById.mockResolvedValue(dallas); + }); + + it("shows the site's contacts, notes, linked address and map preview", async () => { + renderDialog(); + + expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument(); + expect(screen.getByDisplayValue("(421) 433-0032")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Bob")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Gate code 1234")).toBeInTheDocument(); + const address = screen.getByRole("link", { name: /3811 Distribution Dr/ }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(screen.getByTitle("Site location map")).toBeInTheDocument(); + expect(screen.getByText("(214) 555-0100")).toBeInTheDocument(); + expect(screen.getByText("dal1@acme.test")).toBeInTheDocument(); + }); + + it("locks the contact fields until the site loads, so typed input is never replaced", async () => { + let resolveSite: (site: Location) => void = () => {}; + getById.mockReturnValue( + new Promise((resolve) => { + resolveSite = resolve; + }), + ); + renderDialog(); + + await waitFor(() => expect(getById).toHaveBeenCalled()); + expect(primaryField("POC name")).toBeDisabled(); + expect(primaryField("POC phone")).toBeDisabled(); + expect(primaryField("Notes…")).toBeDisabled(); + expect(saveButton()).toBeDisabled(); + + resolveSite(dallas); + + expect(await screen.findByDisplayValue("Jane")).toBeEnabled(); + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + expect(primaryField("POC phone")).toHaveValue("(421) 433-9999"); + expect(saveButton()).toBeEnabled(); + }); + + it("keeps a POC typed after a failed site load when a later refetch succeeds", async () => { + getById.mockRejectedValueOnce(new Error("Network error")); + updateContactInfo.mockResolvedValue(undefined); + const { onSave, queryClient } = renderDialog(); + + await waitFor(() => expect(primaryField("POC name")).toBeEnabled()); + fireEvent.change(primaryField("POC name"), { target: { value: "John" } }); + + await queryClient.refetchQueries({ queryKey: queryKeys.locations.detail("1") }); + await waitFor(() => expect(getById).toHaveBeenCalledTimes(2)); + await screen.findByText("(214) 555-0100"); + + expect(primaryField("POC name")).toHaveValue("John"); + expect(primaryField("POC name")).toBeEnabled(); + expect(primaryField("POC phone")).toHaveValue("(000) 000-0000"); + expect(screen.queryByDisplayValue("Jane")).not.toBeInTheDocument(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "John", + pocPhone: "(000) 000-0000", + pocNotes: "Old work order note", + }); + // The site's notes and other contacts were never shown, so they are not written back. + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + + it("keeps Save disabled until something changes", async () => { + renderDialog(); + await screen.findByDisplayValue("Jane"); + + expect(saveButton()).toBeDisabled(); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + expect(saveButton()).toBeEnabled(); + }); + + it("saves contact and notes edits to the site, then points the work order at it", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave, onOpenChange } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { + target: { value: "(421) 433-9999" }, + }); + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + fireEvent.change(screen.getAllByPlaceholderText("POC phone")[1], { + target: { value: "(421) 433-7777" }, + }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith("1", { + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-9999" }, + { id: 32, name: "Bob", phone: "(421) 433-7777" }, + ], + notes: "Gate code 9999", + }); + expect(updateContactInfo.mock.invocationCallOrder[0]).toBeLessThan( + onSave.mock.invocationCallOrder[0], + ); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + locationId: "1", + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "", + additionalContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }], + }), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("a phone-only edit keeps the work order's own notes and extra contacts", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog({ + additionalContacts: [{ name: "Night guard", phone: "(555) 000-1111" }], + }); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC phone"), { target: { value: "(421) 433-9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(updateContactInfo).toHaveBeenCalledWith( + "1", + expect.objectContaining({ notes: "Gate code 1234" }), + ); + const patch = onSave.mock.calls[0][0]; + expect(patch).toMatchObject({ + pocName: "Jane", + pocPhone: "(421) 433-9999", + pocNotes: "Old work order note", + }); + expect(patch).not.toHaveProperty("additionalContacts"); + }); + + it("a notes-only edit keeps the work order's own contact override", async () => { + updateContactInfo.mockResolvedValue(undefined); + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pocName: "Someone Else", + pocPhone: "(000) 000-0000", + pocNotes: "", + }); + }); + + it("keeps the dialog open and the work order untouched when the site update fails", async () => { + updateContactInfo.mockRejectedValue(new Error("Forbidden")); + const { onSave, onOpenChange } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("Notes…"), { + target: { value: "Gate code 9999" }, + }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(updateContactInfo).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(saveButton()).toBeEnabled()); + expect(onSave).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("requires the first contact before saving", async () => { + const { onSave } = renderDialog(); + await screen.findByDisplayValue("Jane"); + + fireEvent.change(primaryField("POC name"), { target: { value: "" } }); + fireEvent.click(saveButton()); + + expect(await screen.findByText(/name is required/i)).toBeInTheDocument(); + expect(updateContactInfo).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("a completed work order shows its frozen snapshot read-only, without a site lookup", () => { + renderDialog({ + viewOnly: true, + frozenSite: { + code: "DAL1", + label: "DAL1", + address: "1 Old Rd", + city: "Dallas", + state: "TX", + zip: "75201", + }, + }); + + expect(screen.getByRole("link", { name: /1 Old Rd/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("Someone Else")).toBeDisabled(); + expect(getById).not.toHaveBeenCalled(); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); + + it("inline create keeps the work-order-only path and never writes the site", async () => { + const { onSave } = renderDialog({ + createMode: true, + pocName: "Temp", + pocPhone: "(111) 111-1111", + pocNotes: "", + }); + await waitFor(() => expect(getById).toHaveBeenCalled()); + + fireEvent.change(primaryField("Notes…"), { target: { value: "Bring ladder" } }); + fireEvent.click(saveButton()); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pocName: "Temp", pocNotes: "Bring ladder" }), + ); + expect(updateContactInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx index d527414d..b868415a 100644 --- a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx @@ -19,6 +19,10 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ }, })); +vi.mock("@/domain/locations/use-cases/use-update-site-contact-info", () => ({ + useUpdateSiteContactInfo: () => ({ mutate: vi.fn(), isPending: false }), +})); + const SITES: LocationOption[] = [ { id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" }, { id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },