mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +00:00
Merge pull request #273 from Sea-Haven-Industries/feat/ab/sh-334-site-delete
feat(sites): delete a site from the detail panel with an open work-order warning
This commit is contained in:
commit
e8cdb9814a
16 changed files with 538 additions and 106 deletions
|
|
@ -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 this site?</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText>
|
|
||||||
This will permanently remove {target?.name} from Sites. This 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -10,7 +8,6 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
|
@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell";
|
||||||
|
|
||||||
interface LocationsTableProps {
|
interface LocationsTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onDelete: (row: LocationListItem) => void;
|
|
||||||
onOpen: (row: LocationListItem) => void;
|
onOpen: (row: LocationListItem) => void;
|
||||||
rows: LocationListItem[];
|
rows: LocationListItem[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_COUNT = 8;
|
const COLUMN_COUNT = 7;
|
||||||
|
|
||||||
export function LocationsTable({
|
export function LocationsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
onDelete,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
rows,
|
rows,
|
||||||
selectedId = null,
|
selectedId = null,
|
||||||
|
|
@ -46,9 +41,6 @@ export function LocationsTable({
|
||||||
<TableCell className="font-semibold">State</TableCell>
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
<TableCell className="font-semibold">POC Name</TableCell>
|
<TableCell className="font-semibold">POC Name</TableCell>
|
||||||
<TableCell className="font-semibold">POC Phone</TableCell>
|
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||||
<TableCell align="right" className="font-semibold">
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -98,20 +90,6 @@ export function LocationsTable({
|
||||||
<LocationsContactsCell row={row} />
|
<LocationsContactsCell row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.phone || "—"}</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
|
||||||
110
src/app/(protected)/locations/_components/site-delete-dialog.tsx
Normal file
110
src/app/(protected)/locations/_components/site-delete-dialog.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
{isFetching && !isError && (
|
||||||
|
<CircularProgress size={20} aria-label="Checking open work orders" />
|
||||||
|
)}
|
||||||
|
{isError && <DialogContentText role="alert">{CHECK_FAILED_MESSAGE}</DialogContentText>}
|
||||||
|
{!countUnknown && (
|
||||||
|
<DialogContentText>{describeDeletion(siteCode, openCount)}</DialogContentText>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="text" onClick={onClose} disabled={deleteLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{isError && (
|
||||||
|
<Button variant="outlined" disabled={isFetching} onClick={() => void refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!countUnknown && openCount > 0 && (
|
||||||
|
<Button variant="outlined" onClick={() => navigate(openWorkOrdersHref)}>
|
||||||
|
View open work orders
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={countUnknown || deleteLocation.isPending}
|
||||||
|
onClick={() => deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
|
||||||
|
>
|
||||||
|
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||||
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
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 { SiteDetailEdit } from "./site-detail-edit";
|
||||||
import { SiteDetailView } from "./site-detail-view";
|
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 }) {
|
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
||||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
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);
|
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
|
||||||
className="border-t px-4 py-3"
|
className="border-t px-4 py-3"
|
||||||
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||||
>
|
>
|
||||||
|
{canDelete && (
|
||||||
|
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="contained" onClick={() => setMode("edit")}>
|
<Button variant="contained" onClick={() => setMode("edit")}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<SiteDeleteDialog
|
||||||
|
open={isDeleteOpen}
|
||||||
|
siteId={siteId}
|
||||||
|
siteCode={location.name ?? ""}
|
||||||
|
onClose={() => setIsDeleteOpen(false)}
|
||||||
|
onDeleted={onClose}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||||
import { AddSiteDialog } from "./_components/add-site-dialog";
|
import { AddSiteDialog } from "./_components/add-site-dialog";
|
||||||
import { AreaFacet } from "./_components/area-facet";
|
import { AreaFacet } from "./_components/area-facet";
|
||||||
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
|
||||||
import { LocationsTable } from "./_components/locations-table";
|
import { LocationsTable } from "./_components/locations-table";
|
||||||
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
||||||
|
|
||||||
|
|
@ -25,12 +22,10 @@ export default function LocationsListPage() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteLocation = useDeleteLocation();
|
|
||||||
const closePanel = useCallback(() => setSelectedId(null), []);
|
const closePanel = useCallback(() => setSelectedId(null), []);
|
||||||
|
|
||||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||||
|
|
@ -50,20 +45,6 @@ export default function LocationsListPage() {
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
|
|
@ -120,7 +101,6 @@ export default function LocationsListPage() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onOpen={(row) => setSelectedId(String(row.id))}
|
onOpen={(row) => setSelectedId(String(row.id))}
|
||||||
onDelete={setDeleteTarget}
|
|
||||||
/>
|
/>
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|
@ -136,12 +116,6 @@ export default function LocationsListPage() {
|
||||||
/>
|
/>
|
||||||
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||||
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||||
<LocationDeleteDialog
|
|
||||||
target={deleteTarget}
|
|
||||||
isPending={deleteLocation.isPending}
|
|
||||||
onClose={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import {
|
||||||
mapLocationListItems,
|
mapLocationListItems,
|
||||||
mapLocationSites,
|
mapLocationSites,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
|
mapSiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
import type {
|
import type {
|
||||||
Location,
|
Location,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
|
SiteOpenWorkOrders,
|
||||||
LocationsListParams,
|
LocationsListParams,
|
||||||
LocationsListResult,
|
LocationsListResult,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
@ -74,4 +76,9 @@ export const locationsApi = {
|
||||||
delete: async (id: string | number): Promise<void> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
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));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
LocationContact,
|
LocationContact,
|
||||||
LocationListItem,
|
LocationListItem,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
|
SiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
|
@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
||||||
|
|
||||||
return payload;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,9 @@ export interface LocationsListResult {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
|
||||||
|
export interface SiteOpenWorkOrders {
|
||||||
|
count: number;
|
||||||
|
workOrderIds: number[];
|
||||||
|
}
|
||||||
|
|
|
||||||
17
src/domain/locations/use-cases/use-site-open-work-orders.ts
Normal file
17
src/domain/locations/use-cases/use-site-open-work-orders.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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(
|
export function parseDashboardDrilldownParams(
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
): WorkOrderAdvancedFilters | null {
|
): WorkOrderAdvancedFilters | null {
|
||||||
|
|
@ -79,6 +91,7 @@ export function parseDashboardDrilldownParams(
|
||||||
const types = parseTypes(params.get("types"));
|
const types = parseTypes(params.get("types"));
|
||||||
const dispatchers = parseList(params.get("dispatchers"));
|
const dispatchers = parseList(params.get("dispatchers"));
|
||||||
const regions = parseRegions(params.get("regions"));
|
const regions = parseRegions(params.get("regions"));
|
||||||
|
const sites = [...new Set(parseList(params.get("sites")))];
|
||||||
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
|
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
|
||||||
const hasUplift = params.get("hasUplift") === "1";
|
const hasUplift = params.get("hasUplift") === "1";
|
||||||
|
|
||||||
|
|
@ -90,6 +103,7 @@ export function parseDashboardDrilldownParams(
|
||||||
types.length,
|
types.length,
|
||||||
dispatchers.length,
|
dispatchers.length,
|
||||||
regions.length,
|
regions.length,
|
||||||
|
sites.length,
|
||||||
upliftStatuses.length,
|
upliftStatuses.length,
|
||||||
hasUplift,
|
hasUplift,
|
||||||
].some(Boolean);
|
].some(Boolean);
|
||||||
|
|
@ -122,6 +136,9 @@ export function parseDashboardDrilldownParams(
|
||||||
if (regions.length > 0) {
|
if (regions.length > 0) {
|
||||||
filters.regions = new Set(regions);
|
filters.regions = new Set(regions);
|
||||||
}
|
}
|
||||||
|
if (sites.length > 0) {
|
||||||
|
filters.sites = new Set(sites);
|
||||||
|
}
|
||||||
if (upliftStatuses.length > 0) {
|
if (upliftStatuses.length > 0) {
|
||||||
filters.upliftStatuses = new Set(upliftStatuses);
|
filters.upliftStatuses = new Set(upliftStatuses);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ export const queryKeys = {
|
||||||
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
||||||
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
||||||
sites: (search = "") => [...queryKeys.locations.all, "sites", search] 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: {
|
vendors: {
|
||||||
all: ["vendors"] as const,
|
all: ["vendors"] as const,
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde
|
||||||
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
|
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
|
||||||
return roles.includes("admin") || roles.includes("scheduler");
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
286
src/test/app/(protected)/locations/site-delete.test.tsx
Normal file
286
src/test/app/(protected)/locations/site-delete.test.tsx
Normal file
|
|
@ -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 <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.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -235,17 +235,4 @@ describe("Sites page detail panel", () => {
|
||||||
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("row delete asks to confirm removing the site without opening the panel", async () => {
|
|
||||||
renderWithProviders(<LocationsListPage />, { 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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
mapLocation,
|
mapLocation,
|
||||||
mapLocationListItem,
|
mapLocationListItem,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
|
mapSiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
|
|
||||||
describe("location contacts hydration", () => {
|
describe("location contacts hydration", () => {
|
||||||
|
|
@ -147,3 +148,16 @@ describe("mapLocation site fields", () => {
|
||||||
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
avetaPendingDrilldownSearch,
|
avetaPendingDrilldownSearch,
|
||||||
unassignedDrilldownSearch,
|
unassignedDrilldownSearch,
|
||||||
|
|
@ -102,6 +106,39 @@ describe("parseDashboardDrilldownParams", () => {
|
||||||
expect(cleared.dispatchers.size).toBe(0);
|
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", () => {
|
it("rejects partial or malformed date ranges", () => {
|
||||||
expect(
|
expect(
|
||||||
parseDashboardDrilldownParams(
|
parseDashboardDrilldownParams(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue