359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||
import { ruRU } from "@mui/x-data-grid/locales";
|
||
import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared";
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { observer } from "mobx-react-lite";
|
||
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||
import { Box, CircularProgress, Tooltip } from "@mui/material";
|
||
|
||
export const RouteListPage = observer(() => {
|
||
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
|
||
const { carriers, getCarriers } = carrierStore;
|
||
const navigate = useNavigate();
|
||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||
const [rowId, setRowId] = useState<number | null>(null);
|
||
const [ids, setIds] = useState<number[]>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [paginationModel, setPaginationModel] = useState({
|
||
page: 0,
|
||
pageSize: 50,
|
||
});
|
||
const { language } = languageStore;
|
||
const canWriteRoutes = authStore.canWrite("routes");
|
||
const canShowRoutePreview =
|
||
authStore.canWrite("stations") &&
|
||
authStore.canWrite("sights") &&
|
||
authStore.canWrite("routes");
|
||
const canShowActionsColumn = canWriteRoutes || canShowRoutePreview;
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
setIsLoading(true);
|
||
await getCarriers("ru");
|
||
await getCarriers("en");
|
||
await getCarriers("zh");
|
||
await getRoutes();
|
||
setIsLoading(false);
|
||
|
||
const routeIds = routeStore.routes.data.map((r) => r.id);
|
||
loadCounts(routeIds);
|
||
};
|
||
fetchData();
|
||
}, [language, selectedCityStore.cityVersion]);
|
||
|
||
const columns: GridColDef[] = [
|
||
{
|
||
field: "carrier_id",
|
||
headerName: "Перевозчик",
|
||
width: 250,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<div className="w-full h-full flex items-center">
|
||
{params.value ? (
|
||
carriers[language].data.find(
|
||
(carrier) => carrier.id == params.value
|
||
)?.short_name
|
||
) : (
|
||
<Minus size={20} className="text-red-500" />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
field: "route_name",
|
||
headerName: "Название маршрута",
|
||
flex: 1,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<div className="w-full h-full flex items-center">
|
||
{params.value ? (
|
||
params.value
|
||
) : (
|
||
<Minus size={20} className="text-red-500" />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
field: "route_number",
|
||
headerName: "Номер маршрута",
|
||
flex: 1,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<div className="w-full h-full flex items-center">
|
||
{params.value ? (
|
||
params.value
|
||
) : (
|
||
<Minus size={20} className="text-red-500" />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
field: "route_sys_number",
|
||
headerName: "Номер трассы маршрута",
|
||
description: "Уникальный номер трассы маршрута, в т.ч. используемый системой \"Говорящий город\"",
|
||
flex: 1,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<div className="w-full h-full flex items-center">
|
||
{params.value ? (
|
||
params.value
|
||
) : (
|
||
<Minus size={20} className="text-red-500" />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
field: "route_direction",
|
||
headerName: "Направление",
|
||
flex: 1,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<p
|
||
className={
|
||
params.row.route_direction === "Прямой"
|
||
? "text-green-500"
|
||
: "text-red-500"
|
||
}
|
||
>
|
||
{params.row.route_direction}
|
||
</p>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
field: "sightCount",
|
||
headerName: "Достопримечательности",
|
||
width: 180,
|
||
align: "center" as const,
|
||
headerAlign: "center" as const,
|
||
sortable: true,
|
||
renderHeader: (params: any) => (
|
||
<Tooltip title="Отображает количество привязанных достопримечательностей">
|
||
<span>{params.colDef.headerName}</span>
|
||
</Tooltip>
|
||
),
|
||
renderCell: (params: GridRenderCellParams) => (
|
||
<div className="w-full h-full flex items-center justify-center">
|
||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
field: "stationCount",
|
||
headerName: "Остановки",
|
||
width: 120,
|
||
align: "center" as const,
|
||
headerAlign: "center" as const,
|
||
sortable: true,
|
||
renderHeader: (params: any) => (
|
||
<Tooltip title="Отображает количество привязанных остановок">
|
||
<span>{params.colDef.headerName}</span>
|
||
</Tooltip>
|
||
),
|
||
renderCell: (params: GridRenderCellParams) => (
|
||
<div className="w-full h-full flex items-center justify-center">
|
||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||
</div>
|
||
),
|
||
},
|
||
...(canShowActionsColumn ? [{
|
||
field: "actions",
|
||
headerName: "Действия",
|
||
width: 250,
|
||
align: "center" as const,
|
||
headerAlign: "center" as const,
|
||
sortable: false,
|
||
renderCell: (params: GridRenderCellParams) => {
|
||
return (
|
||
<div className="flex h-full gap-7 justify-center items-center">
|
||
{canWriteRoutes && (
|
||
<button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||
<Pencil size={20} className="text-blue-500" />
|
||
</button>
|
||
)}
|
||
{canShowRoutePreview && (
|
||
<button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||
<Map size={20} className="text-purple-500" />
|
||
</button>
|
||
)}
|
||
{canShowRoutePreview && (
|
||
<button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
||
<Monitor size={20} className="text-green-500" />
|
||
</button>
|
||
)}
|
||
{canWriteRoutes && (
|
||
<button
|
||
title="Удалить"
|
||
onClick={() => {
|
||
setIsDeleteModalOpen(true);
|
||
setRowId(params.row.id);
|
||
}}
|
||
>
|
||
<Trash2 size={20} className="text-red-500" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
}] : []),
|
||
];
|
||
|
||
const rows = useMemo(() => {
|
||
const { selectedCityId } = selectedCityStore;
|
||
if (!selectedCityId) {
|
||
return [];
|
||
}
|
||
const query = searchQuery.trim().toLowerCase();
|
||
let filtered = routes.data;
|
||
if (selectedCityId) {
|
||
const cityCarrierIds = new Set(
|
||
carriers["ru"].data
|
||
.filter((c) => c.city_id === selectedCityId)
|
||
.map((c) => c.id)
|
||
);
|
||
filtered = filtered.filter((route) => cityCarrierIds.has(route.carrier_id));
|
||
}
|
||
return filtered
|
||
.filter(
|
||
(route) =>
|
||
!query ||
|
||
(route.route_name ?? "").toLowerCase().includes(query) ||
|
||
String(route.route_number ?? "").toLowerCase().includes(query)
|
||
)
|
||
.map((route) => ({
|
||
id: route.id,
|
||
carrier_id: route.carrier_id,
|
||
route_number: route.route_number,
|
||
route_sys_number: route.route_sys_number,
|
||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||
route_name: route.route_name,
|
||
sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null,
|
||
stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null,
|
||
}));
|
||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]);
|
||
|
||
return (
|
||
<>
|
||
<LanguageSwitcher />
|
||
|
||
<div style={{ width: "100%" }}>
|
||
<div className="flex justify-between items-center mb-10">
|
||
<h1 className="text-2xl">Маршруты</h1>
|
||
{canWriteRoutes && (
|
||
<CreateButton
|
||
label="Создать маршрут"
|
||
path="/route/create"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{canWriteRoutes && ids.length > 0 && (
|
||
<div className="flex justify-end mb-5 duration-300">
|
||
<button
|
||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||
>
|
||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||
{ids.length})
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{(rows.length > 0 || searchQuery) && (
|
||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||
)}
|
||
|
||
<DataGrid
|
||
rows={rows}
|
||
columns={columns}
|
||
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
||
checkboxSelection={canWriteRoutes}
|
||
disableRowSelectionExcludeModel
|
||
disableRowSelectionOnClick
|
||
loading={isLoading}
|
||
paginationModel={paginationModel}
|
||
onPaginationModelChange={setPaginationModel}
|
||
pageSizeOptions={[50]}
|
||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||
onRowSelectionModelChange={
|
||
canWriteRoutes
|
||
? (newSelection: any) => {
|
||
if (Array.isArray(newSelection)) {
|
||
const selectedIds = newSelection.map((id: string | number) =>
|
||
Number(id)
|
||
);
|
||
setIds(selectedIds);
|
||
} else if (
|
||
newSelection &&
|
||
typeof newSelection === "object" &&
|
||
"ids" in newSelection
|
||
) {
|
||
const idsSet = newSelection.ids as Set<string | number>;
|
||
const selectedIds = Array.from(idsSet).map(
|
||
(id: string | number) => Number(id)
|
||
);
|
||
setIds(selectedIds);
|
||
} else {
|
||
setIds([]);
|
||
}
|
||
}
|
||
: undefined
|
||
}
|
||
slots={{
|
||
noRowsOverlay: () => (
|
||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||
{isLoading ? (
|
||
<CircularProgress size={20} />
|
||
) : !selectedCityStore.selectedCityId ? (
|
||
"Выберите город"
|
||
) : (
|
||
"Нет маршрутов"
|
||
)}
|
||
</Box>
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<DeleteModal
|
||
open={isDeleteModalOpen}
|
||
onDelete={async () => {
|
||
if (rowId) {
|
||
deleteRoute(rowId);
|
||
}
|
||
setIsDeleteModalOpen(false);
|
||
setRowId(null);
|
||
}}
|
||
onCancel={() => {
|
||
setIsDeleteModalOpen(false);
|
||
setRowId(null);
|
||
}}
|
||
/>
|
||
|
||
<DeleteModal
|
||
open={isBulkDeleteModalOpen}
|
||
onDelete={async () => {
|
||
await Promise.all(ids.map((id) => deleteRoute(id)));
|
||
getRoutes();
|
||
setIsBulkDeleteModalOpen(false);
|
||
setIds([]);
|
||
}}
|
||
onCancel={() => {
|
||
setIsBulkDeleteModalOpen(false);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
});
|