Compare commits

...

24 commits

Author SHA1 Message Date
Arthur Bassi
19a3fce9b7
Merge 64686a59eb into 24cbd09a60 2026-09-29 16:33:39 +00:00
Alexandre Brandizzi
24cbd09a60
Merge pull request #284 from Sea-Haven-Industries/fix/ab/sh-357-pending-table-only-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-357: remove Pending from existing work order status menu
2026-09-29 16:22:11 +00:00
Alexandre Brandizzi
5ace29eda4 test: expect Pending disabled on dated inline draft 2026-09-29 13:12:11 -03:00
Alexandre Brandizzi
7d5d015602 fix: require undated drafts for Pending status 2026-09-29 13:04:51 -03:00
Alexandre Brandizzi
4771f6f8eb test: cover pending schedule date promotion 2026-09-29 12:52:02 -03:00
Alexandre Brandizzi
a61ab1919a test: find pending creation in unscheduled results 2026-09-29 12:43:38 -03:00
Alexandre Brandizzi
52b682283c test: scope pending wizard selection assertion 2026-09-29 12:33:15 -03:00
Alexandre Brandizzi
123b1ec9b5 test: cover pending work order status workflows 2026-09-29 12:25:56 -03:00
Alexandre Brandizzi
c5d0429fa2 style(work-orders): format status menu expectations 2026-09-29 11:46:12 -03:00
Alexandre Brandizzi
b6d5ad8a1e fix(work-orders): hide Pending from existing status menus 2026-09-29 11:28:26 -03:00
Alexandre Brandizzi
73c7094102
Merge pull request #275 from Sea-Haven-Industries/feat/ab/sh-335-wo-site-dialog
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Work Order Site dialog saves contacts and notes to the site
2026-09-28 23:45:53 +00:00
Alexandre Brandizzi
301fe44b0c fix(workorders): Site dialog loads legacy site contacts and locks fields while saving
A site saved before the contacts list existed maps with no contacts, only
the legacy contact/phone fields. The site-record sync read the contacts
list alone, so the dialog blanked the POC, blocked a notes-only Save until
the contact was retyped, and when the work order had no POC the legacy
autofill made the diff report a contact edit nobody made. The legacy
contact is now the site's main contact for display, baseline and request.

Save sends the values it had when clicked, so the dialog's fields are now
disabled while the site update is in flight instead of accepting edits
that neither save would include.

The site-update mutation already toasts its own failure, so it sets
meta.suppressErrorToast and the global mutation toast no longer repeats it.
2026-09-25 19:52:03 -03:00
Alexandre Brandizzi
53dcb6af9e fix(workorders): Site dialog hides per-contact notes when it edits the site record
Site contacts carry only a name and a phone (LocationContact, and the
Sites page contact rows), so PATCH contact-info has nowhere to put an
additional contact's note. The Notes box on each additional contact card
let a dispatcher type one under copy saying contacts are saved to the
site record, but the note only reached this work order's copy and no
other work order at the site ever saw it.

The additional-contact Notes box is now hidden while the dialog edits the
site record. It stays where contacts are saved to the work order only:
inline create, the wizard, and an existing work order whose site record
failed to load.
2026-09-25 19:36:15 -03:00
Alexandre Brandizzi
f36a07c99c fix(workorders): Site dialog clears the previous site's contact on a site switch
Picking another site on an existing work order kept the old site's POC
name, phone and notes in the fields. When the new site's record failed
to load, the dialog never synced it, Save stayed enabled, and the work
order pointing at the new site was saved with the previous site's
contact and notes as its override.

A site switch now empties the POC name and phone for every work order,
and the notes for existing ones, so the fields hold either the new
site's record or what the dispatcher types for it.
2026-09-25 19:17:24 -03:00
Alexandre Brandizzi
b55938104a fix(workorders): Site dialog sends only complete contacts without site ids to the work order
The Site dialog loads extra contacts from the site record with their site
contact ids so the site request can update rows in place. buildSiteDialogPatch
copied those rows verbatim into the work-order patch, so onSave received
siteContactId (and half-filled rows) even though the type documents that the
id is never sent on work-order patches. The wire serializer already stripped
both, but the patch handed to the board did not.

Run the patch's extra contacts through normalizeAdditionalContacts, the same
helper the work-order serializer uses, so the patch carries only complete
name/phone/notes rows. An emptied list still clears the work order's copy.
The site request keeps its ids because it is built from the form fields.
2026-09-25 19:04:32 -03:00
Alexandre Brandizzi
414e584b36 Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog
# Conflicts:
#	src/domain/locations/api/locations-api.ts
#	src/domain/locations/types/location.ts
2026-09-25 17:34:41 -03:00
Alexandre Brandizzi
62664894bd Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog 2026-09-25 17:01:27 -03:00
Alexandre Brandizzi
35714a917d fix(workorders): Site dialog re-syncs a site after switching back and never syncs cached data
Switching to another site and back now loads the site record again, so its
extra contacts are not dropped on Save. The dialog also waits for the site
request to settle before syncing, so a record cached before an earlier save
is never shown or written back. Half-filled extra contacts stay off the site
record, as they already stay off the work order. The save path moves into
its own hook to keep the dialog state under the complexity limit.
2026-09-25 17:01:27 -03:00
Alexandre Brandizzi
c0e484275d fix(workorders): say Site dialog saves to the work order only when the site did not load
When the site detail request fails, or the user types before it loads,
the dialog falls back to saving this work order only and never calls
updateContactInfo. The POC helper text still said contacts and notes were
saved to the site record, so a dispatcher could believe every work order
at the site now had the new contact. The helper text now follows the
site-record sync state and says the change applies to this work order
only whenever that fallback is active, including after a later refetch
succeeds.
2026-09-25 13:39:25 -03:00
Alexandre Brandizzi
7efc9e8914 fix(workorders): never let a late site load replace typed Site dialog input
The contact lock only covered a pending load. After a failed getById the
fields unlock, and a later successful refetch (focus, invalidation) still
ran the apply effect and overwrote whatever the dispatcher had typed.

The apply effect now skips once the user has typed since the last reset or
site switch, so the dialog stays on the work order's values and saves them
to the work order only; the site's unseen notes and contacts are never
written back. Picking another site still clears the marker and adopts it.
2026-09-25 13:12:27 -03:00
Alexandre Brandizzi
ad500753e1 fix(workorders): lock Site dialog contacts until the site record loads 2026-09-25 12:36:16 -03:00
Alexandre Brandizzi
cccdeceb10 fix(workorders): only edited Site dialog fields follow the site record 2026-09-25 12:32:24 -03:00
Alexandre Brandizzi
bea3540409 Merge remote-tracking branch 'origin/feat/ab/sh-331-sites-panel' into feat/ab/sh-335-wo-site-dialog 2026-09-25 12:24:54 -03:00
Alexandre Brandizzi
7eaf6bca18 Save Work Order Site dialog contact and notes edits to the site record 2026-09-25 12:19:31 -03:00
24 changed files with 1602 additions and 114 deletions

View file

@ -0,0 +1,268 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "In Progress",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
rowVersion: "rv-1",
};
type Role = "Admin" | "Dispatcher";
async function fulfillJson(route: Route, body: unknown) {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
}
async function mockApi(
page: Page,
role: Role,
createPayloads: unknown[] = [],
searchUrls: string[] = [],
) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript(
(user) => {
localStorage.setItem("auth", JSON.stringify(user));
const style = document.createElement("style");
style.textContent = ".tsqd-parent-container { display: none !important; }";
document.documentElement.appendChild(style);
},
{
token: `wo-pending-${role.toLowerCase()}-e2e-token`,
expiration: "2030-01-01T00:00:00.000Z",
email: `${role.toLowerCase()}@seahavenind.com`,
userRoles: role,
phoneNumber: "5551234567",
fullname: `Work Order E2E ${role}`,
id: "1",
},
);
let row = { ...BOARD_ROW };
let createdRow: typeof BOARD_ROW | undefined;
await page.route(
(url) => url.pathname.startsWith("/api/"),
async (route) => {
const request = route.request();
const { pathname } = new URL(request.url());
if (pathname.includes("/workorders/board/search")) {
searchUrls.push(request.url());
await fulfillJson(route, {
items: createdRow ? [createdRow] : [],
totalCount: createdRow ? 1 : 0,
page: 0,
pageSize: 50,
});
return;
}
if (pathname === "/api/workorders/board" && request.method() === "GET") {
await fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: createdRow ? 2 : 1, total: createdRow ? 2 : 1 },
unscheduled: createdRow ? [createdRow] : [],
scheduled: [row],
});
return;
}
if (pathname === "/api/workorders/board" && request.method() === "POST") {
const payload = request.postDataJSON() as {
lifecycleStatus: number;
scheduledDate?: string;
};
createPayloads.push(payload);
createdRow = {
...BOARD_ROW,
id: 900,
woNumber: "WO-900",
lifecycleStatusLabel: payload.lifecycleStatus === 3 ? "Scheduled" : "Pending",
scheduledDate: payload.scheduledDate ?? "",
rowVersion: "rv-created",
};
await fulfillJson(route, { data: { id: 900, internalWONumber: "WO-900" } });
return;
}
if (pathname === "/api/workorders/1/board" && request.method() === "PATCH") {
const body = request.postDataJSON() as { field: string; value: string };
row = { ...row, lifecycleStatusLabel: body.value, rowVersion: "rv-2" };
await fulfillJson(route, row);
return;
}
if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) {
await fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
return;
}
if (pathname === "/api/locations/10") {
await fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
return;
}
await fulfillJson(route, { data: [], items: [] });
},
);
}
async function expectManualTransitionWithoutPending(page: Page) {
const row = page.locator("#wo-row-1");
await expect(row.getByText("WO-501")).toBeVisible();
await row.getByRole("button", { name: "In Progress" }).click();
const menu = page.locator(".MuiPopover-paper").last();
await expect(menu.getByRole("button", { name: "Pending", exact: true })).toHaveCount(0);
await expect(menu.getByRole("button", { name: "On Site", exact: true })).toBeEnabled();
await menu.getByRole("button", { name: "On Site", exact: true }).click();
await expect(row.getByRole("button", { name: "On Site" })).toBeVisible();
}
test.describe("existing work order status menu", () => {
test.setTimeout(60_000);
test("Admin can choose a manual status but not Pending", async ({ page }) => {
await mockApi(page, "Admin");
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expectManualTransitionWithoutPending(page);
});
test("Dispatcher can choose a manual status but not Pending", async ({ page }) => {
await mockApi(page, "Dispatcher");
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await expectManualTransitionWithoutPending(page);
});
test("Admin can select Pending for a new work order when Schedule On has no date", async ({
page,
}) => {
const createPayloads: unknown[] = [];
const searchUrls: string[] = [];
await mockApi(page, "Admin", createPayloads, searchUrls);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await expect(page.getByRole("button", { name: "Pick a date" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Incomplete", exact: true }).last().click();
const menu = page.locator(".MuiPopover-paper").last();
await expect(menu.getByRole("button", { name: "Pending", exact: true })).toBeEnabled();
await menu.getByRole("button", { name: "Pending", exact: true }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Pending", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Create work order" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
await expect
.poll(() => createPayloads)
.toEqual([expect.objectContaining({ lifecycleStatus: 2 })]);
const filtersSheet = page.getByRole("dialog", { name: "Advanced Filters" });
await page.getByRole("button", { name: "Advanced Filters" }).click();
await expect(filtersSheet).toBeVisible();
await filtersSheet.getByRole("button", { name: "Date range" }).click();
await filtersSheet.getByRole("button", { name: "Unscheduled", exact: true }).click();
await filtersSheet.getByRole("button", { name: "Apply filters" }).click();
await expect
.poll(() => searchUrls.some((url) => url.includes("unscheduledOnly=true")))
.toBe(true);
const createdRow = page.locator("#wo-row-900");
await expect(createdRow.getByText("WO-900")).toBeVisible();
await expect(createdRow.getByRole("button", { name: "Pending" })).toBeVisible();
});
test("Admin gets Scheduled when adding Schedule On after selecting Pending", async ({ page }) => {
const createPayloads: unknown[] = [];
await mockApi(page, "Admin", createPayloads);
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Incomplete", exact: true }).last().click();
const menu = page.locator(".MuiPopover-paper").last();
await menu.getByRole("button", { name: "Pending", exact: true }).click();
await expect(
page.getByRole("dialog").getByRole("button", { name: "Pending", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Pick a date" }).click();
const dateTab = page.getByRole("button", { name: "Date", exact: true });
if (await dateTab.isVisible()) {
await dateTab.click();
}
await page.getByRole("button", { name: "20", exact: true }).click();
await expect(page.getByRole("button", { name: /Aug 20/ })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
const wizardDialog = page.getByRole("dialog");
const scheduledStatus = wizardDialog.getByRole("button", {
name: "Scheduled",
exact: true,
});
await expect(scheduledStatus).toBeVisible();
await scheduledStatus.click();
const statusMenu = page.locator(".MuiPopover-paper").last();
await expect(statusMenu.getByRole("button", { name: "Pending", exact: true })).toBeDisabled();
await statusMenu.getByRole("button", { name: "Scheduled", exact: true }).click();
await expect(scheduledStatus).toBeVisible();
await page.getByRole("button", { name: "Create work order" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
await expect
.poll(() => createPayloads)
.toEqual([expect.objectContaining({ lifecycleStatus: 3, scheduledDate: "2026-08-20" })]);
});
});

View file

@ -1,5 +1,6 @@
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -14,6 +15,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 +29,7 @@ export function buildSiteDialogPatch({
extraContacts,
contactsDirty,
baselineHadContacts,
followsSiteRecord = false,
}: BuildSiteDialogPatchArgs): SitePatch {
const patch: SitePatch = {
site: code || (selected ? resolveSiteCode(selected) : ""),
@ -35,8 +39,11 @@ export function buildSiteDialogPatch({
pocPhone,
pocNotes,
};
if (contactsDirty || baselineHadContacts) {
patch.additionalContacts = extraContacts;
// 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) {
// The work order keeps only complete name/phone rows; site contact ids stay on the site request.
patch.additionalContacts = normalizeAdditionalContacts(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,29 @@ 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}
/>
</>
) : (
<>

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,9 @@ 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)}
// Site contacts carry only a name and phone, so a per-contact note could never reach the site.
additionalContactNotes={!editsSiteRecord || siteRecordUnavailable}
/>
);
}

View file

@ -58,6 +58,7 @@ export function SiteDialog({
additionalContacts,
sites,
createMode,
viewOnly,
frozenSite,
onSave,
});
@ -84,9 +85,10 @@ export function SiteDialog({
Select a site and edit its point of contact
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */}
<fieldset disabled={viewOnly || s.saving} 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 +109,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 +126,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,10 +95,13 @@ export function useSiteDialogFormFields({
if (siteChanged) {
setExtraContacts([]);
setContactsDirty(true);
setUserEdited(false);
setDirty(true);
if (createMode) {
setPn("");
setPp("");
// The previous site's contact never carries over, even when the new site fails to load.
setPn("");
setPp("");
if (!createMode) {
setNotes("");
}
setPocFilledFor("");
} else if (id !== pocFilledFor) {
@ -87,6 +111,7 @@ export function useSiteDialogFormFields({
const markPocDirty = (apply: () => void) => {
setDirty(true);
setUserEdited(true);
apply();
};
@ -100,6 +125,8 @@ export function useSiteDialogFormFields({
pocFilledFor,
dirty,
contactsDirty,
userEdited,
resetVersion,
baselineHadContacts,
showErrors,
selected: sites.find((s) => String(s.id) === locId),
@ -108,6 +135,8 @@ export function useSiteDialogFormFields({
setPn,
setPp,
setShowErrors,
setNotes,
setExtraContacts,
markDirty: () => setDirty(true),
handlePick,
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
@ -115,6 +144,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,201 @@
import { useEffect, useState } from "react";
import type {
Location,
LocationContact,
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's contacts in order. A site saved before the contacts list existed has none; its
* contact lives only in the legacy `contact`/`phone` fields, so that one is the main contact.
*/
function siteContacts(location: Location): LocationContact[] {
if (location.contacts && location.contacts.length > 0) {
return location.contacts;
}
const name = location.contact ?? "";
const phone = location.phone ?? "";
return name.trim() || phone.trim() ? [{ name, phone }] : [];
}
/** The site record's contacts and notes, in the dialog's field shape. */
function siteRecordValues(location: Location) {
const [main, ...others] = siteContacts(location);
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

@ -26,6 +26,8 @@ type PocContactFieldsProps = {
showPrimaryErrors?: boolean;
/** Labels the primary name/phone inputs with a persistent required asterisk. */
primaryRequired?: boolean;
/** Offers a Notes box on each additional contact; off where contacts save without per-contact notes. */
additionalContactNotes?: boolean;
};
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
@ -49,11 +51,13 @@ function updateContactAt(
function AdditionalPocCard({
contact,
index,
showNotes,
onChange,
onRemove,
}: {
contact: WorkOrderAdditionalContact;
index: number;
showNotes: boolean;
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
onRemove: () => void;
}) {
@ -115,14 +119,16 @@ function AdditionalPocCard({
</Text>
</div>
</div>
<textarea
value={contact.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
{showNotes && (
<textarea
value={contact.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
)}
</div>
);
}
@ -139,6 +145,7 @@ export function PocContactFields({
helperText,
showPrimaryErrors = false,
primaryRequired = false,
additionalContactNotes = true,
}: PocContactFieldsProps) {
const fieldId = useId();
const nameMissing = showPrimaryErrors && !pocName.trim();
@ -214,6 +221,7 @@ export function PocContactFields({
key={`additional-poc-${index}`}
contact={contact}
index={index}
showNotes={additionalContactNotes}
onChange={(patch) =>
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
}

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

@ -68,6 +68,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,35 @@
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");
},
// The toast above is this failure's only message; the global mutation toast would repeat it.
meta: { suppressErrorToast: true },
});
}

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

@ -17,9 +17,11 @@ import {
*/
export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"];
/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */
/**
* Manually selectable statuses for an existing WO. Pending is creation-only; Canceled is kebab-only.
*/
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter(
(status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status),
(status) => status !== "Pending" && !AUTOMATIC_LIFECYCLE_STATUSES.includes(status),
);
/** Inline options for an existing WO never repeat its current status. */
@ -36,6 +38,8 @@ export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
export const SCHEDULED_NEEDS_DATE_MSG =
"Set a Schedule On date before marking this work order as Scheduled.";
export const PENDING_NEEDS_NO_DATE_MSG =
"Clear the Schedule On date before marking this work order as Pending.";
export const PAST_DUE_BADGE_TOOLTIP =
"This WO missed its service window. Reschedule to unlock status updates.";
@ -130,6 +134,9 @@ export function getStatusOptionState(
status: WizardWOStatus,
closabilityGaps: string[],
): StatusOptionState {
if (status === "Pending" && hasConcreteScheduleDate(row)) {
return { disabled: true, title: PENDING_NEEDS_NO_DATE_MSG };
}
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
const base = {
code: "S-100",
locId: "1",
selected: undefined,
pocName: "Jane",
pocPhone: "(421) 433-1111",
pocNotes: "",
contactsDirty: true,
baselineHadContacts: true,
followsSiteRecord: true,
};
describe("buildSiteDialogPatch", () => {
it("copies site-loaded extra contacts to the work order without their site contact ids", () => {
const patch = buildSiteDialogPatch({
...base,
extraContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
});
expect(patch.additionalContacts).toEqual([{ name: "Bob", phone: "(421) 433-7777" }]);
});
it("drops half-filled and empty extra contacts from the work-order patch", () => {
const patch = buildSiteDialogPatch({
...base,
extraContacts: [
{ name: "Bob", phone: "(421) 433-7777", notes: "Night shift", siteContactId: 32 },
{ name: "Half", phone: "", siteContactId: 33 },
{ name: "", phone: "(421) 433-0000" },
{ name: "", phone: "" },
],
});
expect(patch.additionalContacts).toEqual([
{ name: "Bob", phone: "(421) 433-7777", notes: "Night shift" },
]);
});
it("still clears the work order's copy when every extra contact was removed", () => {
const patch = buildSiteDialogPatch({ ...base, extraContacts: [] });
expect(patch.additionalContacts).toEqual([]);
});
it("leaves additional contacts off the patch when they were not edited", () => {
const patch = buildSiteDialogPatch({
...base,
contactsDirty: false,
extraContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
});
expect(patch).not.toHaveProperty("additionalContacts");
});
});

View file

@ -0,0 +1,225 @@
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("does not carry the previous site's contact onto a site whose record failed to load", async () => {
// The legacy contact fields too, so nothing can refill the cleared fields from DAL1.
const dallasWithContact: Location = { ...dallas, contact: "Jane", phone: "(421) 433-0032" };
getById.mockImplementation((id: string) =>
String(id) === "1"
? Promise.resolve(dallasWithContact)
: Promise.reject(new Error("Network error")),
);
const { onSave } = renderDialog();
expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument();
pickSite("CHI2");
expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument();
expect(primaryField("POC name")).toHaveValue("");
expect(primaryField("POC phone")).toHaveValue("");
expect(primaryField("Notes…")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
fireEvent.change(primaryField("POC name"), { target: { value: "Carl" } });
fireEvent.change(primaryField("POC phone"), { target: { value: "(312) 555-0101" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({
locationId: "2",
pocName: "Carl",
pocPhone: "(312) 555-0101",
pocNotes: "",
additionalContacts: [],
});
expect(updateContactInfo).not.toHaveBeenCalled();
});
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,438 @@
import type { 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 { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const getById = vi.fn();
const updateContactInfo = vi.fn();
const toastMocks = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
vi.mock("react-toastify", () => ({ toast: toastMocks }));
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]> = {},
options: { queryClient?: QueryClient } = {},
) {
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, ...options },
);
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();
toastMocks.error.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();
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("offers no per-contact notes on additional contacts, since site contacts cannot store them", async () => {
renderDialog();
await screen.findByDisplayValue("Bob");
// Only the site notes box: Bob's card has none.
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("POC name")).toHaveLength(3);
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(1);
});
it("keeps per-contact notes when the site record is unavailable and Save writes the work order only", async () => {
getById.mockRejectedValue(new Error("Network error"));
renderDialog();
await screen.findByText(SITE_UNAVAILABLE_COPY);
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(2);
});
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" }],
}),
);
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("shows one error toast when the site update fails, not the global one as well", async () => {
updateContactInfo.mockRejectedValue(new Error("Forbidden"));
renderDialog({}, { queryClient: createAppQueryClient() });
await screen.findByDisplayValue("Jane");
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await waitFor(() => expect(saveButton()).toBeEnabled());
expect(toastMocks.error).toHaveBeenCalledTimes(1);
expect(toastMocks.error).toHaveBeenCalledWith("Forbidden");
});
it("locks the fields while the site update is in flight, so no edit is lost", async () => {
let finishSiteUpdate: () => void = () => {};
updateContactInfo.mockReturnValue(
new Promise<void>((resolve) => {
finishSiteUpdate = resolve;
}),
);
const { onSave } = renderDialog();
await screen.findByDisplayValue("Jane");
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await screen.findByRole("button", { name: /saving/i });
expect(primaryField("POC name")).toBeDisabled();
expect(primaryField("POC phone")).toBeDisabled();
expect(primaryField("Notes…")).toBeDisabled();
expect(screen.getByRole("button", { name: /add point of contact/i })).toBeDisabled();
finishSiteUpdate();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ pocNotes: "" });
});
describe("on a site saved before the contacts list existed", () => {
const legacyDallas: Location = {
...dallas,
phone: "(421) 433-0050",
contact: "Legacy Lee",
contacts: undefined,
};
beforeEach(() => {
getById.mockResolvedValue(legacyDallas);
});
it("shows the site's legacy contact and saves a notes-only edit without retyping it", async () => {
updateContactInfo.mockResolvedValue(undefined);
const { onSave } = renderDialog();
expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument();
expect(primaryField("POC phone")).toHaveValue("(421) 433-0050");
expect(saveButton()).toBeDisabled();
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument();
// The legacy contact becomes the site's first contact row, unchanged.
expect(updateContactInfo).toHaveBeenCalledWith("1", {
contacts: [{ name: "Legacy Lee", phone: "(421) 433-0050" }],
notes: "Gate code 9999",
});
// Only the notes were edited, so the work order keeps its own contact override.
expect(onSave.mock.calls[0][0]).toMatchObject({
pocName: "Someone Else",
pocPhone: "(000) 000-0000",
pocNotes: "",
});
});
it("does not report a contact edit the user never made when the work order had no POC", async () => {
updateContactInfo.mockResolvedValue(undefined);
const { onSave } = renderDialog({ pocName: "", pocPhone: "" });
expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument();
expect(saveButton()).toBeDisabled();
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: "", pocPhone: "", pocNotes: "" });
});
});
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" } });
// Work-order-only contacts keep their own notes.
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: /remove point of contact 2/i }));
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

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CREATION_INLINE_STATUSES } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
const incompleteRow = {
id: "wo-1",
@ -70,21 +71,16 @@ describe("StatusCell closability gate", () => {
expect(offered).not.toContain("Scheduled");
expect(offered).not.toContain("Past Due");
expect(offered).not.toContain("Canceled");
expect(offered).not.toContain("Pending");
expect(offered).not.toContain(current);
expect(offered).toEqual(
[
"Pending",
"En Route",
"On Site",
"In Progress",
"Completed",
"Rescheduled",
"Pending Quote",
].filter((status) => status !== current),
["En Route", "On Site", "In Progress", "Completed", "Rescheduled", "Pending Quote"].filter(
(status) => status !== current,
),
);
});
it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => {
it("omits Pending and automatic statuses from Incomplete or Scheduled", () => {
for (const current of ["Incomplete", "Scheduled"] as const) {
const { unmount } = render(
<StatusCell
@ -95,7 +91,7 @@ describe("StatusCell closability gate", () => {
);
fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") }));
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /^pending$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull();
unmount();
@ -116,4 +112,23 @@ describe("StatusCell closability gate", () => {
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
});
it("disables Pending for inline creation when Schedule On has a concrete date", () => {
render(
<StatusCell
wo={{ ...incompleteRow, status: "Scheduled", scheduledOn: "2026-06-01" }}
statusOptions={CREATION_INLINE_STATUSES}
onChangeStatus={vi.fn()}
onOpenSlideOver={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(pending).toBeDisabled();
expect(pending).toHaveAttribute(
"title",
"Clear the Schedule On date before marking this work order as Pending.",
);
});
});

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

@ -77,7 +77,7 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
});
it("promotes Pending to Scheduled when picked on a dated draft", () => {
it("disables Pending when Schedule On has a concrete date", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
@ -95,7 +95,13 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(pending).toBeDisabled();
expect(pending).toHaveAttribute(
"title",
"Clear the Schedule On date before marking this work order as Pending.",
);
fireEvent.click(pending);
expect(onPatch).not.toHaveBeenCalled();
});
});

View file

@ -36,7 +36,7 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cel
}));
describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
it("keeps Scheduled when Incomplete is picked on a dated draft", () => {
it("disables Pending on a dated draft", () => {
const onChange = vi.fn();
const draft = {
...EMPTY_TABLE_DRAFT,
@ -66,8 +66,10 @@ describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
const pending = screen.getByRole("button", { name: /^pending$/i });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ status: "Scheduled" }));
expect(pending).toBeDisabled();
fireEvent.click(pending);
expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ status: "Pending" }));
});
});

View file

@ -6,6 +6,7 @@ import {
COMPLETED_DOC_BLOCK_MSG,
COMPLETED_MEDIA_BLOCK_MSG,
CREATION_INLINE_STATUSES,
PENDING_NEEDS_NO_DATE_MSG,
SCHEDULED_NEEDS_DATE_MSG,
formatAapCompletedBlockTitle,
getInlineStatusTransitions,
@ -21,7 +22,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
describe("wo-lifecycle-transitions", () => {
it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => {
expect(BOARD_INLINE_STATUSES).toEqual([
"Pending",
"En Route",
"On Site",
"In Progress",
@ -37,7 +37,6 @@ describe("wo-lifecycle-transitions", () => {
it("never offers the current status as a transition (SH-358)", () => {
expect(getInlineStatusTransitions("On Site")).toEqual([
"Pending",
"En Route",
"In Progress",
"Completed",
@ -67,6 +66,25 @@ describe("wo-lifecycle-transitions", () => {
});
});
it("disables Pending only when a concrete schedule date exists", () => {
expect(getStatusOptionState({ scheduledOn: "2026-06-01" }, "Pending", [])).toEqual({
disabled: true,
title: PENDING_NEEDS_NO_DATE_MSG,
});
expect(
getStatusOptionState({ scheduledOn: "", scheduleWeekOnly: false }, "Pending", []),
).toEqual({
disabled: false,
});
expect(
getStatusOptionState(
{ scheduledOn: "2026-06-01", scheduleWeekOnly: true, schedMode: "week" },
"Pending",
[],
),
).toEqual({ disabled: false });
});
it("promotes Incomplete/Pending when a concrete date exists", () => {
expect(
maybeAutoSchedulePatch({