mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Save Work Order Site dialog contact and notes edits to the site record
This commit is contained in:
parent
c5816f8907
commit
7eaf6bca18
14 changed files with 489 additions and 31 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue