feature/webgl-preview-update (#21)

Co-authored-by: itoshi <kkzemeow@gmail.com>
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-10-13 16:49:18 +00:00
parent 50ad374cf5
commit 71834904a1
327 changed files with 39034 additions and 8829 deletions

View File

@@ -1,15 +1,15 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
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 } from "lucide-react";
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
import { Box, CircularProgress, Tooltip } from "@mui/material";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -17,7 +17,18 @@ export const RouteListPage = observer(() => {
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 () => {
@@ -27,9 +38,12 @@ export const RouteListPage = observer(() => {
await getCarriers("zh");
await getRoutes();
setIsLoading(false);
const routeIds = routeStore.routes.data.map((r) => r.id);
loadCounts(routeIds);
};
fetchData();
}, [language]);
}, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [
{
@@ -50,6 +64,22 @@ export const RouteListPage = observer(() => {
);
},
},
{
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: "Номер маршрута",
@@ -66,6 +96,23 @@ export const RouteListPage = observer(() => {
);
},
},
{
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: "Направление",
@@ -85,44 +132,116 @@ export const RouteListPage = observer(() => {
},
},
{
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",
headerAlign: "center",
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">
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
{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 = routes.data.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
}));
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 (
<>
@@ -131,36 +250,76 @@ export const RouteListPage = observer(() => {
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" />
{canWriteRoutes && (
<CreateButton
label="Создать маршрут"
path="/route/create"
/>
)}
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<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>
{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}
hideFooter
checkboxSelection
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={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
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} /> : "Нет маршрутов"}
{isLoading ? (
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет маршрутов"
)}
</Box>
),
}}