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