diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx deleted file mode 100644 index ed87aa3c..00000000 --- a/src/app/(protected)/locations/_components/location-delete-dialog.tsx +++ /dev/null @@ -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 ( - - Delete this site? - - - This will permanently remove {target?.name} from Sites. This cannot be undone. - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index 840a06c0..a7332a76 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -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({ State POC Name POC Phone - - Actions - @@ -98,20 +90,6 @@ export function LocationsTable({ {row.phone || "—"} - - - { - event.stopPropagation(); - onDelete(row); - }} - > - - - - ))} diff --git a/src/app/(protected)/locations/_components/site-delete-dialog.tsx b/src/app/(protected)/locations/_components/site-delete-dialog.tsx new file mode 100644 index 00000000..5b7ba58b --- /dev/null +++ b/src/app/(protected)/locations/_components/site-delete-dialog.tsx @@ -0,0 +1,110 @@ +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 { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +interface SiteDeleteDialogProps { + open: boolean; + siteId: string; + siteCode: string; + onClose: () => void; + onDeleted: () => void; +} + +const CHECK_FAILED_MESSAGE = + "Couldn't check this site for open work orders. Try again before deleting it."; + +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, + isError, + isFetching, + refetch, + } = useSiteOpenWorkOrders(siteId, open); + const openCount = openWorkOrders?.count ?? 0; + // Without a current count the warning cannot be trusted, so Delete waits for a successful check. + // `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands. + const countUnknown = isLoading || isFetching || isError; + // The server caps the id list; when it doesn't cover the stated count, open the site's whole board. + const openIds = openWorkOrders?.workOrderIds ?? []; + const openWorkOrdersHref = + openIds.length > 0 && openIds.length >= openCount + ? buildWorkOrderIdsHref(openIds) + : buildSiteOpenWorkOrdersHref(siteId); + + return ( + { + if (!deleteLocation.isPending) { + onClose(); + } + }} + aria-labelledby="site-delete-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + Delete this site? + + {isFetching && !isError && ( + + )} + {isError && {CHECK_FAILED_MESSAGE}} + {!countUnknown && ( + {describeDeletion(siteCode, openCount)} + )} + + + + {isError && ( + + )} + {!countUnknown && openCount > 0 && ( + + )} + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx index 3111bfa5..06e78919 100644 --- a/src/app/(protected)/locations/_components/site-detail-panel.tsx +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -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 && ( + + )} + setIsDeleteOpen(false)} + onDeleted={onClose} + /> )} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 52308aad..6492b96b 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -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([]); - const [deleteTarget, setDeleteTarget] = useState(null); const [isAddOpen, setIsAddOpen] = useState(false); const [selectedId, setSelectedId] = useState(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 ( setSelectedId(String(row.id))} - onDelete={setDeleteTarget} /> setIsAddOpen(false)} /> - setDeleteTarget(null)} - onConfirm={handleDeleteConfirm} - /> ); } diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 2f9f1376..728980e8 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -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 => { await apiDelete(`${API_PATHS.rest.locations}/${id}`); }, + + getOpenWorkOrders: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.locations}/${id}/open-work-orders`); + return mapSiteOpenWorkOrders(handleApiResponse(data)); + }, }; diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 99241eb0..dfb0a25f 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -3,6 +3,7 @@ import type { LocationContact, LocationListItem, LocationSite, + SiteOpenWorkOrders, } from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { @@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record return payload; } + +export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders { + const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + 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, + }; +} diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 77cadb17..16829026 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -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[]; +} diff --git a/src/domain/locations/use-cases/use-site-open-work-orders.ts b/src/domain/locations/use-cases/use-site-open-work-orders.ts new file mode 100644 index 00000000..f405c7ff --- /dev/null +++ b/src/domain/locations/use-cases/use-site-open-work-orders.ts @@ -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 { + 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(