shoc-frontend-new/src/app/(protected)/locations/index.tsx
Alexandre Brandizzi a75b9c8be6 feat(sites): Add Site modal and site detail panel with in-place edit
- 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
2026-09-25 11:43:30 -03:00

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