This commit is contained in:
Alexandre Brandizzi 2026-09-25 20:45:34 +00:00 • committed by GitHub
commit 90ffd85c3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1241 additions and 126 deletions

View file

@ -27,7 +27,11 @@ export function SiteFormFields({
register,
formState: { errors },
} = form;
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
// ZIP is carried through unedited, so the preview shows the same line as the Street Address link.
const [address, city, state, zipCode] = useWatch({
control,
name: ["address", "city", "state", "zipCode"],
});
const { field: addressField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
@ -84,7 +88,13 @@ export function SiteFormFields({
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<SiteMapPreview
address={address}
city={city}
state={state}
zipCode={zipCode}
placeholderUntilComplete
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("sitePhone")}

View file

@ -1,39 +1,69 @@
import { Box, Typography } from "@mui/material";
import { MapPin } from "lucide-react";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
export function SiteMapPreview(props: SiteMapPreviewProps) {
const complete = hasCompleteSiteAddress(props);
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode"> & {
/**
* Add/Edit forms: hold the map back behind a placeholder until Street, City
* and State are all filled, so a half-typed address never points somewhere
* misleading. Saved sites (view mode, Work Order Site dialog) leave this off
* and always render the map for whatever address they have.
*/
placeholderUntilComplete?: boolean;
/** Hide the "Location preview" caption where the map sits inside an info box. */
hideLabel?: boolean;
height?: number;
};
function MapPlaceholder({ height }: { height: number }) {
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
<Box
role="status"
className="mt-1 flex flex-col items-center justify-center gap-1 rounded-lg"
sx={{ height, bgcolor: "var(--color-bg-muted)", border: 1, borderColor: "divider" }}
>
<MapPin size={18} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
{complete && (
<Box
component="iframe"
title="Site location map"
src={buildSiteMapEmbedUrl(props)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded border-0"
sx={{ height: 140 }}
/>
)}
{!complete && (
<Box
className="mt-1 flex items-center justify-center rounded border border-dashed"
sx={{ height: 140, borderColor: "divider" }}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
</Box>
)}
</Box>
);
}
/** Read-only key-less Google Maps embed of a site's address. */
export function SiteMapPreview({
placeholderUntilComplete = false,
hideLabel = false,
height = 140,
...address
}: SiteMapPreviewProps) {
const src = buildSiteMapEmbedUrl(address);
const showMap = placeholderUntilComplete ? hasCompleteSiteAddress(address) : Boolean(src);
if (!showMap && !placeholderUntilComplete) {
return null;
}
return (
<Box>
{!hideLabel && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
</Typography>
)}
{showMap && (
<Box
component="iframe"
title="Site location map"
src={src}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded-lg border-0"
sx={{ height }}
/>
)}
{!showMap && <MapPlaceholder height={height} />}
</Box>
);
}

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

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";
@ -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 (
<div
className="space-y-1.5 rounded-lg p-3"
@ -44,24 +48,31 @@ export function SiteDialogDetailPanel({
>
{locationDetail ? (
<>
<Detail icon={MapPin} text={addressPreview || 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"
className="flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
<MapPin size={12} className="shrink-0" aria-hidden />
<span className="truncate">{addressPreview}</span>
</a>
)}
{!addressPreview && <Detail icon={MapPin} text={fallbackLabel} />}
{Boolean(sitePhone) && <Detail icon={Phone} text={sitePhone} />}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
<SiteMapPreview
address={locationDetail.address}
city={locationDetail.city}
state={locationDetail.state}
zipCode={locationDetail.zipCode}
hideLabel
height={120}
/>
</>
) : (
<>

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

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
@ -124,21 +125,36 @@ export function SiteDialog({
/>
)}
<SiteDialogPocSection
pocName={s.pn}
pocPhone={s.pp}
pocNotes={s.notes}
additionalContacts={s.extraContacts}
showPrimaryErrors={createMode && s.showErrors}
primaryRequired={createMode}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
onAdditionalContactsChange={s.setExtraContacts}
/>
{/* Locked until the site record loads, so its values never replace typed input. */}
<fieldset
disabled={s.siteRecordLoading}
aria-busy={s.siteRecordLoading || undefined}
className="m-0 border-0 p-0"
>
<SiteDialogPocSection
pocName={s.pn}
pocPhone={s.pp}
pocNotes={s.notes}
additionalContacts={s.extraContacts}
showPrimaryErrors={s.requiresPoc && s.showErrors}
primaryRequired={s.requiresPoc}
editsSiteRecord={s.editsSiteRecord}
siteRecordUnavailable={s.siteRecordUnavailable}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
onAdditionalContactsChange={s.setExtraContacts}
/>
</fieldset>
</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);
@ -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);
},

View file

@ -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<typeof useSiteDialogFormFields>;
siteRecord: ReturnType<typeof useSiteRecordSync>;
/** 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,
};
}

View file

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

View file

@ -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<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
): 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<number | undefined>(undefined);
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(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,
};
}

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

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

View file

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

View file

@ -1,23 +1,28 @@
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { Location } from "@/domain/locations/types/location";
import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps";
import { hasCompleteAddress } from "@/lib/address/vendor-address";
type SiteAddress = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** Google Maps search link for a site address; opens in a new tab. */
export function buildSiteMapsUrl(location: SiteAddress): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
return googleMapsSearchUrl(formatLocationAddressPreview(location));
}
/** True once Street, City and State are all filled, so a map preview can render. */
export function hasCompleteSiteAddress(location: SiteAddress): boolean {
return [location.address, location.city, location.state].every(
(part) => (part?.trim() ?? "") !== "",
);
return hasCompleteAddress({
street: location.address,
city: location.city,
state: location.state,
});
}
/** Key-less Google Maps embed URL for the read-only preview. */
/**
* Key-less Google Maps embed URL for the read-only preview, built from the same
* address line as the Street Address link. Empty when the site has no address.
*/
export function buildSiteMapEmbedUrl(location: SiteAddress): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
return googleMapsEmbedUrl(formatLocationAddressPreview(location));
}

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,28 @@
/**
* Google Maps URLs shared by every address preview and address link.
*
* Each caller composes its own one-line address (Vendors join the parts with
* spaces, Sites use the same line as their Street Address link); these helpers
* only encode it into the URL shape Google expects. Both return an empty string
* for a blank query, so callers can skip rendering instead of pointing at an
* empty search.
*/
/** Link target that opens full Google Maps in a new tab. */
export function googleMapsSearchUrl(query: string): string {
const trimmed = query.trim();
return trimmed
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(trimmed)}`
: "";
}
/**
* Iframe source for the read-only inline preview.
*
* `output=embed` is Google's keyless form: no API key, no billing account and
* no Places request, and the embed brings its own "View larger map" link.
*/
export function googleMapsEmbedUrl(query: string): string {
const trimmed = query.trim();
return trimmed ? `https://www.google.com/maps?q=${encodeURIComponent(trimmed)}&output=embed` : "";
}

View file

@ -7,6 +7,8 @@
* design agree on what a dispatcher sees.
*/
import { googleMapsEmbedUrl, googleMapsSearchUrl } from "./google-maps";
export type AddressParts = {
street: string;
city: string;
@ -112,10 +114,7 @@ export function buildAddressQuery(parts: Partial<AddressParts>): string {
/** Link target for the clickable Street Address. Empty string when unmappable. */
export function mapsSearchUrl(parts: Partial<AddressParts>): string {
const query = buildAddressQuery(parts);
return query
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
: "";
return googleMapsSearchUrl(buildAddressQuery(parts));
}
/**
@ -125,8 +124,7 @@ export function mapsSearchUrl(parts: Partial<AddressParts>): string {
* no billing account and no Places request, which is what SH-271 requires.
*/
export function mapsEmbedUrl(parts: Partial<AddressParts>): string {
const query = buildAddressQuery(parts);
return query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed` : "";
return googleMapsEmbedUrl(buildAddressQuery(parts));
}
/** A preview is only meaningful once all three parts are present. */

View file

@ -115,6 +115,22 @@ describe("AddSiteDialog", () => {
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
});
it("keeps the map placeholder while State is still empty", () => {
const { dialog } = renderDialog();
fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), {
target: { value: "3811 Distribution Dr" },
});
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
target: { value: "Dallas" },
});
expect(
within(dialog).getByText("Enter an address to preview the location"),
).toBeInTheDocument();
expect(within(dialog).queryByTitle("Site location map")).not.toBeInTheDocument();
});
it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => {
const user = userEvent.setup();
const { dialog } = renderDialog();

View file

@ -117,7 +117,11 @@ describe("Sites page detail panel", () => {
"https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201",
);
expect(address).toHaveAttribute("target", "_blank");
expect(within(region).getByTitle("Site location map")).toBeInTheDocument();
expect(within(region).getByTitle("Site location map")).toHaveAttribute(
"src",
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
);
expect(within(region).getByTitle("Site location map")).toHaveAttribute("loading", "lazy");
expect(within(region).getByText("TX · Area: West")).toBeInTheDocument();
expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument();
expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument();
@ -126,6 +130,45 @@ describe("Sites page detail panel", () => {
expect(within(region).queryByText(/status/i)).not.toBeInTheDocument();
});
it("view mode always renders the map for a saved site, even one missing City and State", async () => {
getById.mockResolvedValue({ ...chicago, city: "", state: "" });
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("CHI2");
expect(within(region).getByTitle("Site location map")).toHaveAttribute(
"src",
"https://www.google.com/maps?q=100%20Main%20St&output=embed",
);
expect(
within(region).queryByText("Enter an address to preview the location"),
).not.toBeInTheDocument();
});
it("edit mode previews the stored ZIP and swaps the map for the placeholder while City is cleared", async () => {
renderWithProviders(<LocationsListPage />, { withAuth: false });
const region = await openSite("DAL1");
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
expect(within(region).getByTitle("Site location map")).toHaveAttribute(
"src",
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
);
const city = within(region).getByRole("textbox", { name: "City" });
fireEvent.change(city, { target: { value: "" } });
expect(
await within(region).findByText("Enter an address to preview the location"),
).toBeInTheDocument();
expect(within(region).queryByTitle("Site location map")).not.toBeInTheDocument();
fireEvent.change(city, { target: { value: "Fort Worth" } });
expect(await within(region).findByTitle("Site location map")).toHaveAttribute(
"src",
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Fort%20Worth%2C%20TX%2075201&output=embed",
);
});
it("switches to edit in place, saves, and returns to view showing the saved values", async () => {
update.mockResolvedValue({});
renderWithProviders(<LocationsListPage />, { withAuth: false });

View file

@ -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(
<SiteDialog
open
onOpenChange={vi.fn()}
value="DAL1"
locationId="1"
pocName="Someone Else"
pocPhone="(000) 000-0000"
pocNotes="Old work order note"
sites={SITES}
onSave={onSave}
/>,
{ 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<HTMLElement>('[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<Location>(() => {}),
);
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<Location>((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",
});
});
});

View file

@ -0,0 +1,316 @@
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<Parameters<typeof SiteDialog>[0]> = {}) {
const onSave = vi.fn();
const onOpenChange = vi.fn();
const { queryClient } = 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, 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");
const map = screen.getByTitle("Site location map");
expect(map).toHaveAttribute(
"src",
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
);
expect(map).toHaveAttribute("loading", "lazy");
expect(screen.queryByText("Enter an address to preview the location")).not.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<Location>((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();
});
});

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

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
buildSiteMapEmbedUrl,
buildSiteMapsUrl,
hasCompleteSiteAddress,
} from "@/domain/locations/utils/site-maps";
const site = { address: "12 Dock Rd #4", city: "Smith & Sons", state: "TX", zipCode: "75201" };
describe("site map URLs", () => {
it("embeds the same address line the Street Address link opens", () => {
const line = "12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX%2075201";
expect(buildSiteMapsUrl(site)).toBe(`https://www.google.com/maps/search/?api=1&query=${line}`);
expect(buildSiteMapEmbedUrl(site)).toBe(`https://www.google.com/maps?q=${line}&output=embed`);
});
it("has no embed URL for a site without any address", () => {
expect(buildSiteMapEmbedUrl({})).toBe("");
});
it("counts an address complete only when Street, City and State are all filled", () => {
expect(hasCompleteSiteAddress(site)).toBe(true);
expect(hasCompleteSiteAddress({ ...site, state: " " })).toBe(false);
expect(hasCompleteSiteAddress({ ...site, city: undefined })).toBe(false);
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { googleMapsEmbedUrl, googleMapsSearchUrl } from "@/lib/address/google-maps";
describe("googleMapsEmbedUrl", () => {
it("builds the keyless embed URL for an address", () => {
expect(googleMapsEmbedUrl("3811 Distribution Dr, Dallas, TX 75201")).toBe(
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
);
});
it("encodes commas, # and & so they stay inside the query", () => {
expect(googleMapsEmbedUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe(
"https://www.google.com/maps?q=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX&output=embed",
);
});
it("returns an empty string for a blank address", () => {
expect(googleMapsEmbedUrl("")).toBe("");
expect(googleMapsEmbedUrl(" ")).toBe("");
});
});
describe("googleMapsSearchUrl", () => {
it("encodes commas, # and & in the search link", () => {
expect(googleMapsSearchUrl("12 Dock Rd #4, Smith & Sons, TX")).toBe(
"https://www.google.com/maps/search/?api=1&query=12%20Dock%20Rd%20%234%2C%20Smith%20%26%20Sons%2C%20TX",
);
});
it("returns an empty string for a blank address", () => {
expect(googleMapsSearchUrl(" ")).toBe("");
});
});