mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Compare commits
17 commits
7f023c5a45
...
818357d18b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818357d18b | ||
|
|
24cbd09a60 | ||
|
|
5ace29eda4 | ||
|
|
7d5d015602 | ||
|
|
4771f6f8eb | ||
|
|
a61ab1919a | ||
|
|
52b682283c | ||
|
|
123b1ec9b5 | ||
|
|
c5d0429fa2 | ||
|
|
b6d5ad8a1e | ||
|
|
0aecdf1b10 | ||
|
|
db6027f9c5 | ||
|
|
a2b985b14a | ||
|
|
4f90e0dce1 | ||
|
|
25b4d49811 | ||
|
|
f0b4ced660 | ||
|
|
f6849ed9d0 |
17 changed files with 581 additions and 68 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" })]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export function SiteDialogDetailPanel({
|
|||
city={locationDetail.city}
|
||||
state={locationDetail.state}
|
||||
zipCode={locationDetail.zipCode}
|
||||
hideLabel
|
||||
height={120}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
28
src/lib/address/google-maps.ts
Normal file
28
src/lib/address/google-maps.ts
Normal 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` : "";
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
26
src/test/domain/locations/utils/site-maps.test.ts
Normal file
26
src/test/domain/locations/utils/site-maps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
33
src/test/lib/address/google-maps.test.ts
Normal file
33
src/test/lib/address/google-maps.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue