feat(work-orders): consume completion freeze snapshot on the board

Prefer FrozenSite, FrozenCompany, and FrozenPoc on Completed rows.
This commit is contained in:
Arthur Bassi 2026-09-10 15:41:01 -03:00
parent b7d531f554
commit 116f65ca48
25 changed files with 643 additions and 52 deletions

View file

@ -12,6 +12,11 @@ import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
import {
effectiveCompany,
effectivePoc,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
@ -61,7 +66,9 @@ export function useCompDocDialog({
onDateCompletedChange,
}: UseCompDocDialogArgs) {
const locId = String(row.locationId || "");
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: locationDetail } = useLocationDetail(
effectiveSite(row) != null ? undefined : locId || undefined,
);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const pdfInputRef = useRef<HTMLInputElement>(null);
@ -93,8 +100,8 @@ export function useCompDocDialog({
setEditDateCompleted,
});
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const amazonRep = effectivePoc(info).contacts[0]?.name || "";
const seaRep = effectiveCompany(info).contactName || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);

View file

@ -13,6 +13,10 @@ import type {
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
import {
effectiveCompany,
effectivePoc,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
import { DocBadge } from "./slide-over-doc-badge";
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
@ -39,6 +43,9 @@ export function SlideOverInfoTabDisplayView({
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
const company = effectiveCompany(source);
const poc = effectivePoc(source);
const primaryPoc = poc.contacts[0];
return (
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
@ -51,12 +58,16 @@ export function SlideOverInfoTabDisplayView({
value={source.site}
location={source.location}
locationId={source.locationId}
pocName={source.pocName}
pocPhone={source.pocPhone}
pocNotes={source.pocNotes}
additionalContacts={source.additionalContacts}
pocName={primaryPoc?.name ?? ""}
pocPhone={primaryPoc?.phone ?? ""}
pocNotes={poc.notes ?? ""}
additionalContacts={poc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
}))}
sites={sites}
viewOnly
frozenSite={source.frozenSite}
onSave={() => undefined}
/>
) : (
@ -123,11 +134,12 @@ export function SlideOverInfoTabDisplayView({
{isCompleted ? (
<VendorCell
vendorId={source.vendorId}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
company={company.name ?? source.company}
tech={company.contactName ?? source.tech}
techPhone={company.phone ?? source.techPhone}
vendors={vendors}
viewOnly
frozenCompany={source.frozenCompany}
onSave={() => undefined}
/>
) : (
@ -142,8 +154,8 @@ export function SlideOverInfoTabDisplayView({
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
{source.pocName || "—"}
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
{primaryPoc?.name || "—"}
{primaryPoc?.phone ? ` · ${primaryPoc.phone}` : ""}
</span>
</SlideOverInfoRow>
</div>

View file

@ -3,6 +3,7 @@ import { MapPin } from "lucide-react";
import { formatSitePlace } from "@/domain/locations/mappers/location-mapper";
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";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { SiteDialog, type SitePatch } from "./site-dialog";
@ -22,6 +23,7 @@ type SiteCellProps = {
/** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean;
viewOnly?: boolean;
frozenSite?: WorkOrderFrozenSite;
onSave: (patch: SitePatch) => void;
};
@ -39,10 +41,15 @@ export function SiteCell({
q,
createMode = false,
viewOnly = false,
frozenSite,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
const place = formatSitePlace(city, state, location);
const place = formatSitePlace(
frozenSite?.city ?? city,
frozenSite?.state ?? state,
frozenSite?.label ?? location,
);
return (
<>
@ -102,6 +109,7 @@ export function SiteCell({
sites={sites}
createMode={createMode}
viewOnly={viewOnly}
frozenSite={frozenSite}
onSave={onSave}
/>
</>

View file

@ -6,6 +6,7 @@ import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
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";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer";
@ -27,6 +28,7 @@ type SiteDialogProps = {
sites: LocationOption[];
createMode?: boolean;
viewOnly?: boolean;
frozenSite?: WorkOrderFrozenSite;
onSave: (patch: SitePatch) => void;
};
@ -42,6 +44,7 @@ export function SiteDialog({
sites,
createMode = false,
viewOnly = false,
frozenSite,
onSave,
}: SiteDialogProps) {
const s = useSiteDialogState({
@ -55,6 +58,7 @@ export function SiteDialog({
additionalContacts,
sites,
createMode,
frozenSite,
onSave,
});

View file

@ -1,4 +1,4 @@
import { useEffect } from "react";
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";
@ -6,6 +6,7 @@ import { useLocationDetail } from "@/domain/locations/use-cases/use-location-det
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
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";
export type { SitePatch };
@ -20,6 +21,7 @@ type UseSiteDialogStateArgs = {
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
frozenSite?: WorkOrderFrozenSite;
onSave: (patch: SitePatch) => void;
};
@ -34,6 +36,7 @@ export function useSiteDialogState({
additionalContacts = [],
sites,
createMode,
frozenSite,
onSave,
}: UseSiteDialogStateArgs) {
const fields = useSiteDialogFormFields({
@ -49,23 +52,42 @@ export function useSiteDialogState({
});
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
data: liveLocationDetail,
isLoading: liveLocationDetailLoading,
isError: liveLocationDetailError,
} = useLocationDetail(frozenSite == null && 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],
);
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
const locationDetailError = frozenSite == null && liveLocationDetailError;
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || !pocMissing);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
if (frozenSite != null || !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]);
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
const attemptSave = () => {
if (createMode && !canConfirm) {

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
@ -12,6 +13,7 @@ type VendorCellProps = {
vendors: VendorDropdownItem[];
q?: string;
viewOnly?: boolean;
frozenCompany?: WorkOrderFrozenCompany;
onSave: (patch: VendorPatch) => void;
};
@ -23,6 +25,7 @@ export function VendorCell({
vendors,
q,
viewOnly = false,
frozenCompany,
onSave,
}: VendorCellProps) {
const [open, setOpen] = useState(false);
@ -63,6 +66,7 @@ export function VendorCell({
techPhone={techPhone}
vendors={vendors}
viewOnly={viewOnly}
frozenCompany={frozenCompany}
onSave={onSave}
/>
</>

View file

@ -14,6 +14,7 @@ import type {
CompanyOption,
TechnicianOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
type VendorDialogBodyProps = {
selectedCompany: string;
@ -27,6 +28,7 @@ type VendorDialogBodyProps = {
notes: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
frozenCompany?: WorkOrderFrozenCompany;
saveError: string | null;
onCompanyPick: (company: string) => void;
onPick: (key: string) => void;
@ -51,6 +53,7 @@ export function VendorDialogBody({
notes,
resolvedCompany,
selectedVendor,
frozenCompany,
saveError,
onCompanyPick,
onPick,
@ -85,7 +88,11 @@ export function VendorDialogBody({
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company info</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
<VendorDialogCompanyCard
company={resolvedCompany}
vendor={selectedVendor}
frozenCompany={frozenCompany}
/>
</div>
)}

View file

@ -1,16 +1,39 @@
import { ExternalLink, Mail, MapPin } from "lucide-react";
import { ExternalLink, Mail, MapPin, Phone, User } from "lucide-react";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
type VendorDialogCompanyCardProps = {
company: string;
vendor: VendorDropdownItem | undefined;
frozenCompany?: WorkOrderFrozenCompany;
};
export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompanyCardProps) {
const distance = formatVendorDistance(vendor?.distanceMiles);
const email = vendor?.email?.trim() || "";
function formatFrozenCompanyAddress(company: WorkOrderFrozenCompany): string {
const region = [company.city, company.state]
.map((part) => part?.trim())
.filter(Boolean)
.join(", ");
return [company.address?.trim(), region, company.zip?.trim()].filter(Boolean).join(", ");
}
export function VendorDialogCompanyCard({
company,
vendor,
frozenCompany,
}: VendorDialogCompanyCardProps) {
const isFrozen = frozenCompany != null;
const distance = isFrozen ? "" : formatVendorDistance(vendor?.distanceMiles);
const contactName = isFrozen ? (frozenCompany.contactName?.trim() ?? "") : "";
const email = isFrozen ? (frozenCompany.email?.trim() ?? "") : (vendor?.email?.trim() ?? "");
const address = isFrozen ? formatFrozenCompanyAddress(frozenCompany) : (vendor?.address ?? "");
const mapsUrl =
(isFrozen ? frozenCompany.maps : undefined) ||
(!isFrozen && address
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
: "");
return (
<div
@ -30,11 +53,13 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
</span>
)}
</div>
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
{Boolean(address) && <Detail icon={MapPin} text={address} />}
{Boolean(contactName) && <Detail icon={User} text={contactName} />}
{Boolean(frozenCompany?.phone) && <Detail icon={Phone} text={frozenCompany?.phone ?? ""} />}
{Boolean(email) && <Detail icon={Mail} text={email} />}
{vendor != null && Boolean(vendor.address) && (
{Boolean(mapsUrl) && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(vendor.address)}`}
href={mapsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"

View file

@ -7,6 +7,7 @@ import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { WorkOrderFrozenCompany } from "@/domain/work-orders/types/work-order-table-row";
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
@ -19,6 +20,7 @@ type VendorDialogProps = {
techPhone?: string;
vendors: VendorDropdownItem[];
viewOnly?: boolean;
frozenCompany?: WorkOrderFrozenCompany;
onSave: (patch: VendorPatch) => void;
};
@ -31,6 +33,7 @@ export function VendorDialog({
techPhone = "",
vendors,
viewOnly = false,
frozenCompany,
onSave,
}: VendorDialogProps) {
const form = useVendorDialogForm({
@ -79,6 +82,7 @@ export function VendorDialog({
notes={form.notes}
resolvedCompany={form.resolvedCompany}
selectedVendor={form.selectedVendor}
frozenCompany={frozenCompany}
saveError={form.saveError}
onCompanyPick={form.handleCompanyPick}
onPick={form.handlePick}

View file

@ -3,6 +3,10 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
import {
effectivePoc,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
import { saveExistingRowType } from "./save-existing-row-type";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
@ -45,6 +49,9 @@ export function WoTableRowCells({
}: WoTableRowCellsProps) {
const hasSchedule =
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
const frozenSite = effectiveSite(row);
const poc = effectivePoc(row);
const primaryPoc = poc.contacts[0];
return (
<>
@ -54,18 +61,22 @@ export function WoTableRowCells({
onClick={(e) => e.stopPropagation()}
>
<SiteCell
value={row.site}
location={row.location}
value={frozenSite?.code ?? row.site}
location={frozenSite?.label ?? row.location}
locationId={row.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={row.pocName}
pocPhone={row.pocPhone}
pocNotes={row.pocNotes}
additionalContacts={row.additionalContacts}
pocName={primaryPoc?.name ?? ""}
pocPhone={primaryPoc?.phone ?? ""}
pocNotes={poc.notes ?? ""}
additionalContacts={poc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
}))}
sites={sites}
q={search}
viewOnly={viewOnly}
frozenSite={frozenSite}
onSave={(p) =>
onPatchRow({
site: p.site,

View file

@ -11,6 +11,7 @@ import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
import type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@ -36,6 +37,8 @@ export function WoTableRowServiceCells({
handlers,
onPatchRow,
}: WoTableRowServiceCellsProps) {
const company = effectiveCompany(row);
return (
<>
<td
@ -71,12 +74,13 @@ export function WoTableRowServiceCells({
>
<VendorCell
vendorId={row.vendorId}
company={row.company}
tech={row.tech}
techPhone={row.techPhone}
company={company.name ?? row.company}
tech={company.contactName ?? row.tech}
techPhone={company.phone ?? row.techPhone}
vendors={vendors}
q={search}
viewOnly={viewOnly}
frozenCompany={row.status === "Completed" ? row.frozenCompany : undefined}
onSave={(p) => applyVendorTableSave(handlers.onPatch, row.id, p)}
/>
</td>

View file

@ -7,6 +7,7 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTablePatchFn } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { effectiveSitePlace } from "@/domain/work-orders/utils/effective-completion-snapshot";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
@ -136,7 +137,9 @@ export function WoTable({
allRows={tableData.rows}
users={users}
sites={sites}
sitePlace={resolveSitePlace(row.locationId, row.site, sitePlaceLookups)}
sitePlace={
effectiveSitePlace(row) ?? resolveSitePlace(row.locationId, row.site, sitePlaceLookups)
}
vendors={vendors}
pmTypes={pmTypes}
search={tableData.search}

View file

@ -39,6 +39,7 @@ type CreateCompDocDialogActionsParams = {
site: CompDocSite;
locId: string;
isMockLocation: boolean;
isSiteFrozen: boolean;
locationDetail: Location | undefined;
queryClient: QueryClient;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
@ -58,6 +59,7 @@ export function createCompDocDialogActions({
site,
locId,
isMockLocation,
isSiteFrozen,
locationDetail,
queryClient,
onSave,
@ -79,7 +81,7 @@ export function createCompDocDialogActions({
const next = editAddress.trim();
const current = buildCompDocAddressLine(site);
if (next === current) return;
if (isMockLocation || !locId) return;
if (isSiteFrozen || isMockLocation || !locId) return;
if (!locationDetail) {
throw new Error("Location details are still loading. Try saving again.");
}

View file

@ -15,6 +15,11 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
import {
effectiveCompany,
effectivePoc,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
type UseCompDocDialogParams = {
row: WorkOrderTableRow;
@ -34,8 +39,9 @@ export function useCompDocDialog({
const queryClient = useQueryClient();
const locId = String(row.locationId || "");
const isMockLocation = locId.startsWith("mock-");
const frozenSite = effectiveSite(row);
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
frozenSite != null || isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
@ -75,22 +81,32 @@ export function useCompDocDialog({
return;
}
const awaitingLocation = Boolean(locId) && !isMockLocation && !locationDetail;
const awaitingLocation =
frozenSite == null && Boolean(locId) && !isMockLocation && !locationDetail;
if (awaitingLocation) {
return;
}
setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail)));
addressSeededRef.current = true;
}, [open, row, data?.notes, data?.generatedAt, locationDetail, locId, isMockLocation]);
}, [
open,
row,
data?.notes,
data?.generatedAt,
locationDetail,
locId,
isMockLocation,
frozenSite,
]);
const handleAddressChange = (value: string) => {
addressTouchedRef.current = true;
setEditAddress(value);
};
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
const amazonRep = effectivePoc(row).contacts[0]?.name || "";
const seaRep = effectiveCompany(row).contactName || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
@ -129,6 +145,7 @@ export function useCompDocDialog({
site,
locId,
isMockLocation,
isSiteFrozen: frozenSite != null,
locationDetail,
queryClient,
onSave,

View file

@ -9,6 +9,9 @@ import {
} from "@/domain/work-orders/types/work-order-board";
import type {
CompletionDocStatus,
WorkOrderFrozenCompany,
WorkOrderFrozenPoc,
WorkOrderFrozenSite,
WorkOrderTableRow,
WorkOrderUpliftSummary,
} from "@/domain/work-orders/types/work-order-table-row";
@ -271,6 +274,54 @@ function readPrimaryDispatchStatus(item: Record<string, unknown>): string {
return readString(item, "primaryDispatchStatus", "PrimaryDispatchStatus");
}
function readFrozenSite(item: Record<string, unknown>): WorkOrderFrozenSite | undefined {
const source = asRecord(item.frozenSite ?? item.FrozenSite);
if (Object.keys(source).length === 0) return undefined;
return {
code: readString(source, "code", "Code"),
label: readString(source, "label", "Label"),
address: readString(source, "address", "Address"),
city: readString(source, "city", "City"),
state: readString(source, "state", "State"),
zip: readString(source, "zip", "Zip"),
phone: readString(source, "phone", "Phone"),
email: readString(source, "email", "Email"),
maps: readString(source, "maps", "Maps"),
};
}
function readFrozenCompany(item: Record<string, unknown>): WorkOrderFrozenCompany | undefined {
const source = asRecord(item.frozenCompany ?? item.FrozenCompany);
if (Object.keys(source).length === 0) return undefined;
return {
name: readString(source, "name", "Name"),
contactName: readString(source, "contactName", "ContactName"),
address: readString(source, "address", "Address"),
city: readString(source, "city", "City"),
state: readString(source, "state", "State"),
zip: readString(source, "zip", "Zip"),
phone: readString(source, "phone", "Phone"),
email: readString(source, "email", "Email"),
maps: readString(source, "maps", "Maps"),
};
}
function readFrozenPoc(item: Record<string, unknown>): WorkOrderFrozenPoc | undefined {
const source = asRecord(item.frozenPoc ?? item.FrozenPoc);
if (Object.keys(source).length === 0) return undefined;
const rawContacts = source.contacts ?? source.Contacts;
const contacts = Array.isArray(rawContacts)
? rawContacts.map(asRecord).map((contact) => ({
name: readString(contact, "name", "Name"),
phone: readString(contact, "phone", "Phone"),
}))
: [];
return {
contacts,
notes: readString(source, "notes", "Notes"),
};
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
@ -364,6 +415,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
additionalContacts: readAdditionalContacts(item),
frozenSite: readFrozenSite(item),
frozenCompany: readFrozenCompany(item),
frozenPoc: readFrozenPoc(item),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,

View file

@ -1,6 +1,11 @@
import type { DateRangeKey } from "./work-order-advanced-filters";
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WorkOrderTableRow } from "./work-order-table-row";
import type {
WorkOrderFrozenCompany,
WorkOrderFrozenPoc,
WorkOrderFrozenSite,
WorkOrderTableRow,
} from "./work-order-table-row";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
/** Numeric WorkOrderType enum as expected by POST /workorders/board (no string enums). */
@ -173,6 +178,9 @@ export interface WorkOrderBoardRowApi {
pocPhone?: string | null;
pocNotes?: string | null;
additionalContacts?: WorkOrderAdditionalContact[] | null;
frozenSite?: WorkOrderFrozenSite | null;
frozenCompany?: WorkOrderFrozenCompany | null;
frozenPoc?: WorkOrderFrozenPoc | null;
lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null;
legacyStatus?: string | null;

View file

@ -13,6 +13,35 @@ export type WorkOrderUpliftSummary = {
primaryStatus?: string;
};
export type WorkOrderFrozenSite = {
code?: string;
label?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
phone?: string;
email?: string;
maps?: string;
};
export type WorkOrderFrozenCompany = {
name?: string;
contactName?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
phone?: string;
email?: string;
maps?: string;
};
export type WorkOrderFrozenPoc = {
contacts: { name?: string; phone?: string }[];
notes?: string;
};
export interface WorkOrderTableRow {
id: string | number;
woNumber: string;
@ -49,6 +78,9 @@ export interface WorkOrderTableRow {
pocPhone: string;
pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
frozenSite?: WorkOrderFrozenSite;
frozenCompany?: WorkOrderFrozenCompany;
frozenPoc?: WorkOrderFrozenPoc;
dueDate: string;
rescheduleCount: number;
carriedOver: number;

View file

@ -1,15 +1,27 @@
import type { Location } from "@/domain/locations/types/location";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { effectiveSite } from "@/domain/work-orders/utils/effective-completion-snapshot";
function buildLabel(city?: string, state?: string): string {
return [city, state].filter(Boolean).join(", ");
}
export function resolveCompDocSite(
row: Pick<WorkOrderTableRow, "site" | "location">,
row: Pick<WorkOrderTableRow, "site" | "location" | "status" | "frozenSite">,
location?: Location | null,
): CompDocSiteInfo {
const frozen = effectiveSite(row);
if (frozen != null) {
const city = frozen.city ?? "";
const state = frozen.state ?? "";
const zip = frozen.zip ?? "";
const label = buildLabel(city, state);
const street = frozen.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
if (location?.address || location?.city) {
const city = location.city ?? "";
const state = location.state ?? "";

View file

@ -0,0 +1,55 @@
import type {
WorkOrderFrozenCompany,
WorkOrderFrozenPoc,
WorkOrderFrozenSite,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
type SiteSnapshotRow = Pick<WorkOrderTableRow, "status" | "frozenSite">;
type CompanySnapshotRow = Pick<
WorkOrderTableRow,
"status" | "company" | "tech" | "techPhone" | "frozenCompany"
>;
type PocSnapshotRow = Pick<
WorkOrderTableRow,
"status" | "pocName" | "pocPhone" | "pocNotes" | "frozenPoc"
> & {
additionalContacts?: WorkOrderTableRow["additionalContacts"];
};
export function effectiveSite(row: SiteSnapshotRow): WorkOrderFrozenSite | undefined {
if (row.status === "Completed" && row.frozenSite != null) return row.frozenSite;
return undefined;
}
export function effectiveSitePlace(row: SiteSnapshotRow): {
city: string;
state: string;
} | null {
const frozen = effectiveSite(row);
if (frozen == null) return null;
return { city: frozen.city ?? "", state: frozen.state ?? "" };
}
export function effectiveCompany(row: CompanySnapshotRow): WorkOrderFrozenCompany {
if (row.status === "Completed" && row.frozenCompany != null) return row.frozenCompany;
return {
name: row.company,
contactName: row.tech,
phone: row.techPhone,
};
}
export function effectivePoc(row: PocSnapshotRow): WorkOrderFrozenPoc {
if (row.status === "Completed" && row.frozenPoc != null) return row.frozenPoc;
return {
contacts: [
{ name: row.pocName, phone: row.pocPhone },
...(row.additionalContacts ?? []).map((contact) => ({
name: contact.name,
phone: contact.phone,
})),
],
notes: row.pocNotes,
};
}

View file

@ -9,11 +9,16 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
const locationDetailState = vi.hoisted(() => ({
data: undefined as Location | undefined,
}));
const updateLocation = vi.hoisted(() => vi.fn());
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: locationDetailState.data }),
}));
vi.mock("@/domain/locations/api/locations-api", () => ({
locationsApi: { update: updateLocation },
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
const defaults: WorkOrderTableRow = {
id: 42,
@ -80,6 +85,7 @@ function wrapper({ children }: { children: ReactNode }) {
describe("useCompDocDialog address seeding", () => {
beforeEach(() => {
locationDetailState.data = undefined;
updateLocation.mockReset();
});
it("seeds address from the first authoritative locationDetail without locking on the empty fallback", async () => {
@ -153,4 +159,39 @@ describe("useCompDocDialog address seeding", () => {
expect(result.current.editAddress).toBe("Dispatcher typed address");
});
it("does not update the live location when a completed work order uses a frozen site", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const row = baseRow({
status: "Completed",
frozenSite: {
address: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
},
});
const { result } = renderHook(
() =>
useCompDocDialog({
row,
open: true,
onSave,
}),
{ wrapper },
);
await waitFor(() => {
expect(result.current.editAddress).toBe("100 Frozen Ave");
});
act(() => {
result.current.setEditAddress("Edited document address");
});
await act(async () => {
await result.current.handleSaveDraft();
});
expect(updateLocation).not.toHaveBeenCalled();
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
});
});

View file

@ -5,19 +5,31 @@ import type { Location } from "@/domain/locations/types/location";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
const detailById = new Map<string, Location>();
const requestedDetailIds: Array<string | undefined> = [];
const EMPTY_CONTACTS: [] = [];
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: (id: string | undefined) => ({
data: id && detailById.has(id) ? detailById.get(id) : undefined,
isLoading: Boolean(id && !detailById.has(id)),
isError: false,
}),
useLocationDetail: (id: string | undefined) => {
requestedDetailIds.push(id);
return {
data: id && detailById.has(id) ? detailById.get(id) : undefined,
isLoading: Boolean(id && !detailById.has(id)),
isError: 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" },
];
const FROZEN_SITE = {
label: "BK5 · Frozen",
address: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
};
function locationDetail(id: string, contact: string, phone: string): Location {
return {
@ -42,7 +54,7 @@ function renderSiteDialogState() {
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
additionalContacts: EMPTY_CONTACTS,
sites: SITES,
createMode: true,
onSave: vi.fn(),
@ -53,6 +65,7 @@ function renderSiteDialogState() {
describe("useSiteDialogState POC autofill", () => {
beforeEach(() => {
detailById.clear();
requestedDetailIds.length = 0;
});
it("preserves POC typed before deferred location detail resolves", () => {
@ -110,4 +123,30 @@ describe("useSiteDialogState POC autofill", () => {
expect(result.current.pn).toBe("Contact B");
expect(result.current.pp).toBe("222-222-2222");
});
it("does not request live location detail when a frozen site is provided", () => {
const { result } = renderHook(() =>
useSiteDialogState({
open: true,
onOpenChange: vi.fn(),
value: "BK5",
locationId: "loc-1",
pocName: "Frozen POC",
pocPhone: "111",
pocNotes: "",
additionalContacts: EMPTY_CONTACTS,
sites: SITES,
createMode: false,
frozenSite: FROZEN_SITE,
onSave: vi.fn(),
}),
);
expect(requestedDetailIds).not.toContain("loc-1");
expect(result.current.locationDetail).toMatchObject({
address: "100 Frozen Ave",
city: "Chicago",
state: "IL",
});
});
});

View file

@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
const LIVE_VENDOR: VendorDropdownItem = {
id: 10,
companyName: "Live Vendor",
contactName: "Live Contact",
tradeSpecialties: "Live Trade",
address: "900 Live St",
distanceMiles: 12,
email: "live@example.com",
};
describe("VendorDialogCompanyCard completion snapshot", () => {
it("does not fill missing frozen fields from the live vendor registry", () => {
render(
<VendorDialogCompanyCard
company="Frozen Vendor"
vendor={LIVE_VENDOR}
frozenCompany={{ name: "Frozen Vendor" }}
/>,
);
expect(screen.getByText("Frozen Vendor")).toBeInTheDocument();
expect(screen.queryByText("Live Contact")).not.toBeInTheDocument();
expect(screen.queryByText("Live Trade")).not.toBeInTheDocument();
expect(screen.queryByText("live@example.com")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /open in google maps/i })).not.toBeInTheDocument();
});
it("renders the complete frozen address instead of the live vendor address", () => {
render(
<VendorDialogCompanyCard
company="Frozen Vendor"
vendor={LIVE_VENDOR}
frozenCompany={{
name: "Frozen Vendor",
address: "200 Vendor Ave",
city: "Chicago",
state: "IL",
zip: "60602",
}}
/>,
);
expect(screen.getByText("200 Vendor Ave, Chicago, IL, 60602")).toBeInTheDocument();
expect(screen.queryByText("900 Live St")).not.toBeInTheDocument();
});
});

View file

@ -186,3 +186,29 @@ describe("mapWorkOrderTableRow carry-over fields", () => {
expect(group.items.map((item) => item.id)).toEqual([42]);
});
});
describe("mapWorkOrderTableRow completion snapshot", () => {
it("maps frozen site, company, and POC payloads", () => {
const row = mapWorkOrderTableRow({
id: 191,
lifecycleStatus: 7,
frozenSite: { code: "BK5", label: "BK5 Chicago", city: "Chicago", state: "IL" },
frozenCompany: { name: "Frozen Co", contactName: "Frozen Tech", phone: "111" },
frozenPoc: {
contacts: [{ name: "Frozen POC", phone: "222" }],
notes: "Call first",
},
});
expect(row.frozenSite).toMatchObject({ code: "BK5", city: "Chicago", state: "IL" });
expect(row.frozenCompany).toMatchObject({
name: "Frozen Co",
contactName: "Frozen Tech",
phone: "111",
});
expect(row.frozenPoc).toEqual({
contacts: [{ name: "Frozen POC", phone: "222" }],
notes: "Call first",
});
});
});

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
describe("resolveCompDocSite", () => {
it("prefers a completed work order snapshot over conflicting live location data", () => {
const result = resolveCompDocSite(
{
site: "BK5",
location: "Live City, WI",
status: "Completed",
frozenSite: {
address: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
},
},
{
address: "900 Live St",
city: "Milwaukee",
state: "WI",
zipCode: "53202",
},
);
expect(result).toEqual({
street: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
label: "Chicago, IL",
fullAddress: "100 Frozen Ave, Chicago, IL, 60601",
});
});
it("keeps using the live location for an open work order", () => {
const result = resolveCompDocSite(
{
site: "BK5",
location: "Old City, IL",
status: "Scheduled",
frozenSite: { address: "100 Frozen Ave", city: "Chicago", state: "IL" },
},
{
address: "900 Live St",
city: "Milwaukee",
state: "WI",
zipCode: "53202",
},
);
expect(result.street).toBe("900 Live St");
expect(result.label).toBe("Milwaukee, WI");
});
});

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
effectiveCompany,
effectivePoc,
effectiveSite,
effectiveSitePlace,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
describe("effective completion snapshot", () => {
it("prefers frozen values for a completed work order", () => {
const frozenSite = { code: "BK5", city: "Chicago", state: "IL" };
expect(effectiveSite({ status: "Completed", frozenSite })).toEqual(frozenSite);
expect(effectiveSitePlace({ status: "Completed", frozenSite })).toEqual({
city: "Chicago",
state: "IL",
});
expect(
effectiveCompany({
status: "Completed",
company: "Live Co",
tech: "Live Tech",
techPhone: "222",
frozenCompany: { name: "Frozen Co", contactName: "Frozen Tech", phone: "111" },
}),
).toMatchObject({ name: "Frozen Co", contactName: "Frozen Tech", phone: "111" });
expect(
effectivePoc({
status: "Completed",
pocName: "Live POC",
pocPhone: "222",
pocNotes: "live",
additionalContacts: [],
frozenPoc: {
contacts: [{ name: "Frozen POC", phone: "111" }],
notes: "frozen",
},
}),
).toEqual({
contacts: [{ name: "Frozen POC", phone: "111" }],
notes: "frozen",
});
});
it("uses live values while open or when a completed snapshot is missing", () => {
expect(effectiveSite({ status: "Scheduled", frozenSite: { city: "Old" } })).toBeUndefined();
expect(effectiveSitePlace({ status: "Completed" })).toBeNull();
expect(
effectiveCompany({
status: "Scheduled",
company: "Live Co",
tech: "Live Tech",
techPhone: "222",
frozenCompany: { name: "Old Co" },
}),
).toMatchObject({ name: "Live Co", contactName: "Live Tech", phone: "222" });
expect(
effectivePoc({
status: "Completed",
pocName: "Live POC",
pocPhone: "222",
pocNotes: "live",
additionalContacts: [{ name: "Backup", phone: "333" }],
}),
).toEqual({
contacts: [
{ name: "Live POC", phone: "222" },
{ name: "Backup", phone: "333" },
],
notes: "live",
});
});
it("treats missing additional contacts as an empty live contact list", () => {
expect(
effectivePoc({
status: "Scheduled",
pocName: "Primary",
pocPhone: "111",
pocNotes: "",
}),
).toEqual({
contacts: [{ name: "Primary", phone: "111" }],
notes: "",
});
});
});