Compare commits

...

17 commits

Author SHA1 Message Date
Alexandre Brandizzi
818357d18b
Merge 0aecdf1b10 into 24cbd09a60 2026-09-29 13:28:07 -03:00
Alexandre Brandizzi
24cbd09a60
Merge pull request #284 from Sea-Haven-Industries/fix/ab/sh-357-pending-table-only-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-357: remove Pending from existing work order status menu
2026-09-29 16:22:11 +00:00
Alexandre Brandizzi
5ace29eda4 test: expect Pending disabled on dated inline draft 2026-09-29 13:12:11 -03:00
Alexandre Brandizzi
7d5d015602 fix: require undated drafts for Pending status 2026-09-29 13:04:51 -03:00
Alexandre Brandizzi
4771f6f8eb test: cover pending schedule date promotion 2026-09-29 12:52:02 -03:00
Alexandre Brandizzi
a61ab1919a test: find pending creation in unscheduled results 2026-09-29 12:43:38 -03:00
Alexandre Brandizzi
52b682283c test: scope pending wizard selection assertion 2026-09-29 12:33:15 -03:00
Alexandre Brandizzi
123b1ec9b5 test: cover pending work order status workflows 2026-09-29 12:25:56 -03:00
Alexandre Brandizzi
c5d0429fa2 style(work-orders): format status menu expectations 2026-09-29 11:46:12 -03:00
Alexandre Brandizzi
b6d5ad8a1e fix(work-orders): hide Pending from existing status menus 2026-09-29 11:28:26 -03:00
Alexandre Brandizzi
0aecdf1b10 Merge remote-tracking branch 'origin/feat/ab/sh-335-wo-site-dialog' into feat/ab/sh-312-site-map-preview 2026-09-25 17:45:22 -03:00
Alexandre Brandizzi
db6027f9c5 fix(sites): Edit form map preview includes the stored ZIP
The Edit form's preview now uses the same address line as the Street
Address link and the view mode, ZIP included.
2026-09-25 17:27:32 -03:00
Alexandre Brandizzi
a2b985b14a Merge remote-tracking branch 'origin/feat/ab/sh-335-wo-site-dialog' into feat/ab/sh-312-site-map-preview 2026-09-25 17:12:51 -03:00
Alexandre Brandizzi
4f90e0dce1 Merge remote-tracking branch 'origin/feat/ab/sh-335-wo-site-dialog' into feat/ab/sh-312-site-map-preview 2026-09-25 16:42:55 -03:00
Alexandre Brandizzi
25b4d49811 Merge remote-tracking branch 'origin/main' into feat/ab/sh-312-site-map-preview 2026-09-25 16:42:55 -03:00
Alexandre Brandizzi
f0b4ced660 feat(sites): show the site address map preview through one shared Maps helper
Vendors and Sites now build the keyless embed URL and the Open in Google
Maps link through one helper. The Site map shows the placeholder only in
the Add/Edit forms; view mode and the Work Order Site dialog always render
it for the saved address, the dialog in its compact form.
2026-09-25 13:39:29 -03:00
Alexandre Brandizzi
f6849ed9d0 Merge remote-tracking branch 'origin/feat/ab/sh-335-wo-site-dialog' into feat/ab/sh-312-site-map-preview 2026-09-25 13:31:03 -03:00
17 changed files with 581 additions and 68 deletions

View file

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

View file

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

View file

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

View file

@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({
city={locationDetail.city}
state={locationDetail.state}
zipCode={locationDetail.zipCode}
hideLabel
height={120}
/>
</>
) : (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -102,7 +102,13 @@ describe("Work order Site dialog edits the site record", () => {
"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();
const map = screen.getByTitle("Site location map");
expect(map).toHaveAttribute(
"src",
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201&output=embed",
);
expect(map).toHaveAttribute("loading", "lazy");
expect(screen.queryByText("Enter an address to preview the location")).not.toBeInTheDocument();
expect(screen.getByText("(214) 555-0100")).toBeInTheDocument();
expect(screen.getByText("dal1@acme.test")).toBeInTheDocument();
expect(screen.getByText(SITE_RECORD_COPY)).toBeInTheDocument();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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