mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 08:09:00 +00:00
- Sites page: Add Site modal with Site Code, Client, address, State/Area, optional Site Phone and Email, points of contact - Duplicate Site Code shows inline under the field and keeps the modal open - Row opens a non-modal detail panel; Street Address links to Google Maps; key-less map preview - Edit switches the panel in place with Site Code read-only; Save needs a valid change, Cancel makes no call - Site Phone is sent independently of the contacts; zip and title are carried through edits - Retire the separate new/edit location pages
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
CircularProgress,
|
|
Stack,
|
|
TablePagination,
|
|
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";
|
|
|
|
export default function LocationsListPage() {
|
|
const [search, setSearch] = useState("");
|
|
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]);
|
|
|
|
const listParams = useMemo(
|
|
() => ({
|
|
search: debouncedSearch,
|
|
page: page + 1,
|
|
pageSize,
|
|
states: stateCodes.length > 0 ? stateCodes : undefined,
|
|
}),
|
|
[debouncedSearch, page, pageSize, stateCodes],
|
|
);
|
|
|
|
const { data, isLoading, isFetching, error } = useLocationsList(listParams);
|
|
|
|
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"
|
|
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
|
|
>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
spacing={2}
|
|
sx={{
|
|
alignItems: { xs: "stretch", md: "center" },
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
|
<Typography variant="h5" component="h1">
|
|
Sites
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{totalCount} total
|
|
</Typography>
|
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
|
</Stack>
|
|
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
|
|
<AreaFacet
|
|
value={selectedAreas}
|
|
onChange={(zones) => {
|
|
setSelectedAreas(zones);
|
|
setPage(0);
|
|
}}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
value={search}
|
|
onChange={(event) => {
|
|
setSearch(event.target.value);
|
|
setPage(0);
|
|
}}
|
|
placeholder="Search sites..."
|
|
className="min-w-[220px]"
|
|
/>
|
|
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
|
|
Add Site
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
{Boolean(error) && (
|
|
<Alert severity="error">
|
|
{error instanceof Error ? error.message : "Failed to load sites"}
|
|
</Alert>
|
|
)}
|
|
<LocationsTable
|
|
isLoading={isLoading}
|
|
rows={rows}
|
|
selectedId={selectedId}
|
|
onOpen={(row) => setSelectedId(String(row.id))}
|
|
onDelete={setDeleteTarget}
|
|
/>
|
|
<TablePagination
|
|
component="div"
|
|
count={totalCount}
|
|
page={page}
|
|
onPageChange={(_event, nextPage) => setPage(nextPage)}
|
|
rowsPerPage={pageSize}
|
|
onRowsPerPageChange={(event) => {
|
|
setPageSize(Number(event.target.value));
|
|
setPage(0);
|
|
}}
|
|
rowsPerPageOptions={[12, 24, 48]}
|
|
/>
|
|
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
|
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
|
<LocationDeleteDialog
|
|
target={deleteTarget}
|
|
isPending={deleteLocation.isPending}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={handleDeleteConfirm}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|