Files
WhiteNightsAdminPanel/src/pages/Route/RouteListPage/index.tsx
2026-10-13 16:49:18 +00:00

359 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}}
/>
</>
);
});