{
+ return useQuery({
+ queryKey: queryKeys.locations.openWorkOrders(siteId),
+ queryFn: () => locationsApi.getOpenWorkOrders(siteId),
+ enabled,
+ staleTime: 0,
+ });
+}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index eb8a0664..d23b4c0b 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -88,6 +88,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..19ec50fe
--- /dev/null
+++ b/src/test/app/(protected)/locations/site-delete.test.tsx
@@ -0,0 +1,194 @@
+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("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("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/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts
index 4547267b..82a0865e 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", () => {
@@ -132,3 +133,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: [],
+ });
+ });
+});