Save Work Order Site dialog contact and notes edits to the site record

This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:18:26 -03:00
parent c5816f8907
commit 7eaf6bca18
14 changed files with 489 additions and 31 deletions

View file

@ -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;

View file

@ -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 ? (
<>
<Detail icon={MapPin} text={addressPreview || fallbackLabel} />
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<MapPin size={12} className="shrink-0" aria-hidden />
<span className="truncate">{addressPreview}</span>
</a>
)}
{!addressPreview && <Detail icon={MapPin} text={fallbackLabel} />}
{Boolean(locationDetail.phone) && (
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
<SiteMapPreview
address={locationDetail.address}
city={locationDetail.city}
state={locationDetail.state}
zipCode={locationDetail.zipCode}
/>
</>
) : (
<>

View file

@ -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 (
<div className="mt-4 flex items-center justify-end gap-2">
@ -22,7 +30,13 @@ export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteD
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
Cancel
</Button>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onConfirm}>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={saveDisabled}
loading={saving}
loadingText="Saving…"
onClick={onConfirm}
>
Save
</Button>
</div>

View file

@ -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."
}
/>
);
}

View file

@ -58,6 +58,7 @@ export function SiteDialog({
additionalContacts,
sites,
createMode,
viewOnly,
frozenSite,
onSave,
});
@ -86,7 +87,7 @@ export function SiteDialog({
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
<div>
<FieldLabel required={createMode}>Site</FieldLabel>
<FieldLabel required={s.requiresPoc}>Site</FieldLabel>
<WizardFieldSelect
value={s.locId}
placeholder="Select site …"
@ -107,7 +108,7 @@ export function SiteDialog({
<Text
as="p"
variant="error"
when={Boolean(createMode && s.showErrors && s.siteMissing)}
when={Boolean(s.requiresPoc && s.showErrors && s.siteMissing)}
className="mt-1 text-[11px]"
>
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({
/>
</fieldset>
<SiteDialogFooter viewOnly={viewOnly} onClose={s.close} onConfirm={s.attemptSave} />
<SiteDialogFooter
viewOnly={viewOnly}
saveDisabled={s.saveDisabled}
saving={s.saving}
onClose={s.close}
onConfirm={s.attemptSave}
/>
</DialogContent>
</Dialog>
);

View file

@ -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)),

View file

@ -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,

View file

@ -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<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
): 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<number | undefined>(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,
};
}

View file

@ -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<void> => {
await apiPatch<unknown>(`${API_PATHS.rest.locations}/${id}/contact-info`, {
contacts: mapContactsToBackend(input.contacts),
notes: input.notes,
});
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
},

View file

@ -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;
}

View file

@ -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");
},
});
}

View file

@ -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 = {

View file

@ -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<Parameters<typeof SiteDialog>[0]> = {}) {
const onSave = vi.fn();
const onOpenChange = vi.fn();
renderWithProviders(
<SiteDialog
open
onOpenChange={onOpenChange}
value="DAL1"
locationId="1"
pocName="Someone Else"
pocPhone="(000) 000-0000"
pocNotes="Old work order note"
sites={sites}
onSave={onSave}
{...overrides}
/>,
{ 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();
});
});

View file

@ -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" },