{
+ return useQuery({
+ queryKey: queryKeys.locations.openWorkOrders(siteId),
+ queryFn: () => locationsApi.getOpenWorkOrders(siteId),
+ enabled,
+ staleTime: 0,
+ });
+}
diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
index 2334b9b6..ebc73bee 100644
--- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts
+++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
@@ -61,6 +61,18 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
);
}
+/** Open = every lifecycle status except Completed (Canceled is not a board status). */
+const OPEN_STATUSES = ALL_WIZARD_STATUSES.filter((status) => status !== "Completed");
+
+/** Work Orders filtered to one site's open work orders; `sites` takes the location id. */
+export function buildSiteOpenWorkOrdersHref(siteId: string | number): string {
+ const params = new URLSearchParams({
+ sites: String(siteId),
+ statuses: OPEN_STATUSES.join(","),
+ });
+ return `/workorders?${params.toString()}`;
+}
+
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
@@ -79,6 +91,7 @@ export function parseDashboardDrilldownParams(
const types = parseTypes(params.get("types"));
const dispatchers = parseList(params.get("dispatchers"));
const regions = parseRegions(params.get("regions"));
+ const sites = [...new Set(parseList(params.get("sites")))];
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
const hasUplift = params.get("hasUplift") === "1";
@@ -90,6 +103,7 @@ export function parseDashboardDrilldownParams(
types.length,
dispatchers.length,
regions.length,
+ sites.length,
upliftStatuses.length,
hasUplift,
].some(Boolean);
@@ -122,6 +136,9 @@ export function parseDashboardDrilldownParams(
if (regions.length > 0) {
filters.regions = new Set(regions);
}
+ if (sites.length > 0) {
+ filters.sites = new Set(sites);
+ }
if (upliftStatuses.length > 0) {
filters.upliftStatuses = new Set(upliftStatuses);
}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index c7719463..3368da22 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -94,6 +94,8 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const,
+ openWorkOrders: (id: string | number) =>
+ [...queryKeys.locations.all, "open-work-orders", id] as const,
},
vendors: {
all: ["vendors"] as const,
diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts
index 035e91c0..e67c7bbf 100644
--- a/src/lib/auth/user-utils.ts
+++ b/src/lib/auth/user-utils.ts
@@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
return roles.includes("admin") || roles.includes("scheduler");
}
+
+/**
+ * Site deletion is limited to Admin and Scheduler (the server enforces the DeleteSites team
+ * permission; this only hides the control from roles that would get a 403).
+ */
+export function canDeleteSites(userRoles: string | null | undefined): boolean {
+ return hasUserRole(userRoles, "admin") || hasUserRole(userRoles, "scheduler");
+}
diff --git a/src/test/app/(protected)/locations/site-delete.test.tsx b/src/test/app/(protected)/locations/site-delete.test.tsx
new file mode 100644
index 00000000..10b2c1d1
--- /dev/null
+++ b/src/test/app/(protected)/locations/site-delete.test.tsx
@@ -0,0 +1,286 @@
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { HTTPError } from "ky";
+import { Route, Routes, useLocation } from "react-router";
+import { toast } from "react-toastify";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LocationsListPage from "@/app/(protected)/locations/index";
+import { normalizeApiRequestError } from "@/api/api-error";
+import type { Location, LocationListItem } from "@/domain/locations/types/location";
+import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
+import { renderWithProviders } from "@/test/test-utils";
+
+const getList = vi.fn();
+const getById = vi.fn();
+const getOpenWorkOrders = vi.fn();
+const deleteSite = vi.fn();
+
+vi.mock("@/domain/locations/api/locations-api", () => ({
+ locationsApi: {
+ getList: (...args: unknown[]) => getList(...args),
+ getById: (...args: unknown[]) => getById(...args),
+ getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args),
+ delete: (...args: unknown[]) => deleteSite(...args),
+ update: vi.fn(),
+ create: vi.fn(),
+ },
+}));
+
+vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
+ useAccountsList: () => ({
+ data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
+ isLoading: false,
+ }),
+}));
+
+vi.mock("@/hooks/use-debounce", () => ({
+ useDebounce: (value: string) => value,
+}));
+
+const site: Location = {
+ id: 1,
+ name: "DAL1",
+ clientName: "Acme Logistics",
+ accountId: 7,
+ address: "3811 Distribution Dr",
+ city: "Dallas",
+ state: "TX",
+ contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }],
+};
+
+const row: LocationListItem = {
+ id: 1,
+ name: "DAL1",
+ clientName: "Acme Logistics",
+ sitePhone: "",
+ address: "3811 Distribution Dr",
+ city: "Dallas",
+ state: "TX",
+ zipCode: "",
+ phone: "(421) 433-0032",
+ contact: "Jane",
+ contactEmail: "",
+ status: "Active",
+ accountId: 7,
+ contacts: site.contacts,
+};
+
+function WorkOrdersProbe() {
+ const location = useLocation();
+ return {`${location.pathname}${location.search}`}
;
+}
+
+function renderAs(userRoles: string) {
+ const auth = { user: { userRoles } } as unknown as AuthContextValue;
+ return renderWithProviders(
+
+
+ } />
+ } />
+
+ ,
+ { withAuth: false, route: "/locations" },
+ );
+}
+
+async function openPanel(): Promise {
+ fireEvent.click(await screen.findByRole("button", { name: "DAL1" }));
+ const panel = screen.getByRole("region", { name: "Site details" });
+ await within(panel).findByRole("button", { name: "Edit" });
+ return panel;
+}
+
+function forbidden(): HTTPError {
+ const body = { status: "Error", message: "You are not allowed to delete sites." };
+ const error = new HTTPError(
+ new Response(JSON.stringify(body), { status: 403 }),
+ new Request("https://api.example.test/api/locations/1"),
+ {} as never,
+ );
+ error.data = body;
+ return normalizeApiRequestError(error) as HTTPError;
+}
+
+describe("Delete a site from the detail panel", () => {
+ beforeEach(() => {
+ for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) {
+ mock.mockReset();
+ }
+ getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 });
+ getById.mockResolvedValue(site);
+ });
+
+ it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => {
+ renderAs(role);
+ const panel = await openPanel();
+
+ expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ });
+
+ it("hides Delete from a Dispatcher and removes the old row delete control", async () => {
+ renderAs("Dispatcher");
+ const panel = await openPanel();
+
+ expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument();
+ });
+
+ it("warns with the open work-order count and links to exactly those work orders", async () => {
+ getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] });
+ renderAs("Scheduler");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+
+ expect(
+ await within(dialog).findByText(
+ "This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.",
+ ),
+ ).toBeInTheDocument();
+ expect(getOpenWorkOrders).toHaveBeenCalledWith("1");
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
+
+ expect(await screen.findByTestId("work-orders-url")).toHaveTextContent(
+ "/workorders?ids=101,205,318",
+ );
+ expect(deleteSite).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }],
+ ["an open count with no ids", { count: 4, workOrderIds: [] }],
+ ])("links to the site's open work-order board when there are %s", async (_label, result) => {
+ getOpenWorkOrders.mockResolvedValue(result);
+ renderAs("Admin");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+ await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`));
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
+
+ const url = new URL(
+ (await screen.findByTestId("work-orders-url")).textContent ?? "",
+ "https://app.test",
+ );
+ expect(url.pathname).toBe("/workorders");
+ expect(url.searchParams.get("ids")).toBeNull();
+ expect(url.searchParams.get("sites")).toBe("1");
+ expect(url.searchParams.get("statuses")?.split(",")).toEqual([
+ "Incomplete",
+ "Pending",
+ "Scheduled",
+ "En Route",
+ "On Site",
+ "In Progress",
+ "Rescheduled",
+ "Pending Quote",
+ ]);
+ });
+
+ it("confirms without a warning when nothing is open, deletes and closes the panel", async () => {
+ getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
+ deleteSite.mockResolvedValue(undefined);
+ renderAs("Admin");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+
+ expect(
+ await within(dialog).findByText(
+ "This will permanently remove DAL1 from Sites. This cannot be undone.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ within(dialog).queryByRole("button", { name: "View open work orders" }),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1"));
+ await waitFor(() =>
+ expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
+ );
+ });
+
+ it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => {
+ getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error"));
+ getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] });
+ renderAs("Admin");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+
+ expect(await within(dialog).findByRole("alert")).toHaveTextContent(
+ "Couldn't check this site for open work orders. Try again before deleting it.",
+ );
+ expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
+ expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Try again" }));
+
+ expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument();
+ expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
+ expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled();
+ expect(deleteSite).not.toHaveBeenCalled();
+ });
+
+ it("holds Delete on reopen until the fresh open work-order count replaces the cached one", async () => {
+ let resolveRecheck: (value: { count: number; workOrderIds: number[] }) => void = () => {};
+ getOpenWorkOrders.mockResolvedValueOnce({ count: 0, workOrderIds: [] });
+ getOpenWorkOrders.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveRecheck = resolve;
+ }),
+ );
+ renderAs("Admin");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ let dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+ await within(dialog).findByText(
+ "This will permanently remove DAL1 from Sites. This cannot be undone.",
+ );
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog", { name: "Delete this site?" })).not.toBeInTheDocument(),
+ );
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+ await waitFor(() => expect(getOpenWorkOrders).toHaveBeenCalledTimes(2));
+
+ expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
+ expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
+ expect(within(dialog).getByLabelText("Checking open work orders")).toBeInTheDocument();
+
+ resolveRecheck({ count: 1, workOrderIds: [101] });
+
+ expect(await within(dialog).findByText(/It still has 1 open work order —/)).toBeInTheDocument();
+ expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
+ expect(deleteSite).not.toHaveBeenCalled();
+ });
+
+ it("keeps the site and shows the server's refusal when the API answers 403", async () => {
+ getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
+ deleteSite.mockRejectedValue(forbidden());
+ const toastError = vi.spyOn(toast, "error");
+ renderAs("Admin");
+ const panel = await openPanel();
+
+ fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
+ await within(dialog).findByText(/This cannot be undone/);
+ fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+
+ await waitFor(() =>
+ expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."),
+ );
+ expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument();
+ expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/locations/site-detail-panel.test.tsx b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
index 27e0edc4..8b5c2885 100644
--- a/src/test/app/(protected)/locations/site-detail-panel.test.tsx
+++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx
@@ -235,17 +235,4 @@ describe("Sites page detail panel", () => {
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
);
});
- it("row delete asks to confirm removing the site without opening the panel", async () => {
- renderWithProviders(, { withAuth: false });
-
- fireEvent.click(await screen.findByRole("button", { name: "Delete site DAL1" }));
-
- const confirm = await screen.findByRole("dialog", { name: "Delete this site?" });
- expect(
- within(confirm).getByText(
- "This will permanently remove DAL1 from Sites. This cannot be undone.",
- ),
- ).toBeInTheDocument();
- expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument();
- });
});
diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts
index 62ce35d1..cd8734ce 100644
--- a/src/test/domain/locations/mappers/location-mapper.test.ts
+++ b/src/test/domain/locations/mappers/location-mapper.test.ts
@@ -3,6 +3,7 @@ import {
mapLocation,
mapLocationListItem,
mapLocationToBackend,
+ mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
describe("location contacts hydration", () => {
@@ -147,3 +148,16 @@ describe("mapLocation site fields", () => {
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
});
});
+
+describe("mapSiteOpenWorkOrders", () => {
+ it("keeps the server count and drops invalid ids", () => {
+ expect(mapSiteOpenWorkOrders({ count: 240, workOrderIds: [101, "x", -3, 205] })).toEqual({
+ count: 240,
+ workOrderIds: [101, 205],
+ });
+ expect(mapSiteOpenWorkOrders({ Count: 0, WorkOrderIds: [] })).toEqual({
+ count: 0,
+ workOrderIds: [],
+ });
+ });
+});
diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
index 7faab8b0..eea7afe8 100644
--- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
+++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
+import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
+import {
+ buildSiteOpenWorkOrdersHref,
+ parseDashboardDrilldownParams,
+} from "@/domain/work-orders/utils/dashboard-drilldown-params";
import {
avetaPendingDrilldownSearch,
unassignedDrilldownSearch,
@@ -102,6 +106,39 @@ describe("parseDashboardDrilldownParams", () => {
expect(cleared.dispatchers.size).toBe(0);
});
+ it("turns the site open work-order link into that site's open, all-weeks board search", () => {
+ const href = buildSiteOpenWorkOrdersHref(12);
+ const filters = parseDashboardDrilldownParams(new URL(href, "https://app.test").searchParams);
+
+ expect(filters?.sites).toEqual(new Set(["12"]));
+ expect(filters?.workOrderIds.size).toBe(0);
+ const params = buildResolvedBoardSearchParams({
+ advApplied: filters,
+ search: "",
+ page: 0,
+ pageSize: 200,
+ sortBy: "",
+ sortDir: "",
+ locations: [{ id: 12, name: "DAL1", siteCode: "DAL1" }],
+ vendors: [],
+ });
+ expect(params).toMatchObject({
+ sites: ["DAL1"],
+ statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
+ datePreset: "Custom",
+ dateFrom: "2000-01-01",
+ dateTo: "2099-12-31",
+ includeDateless: true,
+ });
+ });
+
+ it("keeps an exact id set ahead of a site filter", () => {
+ const filters = parseDashboardDrilldownParams(new URLSearchParams({ ids: "4,9", sites: "12" }));
+
+ expect(filters?.workOrderIds).toEqual(new Set([4, 9]));
+ expect(filters?.sites.size).toBe(0);
+ });
+
it("rejects partial or malformed date ranges", () => {
expect(
parseDashboardDrilldownParams(