mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
Compare commits
24 commits
e8c3a8ef4f
...
19a3fce9b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a3fce9b7 | ||
|
|
24cbd09a60 | ||
|
|
5ace29eda4 | ||
|
|
7d5d015602 | ||
|
|
4771f6f8eb | ||
|
|
a61ab1919a | ||
|
|
52b682283c | ||
|
|
123b1ec9b5 | ||
|
|
c5d0429fa2 | ||
|
|
b6d5ad8a1e | ||
|
|
73c7094102 | ||
|
|
301fe44b0c | ||
|
|
53dcb6af9e | ||
|
|
f36a07c99c | ||
|
|
b55938104a | ||
|
|
414e584b36 | ||
|
|
62664894bd | ||
|
|
35714a917d | ||
|
|
c0e484275d | ||
|
|
7efc9e8914 | ||
|
|
ad500753e1 | ||
|
|
cccdeceb10 | ||
|
|
bea3540409 | ||
|
|
7eaf6bca18 |
24 changed files with 1602 additions and 114 deletions
268
e2e/work-orders/pending-status-menu.spec.ts
Normal file
268
e2e/work-orders/pending-status-menu.spec.ts
Normal 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" })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
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 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 { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
@ -14,6 +15,8 @@ type BuildSiteDialogPatchArgs = {
|
||||||
extraContacts: WorkOrderAdditionalContact[];
|
extraContacts: WorkOrderAdditionalContact[];
|
||||||
contactsDirty: boolean;
|
contactsDirty: boolean;
|
||||||
baselineHadContacts: 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({
|
export function buildSiteDialogPatch({
|
||||||
|
|
@ -26,6 +29,7 @@ export function buildSiteDialogPatch({
|
||||||
extraContacts,
|
extraContacts,
|
||||||
contactsDirty,
|
contactsDirty,
|
||||||
baselineHadContacts,
|
baselineHadContacts,
|
||||||
|
followsSiteRecord = false,
|
||||||
}: BuildSiteDialogPatchArgs): SitePatch {
|
}: BuildSiteDialogPatchArgs): SitePatch {
|
||||||
const patch: SitePatch = {
|
const patch: SitePatch = {
|
||||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||||
|
|
@ -35,8 +39,11 @@ export function buildSiteDialogPatch({
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
};
|
};
|
||||||
if (contactsDirty || baselineHadContacts) {
|
// Following the site record, the dialog's extra contacts are the site's: copy them only when edited.
|
||||||
patch.additionalContacts = extraContacts;
|
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;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
import { Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { SiteMapPreview } from "@/app/(protected)/locations/_components/site-map-preview";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
|
@ -34,6 +35,9 @@ export function SiteDialogDetailPanel({
|
||||||
addressPreview,
|
addressPreview,
|
||||||
fallbackLabel,
|
fallbackLabel,
|
||||||
}: SiteDialogDetailPanelProps) {
|
}: SiteDialogDetailPanelProps) {
|
||||||
|
// The site's own phone; a frozen snapshot carries it as `phone`.
|
||||||
|
const sitePhone = locationDetail?.sitePhone ?? locationDetail?.phone ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
|
@ -44,24 +48,29 @@ export function SiteDialogDetailPanel({
|
||||||
>
|
>
|
||||||
{locationDetail ? (
|
{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) && (
|
{Boolean(addressPreview) && (
|
||||||
<a
|
<a
|
||||||
href={buildMapsUrl(locationDetail)}
|
href={buildMapsUrl(locationDetail)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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)" }}
|
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>
|
</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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,17 @@ type SiteDialogFooterProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
viewOnly?: boolean;
|
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) {
|
if (viewOnly) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<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}>
|
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,25 @@ type SiteDialogPocSectionProps = {
|
||||||
additionalContacts: WorkOrderAdditionalContact[];
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
showPrimaryErrors?: boolean;
|
showPrimaryErrors?: boolean;
|
||||||
primaryRequired?: 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;
|
onPocNameChange: (value: string) => void;
|
||||||
onPocPhoneChange: (value: string) => void;
|
onPocPhoneChange: (value: string) => void;
|
||||||
onPocNotesChange: (value: string) => void;
|
onPocNotesChange: (value: string) => void;
|
||||||
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => 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({
|
export function SiteDialogPocSection({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
|
|
@ -21,6 +34,8 @@ export function SiteDialogPocSection({
|
||||||
additionalContacts,
|
additionalContacts,
|
||||||
showPrimaryErrors = false,
|
showPrimaryErrors = false,
|
||||||
primaryRequired = false,
|
primaryRequired = false,
|
||||||
|
editsSiteRecord = false,
|
||||||
|
siteRecordUnavailable = false,
|
||||||
onPocNameChange,
|
onPocNameChange,
|
||||||
onPocPhoneChange,
|
onPocPhoneChange,
|
||||||
onPocNotesChange,
|
onPocNotesChange,
|
||||||
|
|
@ -38,7 +53,9 @@ export function SiteDialogPocSection({
|
||||||
onPocPhoneChange={onPocPhoneChange}
|
onPocPhoneChange={onPocPhoneChange}
|
||||||
onPocNotesChange={onPocNotesChange}
|
onPocNotesChange={onPocNotesChange}
|
||||||
onAdditionalContactsChange={onAdditionalContactsChange}
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export function SiteDialog({
|
||||||
additionalContacts,
|
additionalContacts,
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
|
viewOnly,
|
||||||
frozenSite,
|
frozenSite,
|
||||||
onSave,
|
onSave,
|
||||||
});
|
});
|
||||||
|
|
@ -84,9 +85,10 @@ export function SiteDialog({
|
||||||
Select a site and edit its point of contact
|
Select a site and edit its point of contact
|
||||||
</Text>
|
</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>
|
<div>
|
||||||
<FieldLabel required={createMode}>Site</FieldLabel>
|
<FieldLabel required={s.requiresPoc}>Site</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={s.locId}
|
value={s.locId}
|
||||||
placeholder="Select site …"
|
placeholder="Select site …"
|
||||||
|
|
@ -107,7 +109,7 @@ export function SiteDialog({
|
||||||
<Text
|
<Text
|
||||||
as="p"
|
as="p"
|
||||||
variant="error"
|
variant="error"
|
||||||
when={Boolean(createMode && s.showErrors && s.siteMissing)}
|
when={Boolean(s.requiresPoc && s.showErrors && s.siteMissing)}
|
||||||
className="mt-1 text-[11px]"
|
className="mt-1 text-[11px]"
|
||||||
>
|
>
|
||||||
Site is required
|
Site is required
|
||||||
|
|
@ -124,21 +126,36 @@ export function SiteDialog({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SiteDialogPocSection
|
{/* Locked until the site record loads, so its values never replace typed input. */}
|
||||||
pocName={s.pn}
|
<fieldset
|
||||||
pocPhone={s.pp}
|
disabled={s.siteRecordLoading}
|
||||||
pocNotes={s.notes}
|
aria-busy={s.siteRecordLoading || undefined}
|
||||||
additionalContacts={s.extraContacts}
|
className="m-0 border-0 p-0"
|
||||||
showPrimaryErrors={createMode && s.showErrors}
|
>
|
||||||
primaryRequired={createMode}
|
<SiteDialogPocSection
|
||||||
onPocNameChange={s.setPn}
|
pocName={s.pn}
|
||||||
onPocPhoneChange={s.setPp}
|
pocPhone={s.pp}
|
||||||
onPocNotesChange={s.setNotes}
|
pocNotes={s.notes}
|
||||||
onAdditionalContactsChange={s.setExtraContacts}
|
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>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = {
|
||||||
createMode: boolean;
|
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({
|
export function useSiteDialogFormFields({
|
||||||
open,
|
open,
|
||||||
value,
|
value,
|
||||||
|
|
@ -23,10 +37,11 @@ export function useSiteDialogFormFields({
|
||||||
pocName,
|
pocName,
|
||||||
pocPhone,
|
pocPhone,
|
||||||
pocNotes,
|
pocNotes,
|
||||||
additionalContacts,
|
additionalContacts: incomingContacts,
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
}: UseSiteDialogFormFieldsArgs) {
|
}: UseSiteDialogFormFieldsArgs) {
|
||||||
|
const additionalContacts = useStableContacts(incomingContacts);
|
||||||
const [code, setCode] = useState(value);
|
const [code, setCode] = useState(value);
|
||||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||||
const [pn, setPn] = useState(pocName);
|
const [pn, setPn] = useState(pocName);
|
||||||
|
|
@ -36,15 +51,20 @@ export function useSiteDialogFormFields({
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [contactsDirty, setContactsDirty] = 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(
|
const [baselineHadContacts, setBaselineHadContacts] = useState(
|
||||||
() => additionalContacts.length > 0,
|
() => additionalContacts.length > 0,
|
||||||
);
|
);
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setContactsDirty(false);
|
setContactsDirty(false);
|
||||||
|
setUserEdited(false);
|
||||||
setShowErrors(false);
|
setShowErrors(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
@ -62,6 +82,7 @@ export function useSiteDialogFormFields({
|
||||||
setExtraContacts(additionalContacts);
|
setExtraContacts(additionalContacts);
|
||||||
setBaselineHadContacts(additionalContacts.length > 0);
|
setBaselineHadContacts(additionalContacts.length > 0);
|
||||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||||
|
setResetVersion((version) => version + 1);
|
||||||
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
|
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
|
||||||
|
|
||||||
const handlePick = (id: string) => {
|
const handlePick = (id: string) => {
|
||||||
|
|
@ -74,10 +95,13 @@ export function useSiteDialogFormFields({
|
||||||
if (siteChanged) {
|
if (siteChanged) {
|
||||||
setExtraContacts([]);
|
setExtraContacts([]);
|
||||||
setContactsDirty(true);
|
setContactsDirty(true);
|
||||||
|
setUserEdited(false);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
if (createMode) {
|
// The previous site's contact never carries over, even when the new site fails to load.
|
||||||
setPn("");
|
setPn("");
|
||||||
setPp("");
|
setPp("");
|
||||||
|
if (!createMode) {
|
||||||
|
setNotes("");
|
||||||
}
|
}
|
||||||
setPocFilledFor("");
|
setPocFilledFor("");
|
||||||
} else if (id !== pocFilledFor) {
|
} else if (id !== pocFilledFor) {
|
||||||
|
|
@ -87,6 +111,7 @@ export function useSiteDialogFormFields({
|
||||||
|
|
||||||
const markPocDirty = (apply: () => void) => {
|
const markPocDirty = (apply: () => void) => {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
setUserEdited(true);
|
||||||
apply();
|
apply();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -100,6 +125,8 @@ export function useSiteDialogFormFields({
|
||||||
pocFilledFor,
|
pocFilledFor,
|
||||||
dirty,
|
dirty,
|
||||||
contactsDirty,
|
contactsDirty,
|
||||||
|
userEdited,
|
||||||
|
resetVersion,
|
||||||
baselineHadContacts,
|
baselineHadContacts,
|
||||||
showErrors,
|
showErrors,
|
||||||
selected: sites.find((s) => String(s.id) === locId),
|
selected: sites.find((s) => String(s.id) === locId),
|
||||||
|
|
@ -108,6 +135,8 @@ export function useSiteDialogFormFields({
|
||||||
setPn,
|
setPn,
|
||||||
setPp,
|
setPp,
|
||||||
setShowErrors,
|
setShowErrors,
|
||||||
|
setNotes,
|
||||||
|
setExtraContacts,
|
||||||
markDirty: () => setDirty(true),
|
markDirty: () => setDirty(true),
|
||||||
handlePick,
|
handlePick,
|
||||||
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
|
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
|
||||||
|
|
@ -115,6 +144,7 @@ export function useSiteDialogFormFields({
|
||||||
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
|
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
|
||||||
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
|
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
setUserEdited(true);
|
||||||
setContactsDirty(true);
|
setContactsDirty(true);
|
||||||
setExtraContacts(contacts);
|
setExtraContacts(contacts);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useEffect, useMemo } 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 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 { 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 { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
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 { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
@ -21,10 +23,25 @@ type UseSiteDialogStateArgs = {
|
||||||
additionalContacts: WorkOrderAdditionalContact[];
|
additionalContacts: WorkOrderAdditionalContact[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
createMode: boolean;
|
createMode: boolean;
|
||||||
|
viewOnly?: boolean;
|
||||||
frozenSite?: WorkOrderFrozenSite;
|
frozenSite?: WorkOrderFrozenSite;
|
||||||
onSave: (patch: SitePatch) => void;
|
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({
|
export function useSiteDialogState({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -36,6 +53,7 @@ export function useSiteDialogState({
|
||||||
additionalContacts = [],
|
additionalContacts = [],
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
|
viewOnly = false,
|
||||||
frozenSite,
|
frozenSite,
|
||||||
onSave,
|
onSave,
|
||||||
}: UseSiteDialogStateArgs) {
|
}: UseSiteDialogStateArgs) {
|
||||||
|
|
@ -50,65 +68,51 @@ export function useSiteDialogState({
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
});
|
});
|
||||||
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
|
const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields;
|
||||||
const {
|
const live = frozenSite == null;
|
||||||
data: liveLocationDetail,
|
const liveDetail = useLocationDetail(live && open && locId ? locId : undefined);
|
||||||
isLoading: liveLocationDetailLoading,
|
|
||||||
isError: liveLocationDetailError,
|
|
||||||
} = useLocationDetail(frozenSite == null && open && locId ? locId : undefined);
|
|
||||||
const frozenLocationDetail = useMemo(
|
const frozenLocationDetail = useMemo(
|
||||||
() =>
|
() => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)),
|
||||||
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],
|
[frozenSite],
|
||||||
);
|
);
|
||||||
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
|
const locationDetail = frozenLocationDetail ?? liveDetail.data;
|
||||||
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
|
const locationDetailLoading = live && liveDetail.isLoading;
|
||||||
const locationDetailError = frozenSite == null && liveLocationDetailError;
|
const locationDetailError = live && liveDetail.isError;
|
||||||
|
|
||||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
const pocMissing = !pn.trim() || !pp.trim();
|
// An existing, editable work order edits the site record itself (contacts and notes).
|
||||||
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
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(() => {
|
useEffect(() => {
|
||||||
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||||
setPocFilledFor(locId);
|
setPocFilledFor(locId);
|
||||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
|
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locId: fields.locId,
|
locId: fields.locId,
|
||||||
|
|
@ -125,7 +129,13 @@ export function useSiteDialogState({
|
||||||
showErrors: fields.showErrors,
|
showErrors: fields.showErrors,
|
||||||
siteMissing: fields.siteMissing,
|
siteMissing: fields.siteMissing,
|
||||||
handlePick: fields.handlePick,
|
handlePick: fields.handlePick,
|
||||||
attemptSave,
|
attemptSave: save.attemptSave,
|
||||||
|
saveDisabled: save.saveDisabled,
|
||||||
|
saving: save.saving,
|
||||||
|
siteRecordLoading: siteRecord.loading,
|
||||||
|
editsSiteRecord,
|
||||||
|
siteRecordUnavailable,
|
||||||
|
requiresPoc,
|
||||||
setPn: fields.setPnDirty,
|
setPn: fields.setPnDirty,
|
||||||
setPp: fields.setPpDirty,
|
setPp: fields.setPpDirty,
|
||||||
setNotes: fields.setNotesDirty,
|
setNotes: fields.setNotesDirty,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,8 @@ type PocContactFieldsProps = {
|
||||||
showPrimaryErrors?: boolean;
|
showPrimaryErrors?: boolean;
|
||||||
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
||||||
primaryRequired?: boolean;
|
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 {
|
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||||
|
|
@ -49,11 +51,13 @@ function updateContactAt(
|
||||||
function AdditionalPocCard({
|
function AdditionalPocCard({
|
||||||
contact,
|
contact,
|
||||||
index,
|
index,
|
||||||
|
showNotes,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
contact: WorkOrderAdditionalContact;
|
contact: WorkOrderAdditionalContact;
|
||||||
index: number;
|
index: number;
|
||||||
|
showNotes: boolean;
|
||||||
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
|
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -115,14 +119,16 @@ function AdditionalPocCard({
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
{showNotes && (
|
||||||
value={contact.notes ?? ""}
|
<textarea
|
||||||
onChange={(event) => onChange({ notes: event.target.value })}
|
value={contact.notes ?? ""}
|
||||||
placeholder="Notes…"
|
onChange={(event) => onChange({ notes: event.target.value })}
|
||||||
rows={2}
|
placeholder="Notes…"
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
rows={2}
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
/>
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +145,7 @@ export function PocContactFields({
|
||||||
helperText,
|
helperText,
|
||||||
showPrimaryErrors = false,
|
showPrimaryErrors = false,
|
||||||
primaryRequired = false,
|
primaryRequired = false,
|
||||||
|
additionalContactNotes = true,
|
||||||
}: PocContactFieldsProps) {
|
}: PocContactFieldsProps) {
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
const nameMissing = showPrimaryErrors && !pocName.trim();
|
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||||
|
|
@ -214,6 +221,7 @@ export function PocContactFields({
|
||||||
key={`additional-poc-${index}`}
|
key={`additional-poc-${index}`}
|
||||||
contact={contact}
|
contact={contact}
|
||||||
index={index}
|
index={index}
|
||||||
|
showNotes={additionalContactNotes}
|
||||||
onChange={(patch) =>
|
onChange={(patch) =>
|
||||||
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapLocation,
|
mapLocation,
|
||||||
|
mapContactsToBackend,
|
||||||
mapLocationListItems,
|
mapLocationListItems,
|
||||||
mapLocationSites,
|
mapLocationSites,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
Location,
|
Location,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
|
SiteContactInfoInput,
|
||||||
SiteOpenWorkOrders,
|
SiteOpenWorkOrders,
|
||||||
LocationsListParams,
|
LocationsListParams,
|
||||||
LocationsListResult,
|
LocationsListResult,
|
||||||
|
|
@ -73,6 +75,14 @@ export const locationsApi = {
|
||||||
return mapLocation(handleApiResponse(data));
|
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> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@ export interface LocationsListResult {
|
||||||
pageSize: number;
|
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). */
|
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
|
||||||
export interface SiteOpenWorkOrders {
|
export interface SiteOpenWorkOrders {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ export interface WorkOrderAdditionalContact {
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
notes?: 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 = {
|
export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ import {
|
||||||
*/
|
*/
|
||||||
export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"];
|
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(
|
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. */
|
/** 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 =
|
export const SCHEDULED_NEEDS_DATE_MSG =
|
||||||
"Set a Schedule On date before marking this work order as Scheduled.";
|
"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 =
|
export const PAST_DUE_BADGE_TOOLTIP =
|
||||||
"This WO missed its service window. Reschedule to unlock status updates.";
|
"This WO missed its service window. Reschedule to unlock status updates.";
|
||||||
|
|
@ -130,6 +134,9 @@ export function getStatusOptionState(
|
||||||
status: WizardWOStatus,
|
status: WizardWOStatus,
|
||||||
closabilityGaps: string[],
|
closabilityGaps: string[],
|
||||||
): StatusOptionState {
|
): StatusOptionState {
|
||||||
|
if (status === "Pending" && hasConcreteScheduleDate(row)) {
|
||||||
|
return { disabled: true, title: PENDING_NEEDS_NO_DATE_MSG };
|
||||||
|
}
|
||||||
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
||||||
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
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 { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 = {
|
const incompleteRow = {
|
||||||
id: "wo-1",
|
id: "wo-1",
|
||||||
|
|
@ -70,21 +71,16 @@ describe("StatusCell closability gate", () => {
|
||||||
expect(offered).not.toContain("Scheduled");
|
expect(offered).not.toContain("Scheduled");
|
||||||
expect(offered).not.toContain("Past Due");
|
expect(offered).not.toContain("Past Due");
|
||||||
expect(offered).not.toContain("Canceled");
|
expect(offered).not.toContain("Canceled");
|
||||||
|
expect(offered).not.toContain("Pending");
|
||||||
expect(offered).not.toContain(current);
|
expect(offered).not.toContain(current);
|
||||||
expect(offered).toEqual(
|
expect(offered).toEqual(
|
||||||
[
|
["En Route", "On Site", "In Progress", "Completed", "Rescheduled", "Pending Quote"].filter(
|
||||||
"Pending",
|
(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) {
|
for (const current of ["Incomplete", "Scheduled"] as const) {
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<StatusCell
|
<StatusCell
|
||||||
|
|
@ -95,7 +91,7 @@ describe("StatusCell closability gate", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") }));
|
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: /^incomplete$/i })).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull();
|
||||||
unmount();
|
unmount();
|
||||||
|
|
@ -116,4 +112,23 @@ describe("StatusCell closability gate", () => {
|
||||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
|
||||||
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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[] = [
|
const SITES: LocationOption[] = [
|
||||||
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
|
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
|
||||||
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
|
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
|
||||||
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
|
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();
|
const onPatch = vi.fn();
|
||||||
render(
|
render(
|
||||||
<WizardStepVendorTime
|
<WizardStepVendorTime
|
||||||
|
|
@ -95,7 +95,13 @@ describe("WizardStepVendorTime creation statuses (AAP)", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
const pending = screen.getByRole("button", { name: /^pending$/i });
|
||||||
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cel
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
|
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 onChange = vi.fn();
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_TABLE_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: /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" }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
COMPLETED_DOC_BLOCK_MSG,
|
COMPLETED_DOC_BLOCK_MSG,
|
||||||
COMPLETED_MEDIA_BLOCK_MSG,
|
COMPLETED_MEDIA_BLOCK_MSG,
|
||||||
CREATION_INLINE_STATUSES,
|
CREATION_INLINE_STATUSES,
|
||||||
|
PENDING_NEEDS_NO_DATE_MSG,
|
||||||
SCHEDULED_NEEDS_DATE_MSG,
|
SCHEDULED_NEEDS_DATE_MSG,
|
||||||
formatAapCompletedBlockTitle,
|
formatAapCompletedBlockTitle,
|
||||||
getInlineStatusTransitions,
|
getInlineStatusTransitions,
|
||||||
|
|
@ -21,7 +22,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
describe("wo-lifecycle-transitions", () => {
|
describe("wo-lifecycle-transitions", () => {
|
||||||
it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => {
|
it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => {
|
||||||
expect(BOARD_INLINE_STATUSES).toEqual([
|
expect(BOARD_INLINE_STATUSES).toEqual([
|
||||||
"Pending",
|
|
||||||
"En Route",
|
"En Route",
|
||||||
"On Site",
|
"On Site",
|
||||||
"In Progress",
|
"In Progress",
|
||||||
|
|
@ -37,7 +37,6 @@ describe("wo-lifecycle-transitions", () => {
|
||||||
|
|
||||||
it("never offers the current status as a transition (SH-358)", () => {
|
it("never offers the current status as a transition (SH-358)", () => {
|
||||||
expect(getInlineStatusTransitions("On Site")).toEqual([
|
expect(getInlineStatusTransitions("On Site")).toEqual([
|
||||||
"Pending",
|
|
||||||
"En Route",
|
"En Route",
|
||||||
"In Progress",
|
"In Progress",
|
||||||
"Completed",
|
"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", () => {
|
it("promotes Incomplete/Pending when a concrete date exists", () => {
|
||||||
expect(
|
expect(
|
||||||
maybeAutoSchedulePatch({
|
maybeAutoSchedulePatch({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue