feat(sites): delete a site from the detail panel with an open work-order warning

- Delete sits in the view-mode footer for Admin and Scheduler only; the row delete icon is gone
- Confirmation states the open work-order count and links to exactly those work orders
- Sites without open work orders confirm with the plain permanent-removal copy
- A server refusal keeps the site and shows the error
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:59:08 -03:00
parent ffc4a9da68
commit 5bcc0f7081
14 changed files with 369 additions and 94 deletions

View file

@ -1,42 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
interface LocationDeleteDialogProps {
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
target: LocationListItem | null;
}
export function LocationDeleteDialog({
isPending,
onClose,
onConfirm,
target,
}: LocationDeleteDialogProps) {
return (
<Dialog open={Boolean(target)} onClose={onClose}>
<DialogTitle>Delete Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,8 +1,6 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
@ -10,7 +8,6 @@ import {
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onOpen: (row: LocationListItem) => void;
rows: LocationListItem[];
selectedId?: string | null;
}
const COLUMN_COUNT = 8;
const COLUMN_COUNT = 7;
export function LocationsTable({
isLoading,
onDelete,
onOpen,
rows,
selectedId = null,
@ -46,9 +41,6 @@ export function LocationsTable({
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">POC Name</TableCell>
<TableCell className="font-semibold">POC Phone</TableCell>
<TableCell align="right" className="font-semibold">
<span className="sr-only">Actions</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -98,20 +90,6 @@ export function LocationsTable({
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Delete">
<IconButton
size="small"
aria-label={`Delete site ${row.name}`}
onClick={(event) => {
event.stopPropagation();
onDelete(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>

View file

@ -0,0 +1,86 @@
import { useNavigate } from "react-router";
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
interface SiteDeleteDialogProps {
open: boolean;
siteId: string;
siteCode: string;
onClose: () => void;
onDeleted: () => void;
}
function describeDeletion(siteCode: string, openCount: number): string {
if (openCount === 0) {
return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`;
}
const noun = openCount === 1 ? "work order" : "work orders";
return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`;
}
/** Delete confirmation; warns with the open work-order count and links to those work orders. */
export function SiteDeleteDialog({
open,
siteId,
siteCode,
onClose,
onDeleted,
}: SiteDeleteDialogProps) {
const navigate = useNavigate();
const deleteLocation = useDeleteLocation();
const { data: openWorkOrders, isLoading } = useSiteOpenWorkOrders(siteId, open);
const openCount = openWorkOrders?.count ?? 0;
return (
<Dialog
open={open}
onClose={() => {
if (!deleteLocation.isPending) {
onClose();
}
}}
aria-labelledby="site-delete-title"
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
>
<DialogTitle id="site-delete-title">Delete this site?</DialogTitle>
<DialogContent>
{isLoading && <CircularProgress size={20} aria-label="Checking open work orders" />}
{!isLoading && (
<DialogContentText>{describeDeletion(siteCode, openCount)}</DialogContentText>
)}
</DialogContent>
<DialogActions>
<Button variant="text" onClick={onClose} disabled={deleteLocation.isPending}>
Cancel
</Button>
{openCount > 0 && (
<Button
variant="outlined"
onClick={() => navigate(buildWorkOrderIdsHref(openWorkOrders?.workOrderIds ?? []))}
>
View open work orders
</Button>
)}
<Button
variant="contained"
color="error"
disabled={isLoading || deleteLocation.isPending}
onClick={() => deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
>
{deleteLocation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,8 +1,11 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
import { IconCloseButton } from "@/components/ui/icon-close-button";
import type { Location } from "@/domain/locations/types/location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { canDeleteSites } from "@/lib/auth/user-utils";
import { AuthContext } from "@/providers/auth-context";
import { SiteDeleteDialog } from "./site-delete-dialog";
import { SiteDetailEdit } from "./site-detail-edit";
import { SiteDetailView } from "./site-detail-view";
@ -36,6 +39,8 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () =
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
const [mode, setMode] = useState<"view" | "edit">("view");
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles);
const { data: location, isLoading, error } = useLocationDetail(siteId);
return (
@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
{canDelete && (
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
Delete
</Button>
)}
<Button variant="contained" onClick={() => setMode("edit")}>
Edit
</Button>
</Stack>
<SiteDeleteDialog
open={isDeleteOpen}
siteId={siteId}
siteCode={location.name ?? ""}
onClose={() => setIsDeleteOpen(false)}
onDeleted={onClose}
/>
</>
)}
</>

View file

@ -9,14 +9,11 @@ import {
TextField,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
import { useDebounce } from "@/hooks/use-debounce";
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
import { AddSiteDialog } from "./_components/add-site-dialog";
import { AreaFacet } from "./_components/area-facet";
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
import { LocationsTable } from "./_components/locations-table";
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
@ -25,12 +22,10 @@ export default function LocationsListPage() {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteLocation = useDeleteLocation();
const closePanel = useCallback(() => setSelectedId(null), []);
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
@ -50,20 +45,6 @@ export default function LocationsListPage() {
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
const deletedId = String(deleteTarget.id);
deleteLocation.mutate(deleteTarget.id, {
onSuccess: () => {
setDeleteTarget(null);
setSelectedId((current) => (current === deletedId ? null : current));
},
});
};
return (
<Box
className="flex flex-col gap-4 p-4"
@ -120,7 +101,6 @@ export default function LocationsListPage() {
rows={rows}
selectedId={selectedId}
onOpen={(row) => setSelectedId(String(row.id))}
onDelete={setDeleteTarget}
/>
<TablePagination
component="div"
@ -136,12 +116,6 @@ export default function LocationsListPage() {
/>
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
</Box>
);
}

View file

@ -6,10 +6,12 @@ import {
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
import type {
Location,
LocationSite,
SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@ -74,4 +76,9 @@ export const locationsApi = {
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
},
getOpenWorkOrders: async (id: string | number): Promise<SiteOpenWorkOrders> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.locations}/${id}/open-work-orders`);
return mapSiteOpenWorkOrders(handleApiResponse(data));
},
};

View file

@ -3,6 +3,7 @@ import type {
LocationContact,
LocationListItem,
LocationSite,
SiteOpenWorkOrders,
} from "@/domain/locations/types/location";
function readString(record: Record<string, unknown>, ...keys: string[]): string {
@ -254,3 +255,16 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
return payload;
}
export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders {
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const rawIds = item.workOrderIds ?? item.WorkOrderIds;
const workOrderIds = Array.isArray(rawIds)
? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0)
: [];
const count = item.count ?? item.Count;
return {
count: typeof count === "number" && count >= 0 ? count : workOrderIds.length,
workOrderIds,
};
}

View file

@ -69,3 +69,9 @@ export interface LocationsListResult {
page: number;
pageSize: number;
}
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
export interface SiteOpenWorkOrders {
count: number;
workOrderIds: number[];
}

View file

@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult<void, Error, string | num
mutationFn: (id: string | number) => locationsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
toast.success("Location deleted successfully!");
toast.success("Site deleted");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete location");
toast.error(error.message || "Failed to delete site");
},
});
}

View file

@ -0,0 +1,17 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { SiteOpenWorkOrders } from "@/domain/locations/types/location";
import { queryKeys } from "@/infra/query-key/query-key";
/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */
export function useSiteOpenWorkOrders(
siteId: string,
enabled: boolean,
): UseQueryResult<SiteOpenWorkOrders, Error> {
return useQuery({
queryKey: queryKeys.locations.openWorkOrders(siteId),
queryFn: () => locationsApi.getOpenWorkOrders(siteId),
enabled,
staleTime: 0,
});
}

View file

@ -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,

View file

@ -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");
}

View file

@ -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 <div data-testid="work-orders-url">{`${location.pathname}${location.search}`}</div>;
}
function renderAs(userRoles: string) {
const auth = { user: { userRoles } } as unknown as AuthContextValue;
return renderWithProviders(
<AuthContext.Provider value={auth}>
<Routes>
<Route path="/locations" element={<LocationsListPage />} />
<Route path="/workorders" element={<WorkOrdersProbe />} />
</Routes>
</AuthContext.Provider>,
{ withAuth: false, route: "/locations" },
);
}
async function openPanel(): Promise<HTMLElement> {
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();
});
});

View file

@ -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: [],
});
});
});