feat: Route list page

This commit is contained in:
2025-06-09 09:17:56 +03:00
parent 02a1d2ea74
commit f4544c1888
37 changed files with 1539 additions and 400 deletions

View File

@ -33,6 +33,10 @@ import {
UserEditPage,
VehicleEditPage,
CarrierEditPage,
StationCreatePage,
StationPreviewPage,
StationEditPage,
RouteCreatePage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
@ -110,7 +114,7 @@ const router = createBrowserRouter([
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id", element: <EditSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> },
@ -135,6 +139,7 @@ const router = createBrowserRouter([
{ path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> },
// User
{ path: "user", element: <UserListPage /> },
@ -151,7 +156,9 @@ const router = createBrowserRouter([
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
{ path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
@ -159,7 +166,7 @@ const router = createBrowserRouter([
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
// { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},

View File

@ -8,7 +8,7 @@ import List from "@mui/material/List";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
interface NavigationItemProps {
item: NavigationItem;
@ -25,8 +25,11 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}) => {
const Icon = item.icon;
const navigate = useNavigate();
const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false);
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const handleClick = () => {
if (item.nestedItems) {
setIsExpanded(!isExpanded);
@ -57,6 +60,12 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
isNested && {
pl: 4,
},
isActive && {
backgroundColor: "rgba(0, 0, 0, 0.08)",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.12)",
},
},
]}
>
<ListItemIcon
@ -64,6 +73,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
{
minWidth: 0,
justifyContent: "center",
color: isActive ? "primary.main" : "inherit",
},
open
? {
@ -86,6 +96,10 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
: {
opacity: 0,
},
isActive && {
color: "primary.main",
fontWeight: "bold",
},
]}
/>
{item.nestedItems &&

View File

@ -2,7 +2,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, FileText } from "lucide-react";
import { Trash2, Eye } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
@ -31,8 +31,8 @@ export const ArticleListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<FileText size={20} className="text-green-500" />
<button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
@ -48,7 +48,7 @@ export const ArticleListPage = observer(() => {
},
];
const rows = articleList.map((article) => ({
const rows = articleList[language].data.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,

View File

@ -63,7 +63,7 @@ export const CarrierCreatePage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/carrier")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -61,7 +61,7 @@ export const CarrierEditPage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/carrier")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -62,7 +62,7 @@ export const CarrierListPage = observer(() => {
},
];
const rows = carriers.map((carrier) => ({
const rows = carriers.data?.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,

View File

@ -72,7 +72,7 @@ export const CityCreatePage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/city")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -89,7 +89,7 @@ export const CityEditPage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/city")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -33,7 +33,7 @@ export const CountryCreatePage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/country")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -43,7 +43,7 @@ export const CountryEditPage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/country")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,129 @@
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
interface ApiRoute {
id: number;
route_number: string;
path: [number, number][];
}
interface ApiStation {
id: number;
name: string;
latitude: number;
longitude: number;
}
interface ApiSight {
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
}
class MapStore {
constructor() {
makeAutoObservable(this);
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
getRoutes = async () => {
const routes = await languageInstance("ru").get("/route");
const mappedRoutes = routes.data.map((route: any) => ({
id: route.id,
route_number: route.route_number,
path: route.path,
}));
this.routes = mappedRoutes;
};
getStations = async () => {
const stations = await languageInstance("ru").get("/station");
const mappedStations = stations.data.map((station: any) => ({
id: station.id,
name: station.name,
latitude: station.latitude,
longitude: station.longitude,
}));
this.stations = mappedStations;
};
getSights = async () => {
const sights = await languageInstance("ru").get("/sight");
const mappedSights = sights.data.map((sight: any) => ({
id: sight.id,
name: sight.name,
description: sight.description,
latitude: sight.latitude,
longitude: sight.longitude,
}));
this.sights = mappedSights;
};
deleteRecourse = async (recourse: string, id: number) => {
await languageInstance("ru").delete(`/${recourse}/${id}`);
if (recourse === "route") {
this.routes = this.routes.filter((route) => route.id !== id);
} else if (recourse === "station") {
this.stations = this.stations.filter((station) => station.id !== id);
} else if (recourse === "sight") {
this.sights = this.sights.filter((sight) => sight.id !== id);
}
};
handleSave = async (json: string) => {
const sights: any[] = [];
const routes: any[] = [];
const stations: any[] = [];
const parsedJSON = JSON.parse(json);
console.log(parsedJSON);
parsedJSON.features.forEach((feature: any) => {
const { geometry, properties, id } = feature;
const idCanBeSplited = id.split("-");
if (!(idCanBeSplited.length > 1)) {
if (properties.featureType === "station") {
stations.push({
name: properties.name || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
});
} else if (properties.featureType === "route") {
routes.push({
route_number: properties.name || "",
path: geometry.coordinates,
});
} else if (properties.featureType === "sight") {
sights.push({
name: properties.name || "",
description: properties.description || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
});
}
}
});
for (const station of stations) {
await languageInstance("ru").post("/station", station);
}
for (const route of routes) {
await languageInstance("ru").post("/route", route);
}
for (const sight of sights) {
await languageInstance("ru").post("/sight", sight);
}
};
}
export default new MapStore();

View File

@ -164,7 +164,7 @@ export const MediaEditPage = observer(() => {
<Button
variant="outlined"
startIcon={<ArrowLeft size={20} />}
onClick={() => navigate("/media")}
onClick={() => navigate(-1)}
>
Назад
</Button>

View File

@ -6,20 +6,94 @@ import {
MenuItem,
FormControl,
InputLabel,
Typography,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
const [carrier, setCarrier] = useState<string>("");
const [routeNumber, setRouteNumber] = useState("");
const [direction, setDirection] = useState("");
const [routeCoords, setRouteCoords] = useState("");
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("");
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
carrierStore.getCarriers();
articlesStore.getArticleList();
}, []);
const handleCreateRoute = async () => {
try {
setIsLoading(true);
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal);
const scale_min = scaleMin ? Number(scaleMin) : undefined;
const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.split("\n")
.map((line) =>
line
.split(" ")
.map((coord) => Number(coord.trim()))
.filter((n) => !isNaN(n))
)
.filter((arr) => arr.length === 2);
// Собираем объект маршрута
const newRoute: Partial<Route> = {
carrier:
carrierStore.carriers.data.find((c: any) => c.id === carrier_id)
?.full_name || "",
carrier_id,
route_number: routeNumber,
route_sys_number: govRouteNumber,
governor_appeal,
route_direction,
scale_min,
scale_max,
rotate,
center_latitude,
center_longitude,
path,
};
// Отправка на сервер (пример, если есть routeStore.createRoute)
let createdRoute: Route | null = null;
await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при создании маршрута");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
@ -28,11 +102,31 @@ export const RouteCreatePage = observer(() => {
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
Маршруты / Создать
</button>
</div>
<h1 className="text-2xl font-bold">Создание маршрута</h1>
<div className="flex flex-col gap-10 w-full items-end">
<Typography variant="h5" fontWeight={700}>
Создать маршрут
</Typography>
<Box className="flex flex-col gap-6 w-full">
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
value={carrier}
label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)}
disabled={carrierStore.carriers.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map(
(c: (typeof carrierStore.carriers.data)[number]) => (
<MenuItem key={c.id} value={c.id}>
{c.full_name}
</MenuItem>
)
)}
</Select>
</FormControl>
<TextField
className="w-full"
label="Номер маршрута"
@ -40,38 +134,88 @@ export const RouteCreatePage = observer(() => {
value={routeNumber}
onChange={(e) => setRouteNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
value={routeCoords}
onChange={(e) => setRouteCoords(e.target.value)}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
value={governorAppeal}
label="Обращение губернатора"
onChange={(e) => setGovernorAppeal(e.target.value as string)}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Прямой/обратный маршрут"
onChange={(e) => setDirection(e.target.value)}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
className="w-full"
label="Поворот"
value={turn}
onChange={(e) => setTurn(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. широта"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
// await createRoute(routeNumber, direction === "forward");
setIsLoading(false);
toast.success("Маршрут успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка");
} finally {
setIsLoading(false);
}
}}
onClick={handleCreateRoute}
disabled={isLoading}
>
{isLoading ? (

View File

@ -72,7 +72,7 @@ export const RouteListPage = observer(() => {
},
];
const rows = routes.map((route) => ({
const rows = routes.data.map((route) => ({
id: route.id,
carrier: route.carrier,
route_number: route.route_number,
@ -81,8 +81,6 @@ export const RouteListPage = observer(() => {
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>

View File

@ -1 +1,2 @@
export * from "./RouteListPage";
export * from "./RouteListPage";
export * from "./RouteCreatePage";

View File

@ -38,7 +38,7 @@ export const SightListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}`)}>
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>

View File

@ -3,18 +3,13 @@ import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
CreateButton,
DeleteModal,
LanguageSwitcher,
SnapshotRestore,
} from "@widgets";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
@ -76,8 +71,6 @@ export const SnapshotListPage = observer(() => {
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1>

View File

@ -0,0 +1,179 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { SelectMediaDialog } from "@shared";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const { id } = useParams();
const {
editStationData,
getEditStation,
setEditCommonData,
editStation,
setLanguageEditStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const handleEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
toast.success("Станция успешно обновлена");
} catch (error) {
console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
const stationId = Number(id);
await getEditStation(stationId);
await getCities(language);
};
fetchAndSetStationData();
}, [id, language]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Название"
value={editStationData[language].name || ""}
required
onChange={(e) =>
setLanguageEditStationData(language, {
name: e.target.value,
})
}
/>
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={editStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setEditCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Описание"
value={editStationData[language].description || ""}
onChange={(e) =>
setLanguageEditStationData(language, {
description: e.target.value,
})
}
/>
<TextField
fullWidth
label="Адрес"
value={editStationData[language].address || ""}
onChange={(e) =>
setLanguageEditStationData(language, {
address: e.target.value,
})
}
/>
<TextField
fullWidth
label="Координаты"
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`}
onChange={(e) => {
const [latitude, longitude] = e.target.value.split(" ").map(Number);
if (!isNaN(latitude) && !isNaN(longitude)) {
setEditCommonData({
latitude: latitude,
longitude: longitude,
});
}
}}
/>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities[language].find(
(city) => city.id === e.target.value
);
setEditCommonData({
city_id: e.target.value as number,
city: selectedCity?.name || "",
});
}}
>
{cities[language].map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -2,19 +2,19 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const StationListPage = observer(() => {
const { stations, getStations, deleteStation } = stationsStore;
const { stationLists, getStationList, deleteStation } = stationsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getStations();
getStationList();
}, [language]);
const columns: GridColDef[] = [
@ -57,6 +57,9 @@ export const StationListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
@ -74,7 +77,7 @@ export const StationListPage = observer(() => {
},
];
const rows = stations.map((station) => ({
const rows = stationLists[language].data.map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,

View File

@ -0,0 +1,77 @@
import { Paper } from "@mui/material";
import { languageStore, stationsStore } from "@shared";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const StationPreviewPage = observer(() => {
const { id } = useParams();
const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate();
const { language } = languageStore;
useEffect(() => {
(async () => {
if (id) {
await getStationPreview(Number(id));
}
})();
}, [id, language]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{stationPreview[id!]?.[language]?.data.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Системное название</h1>
<p>{stationPreview[id!]?.[language]?.data.system_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Направление</h1>
<p
className={`${
stationPreview[id!]?.[language]?.data.direction
? "text-green-500"
: "text-red-500"
}`}
>
{stationPreview[id!]?.[language]?.data.direction
? "Прямой"
: "Обратный"}
</p>
</div>
{stationPreview[id!]?.[language]?.data.address && (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Адрес</h1>
<p>{stationPreview[id!]?.[language]?.data.address}</p>
</div>
)}
{stationPreview[id!]?.[language]?.data.description && (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Описание</h1>
<p>{stationPreview[id!]?.[language]?.data.description}</p>
</div>
)}
</div>
</Paper>
);
});

View File

@ -1 +1,4 @@
export * from "./StationListPage";
export * from "./StationCreatePage";
export * from "./StationPreviewPage";
export * from "./StationEditPage";

View File

@ -36,7 +36,7 @@ export const UserCreatePage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/user")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -52,7 +52,7 @@ export const UserEditPage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/user")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -79,7 +79,7 @@ export const UserListPage = observer(() => {
},
];
const rows = users.map((user) => ({
const rows = users.data?.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,

View File

@ -32,7 +32,7 @@ export const VehicleCreatePage = observer(() => {
await vehicleStore.createVehicle(
Number(tailNumber),
type,
carrierStore.carriers.find((c) => c.id === carrierId)?.full_name!,
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!,
carrierId!
);
toast.success("Транспорт успешно создан");
@ -48,7 +48,7 @@ export const VehicleCreatePage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/vehicle")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
@ -88,7 +88,7 @@ export const VehicleCreatePage = observer(() => {
required
onChange={(e) => setCarrierId(e.target.value as number)}
>
{carrierStore.carriers.map((carrier) => (
{carrierStore.carriers.data.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>

View File

@ -56,7 +56,7 @@ export const VehicleEditPage = observer(() => {
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/vehicle")}
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад

View File

@ -25,18 +25,15 @@ export const VehicleListPage = observer(() => {
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "type",
headerName: "Тип",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<div className="flex h-full gap-7 items-center">
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type}
</div>
@ -47,21 +44,17 @@ export const VehicleListPage = observer(() => {
field: "carrier",
headerName: "Перевозчик",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "city",
headerName: "Город",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "actions",
headerName: "Действия",
flex: 1,
width: 200,
align: "center",
headerAlign: "center",
@ -88,13 +81,14 @@ export const VehicleListPage = observer(() => {
},
];
const rows = vehicles.map((vehicle) => ({
const rows = vehicles.data?.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier,
city: carriers.find((carrier) => carrier.id === vehicle.vehicle.carrier_id)
?.city,
city: carriers.data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
}));
return (

View File

@ -18,6 +18,7 @@ import {
Newspaper,
PersonStanding,
Cpu,
BookImage,
} from "lucide-react";
export const DRAWER_WIDTH = 300;
@ -28,6 +29,7 @@ interface NavigationItem {
path?: string;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
}
export const NAVIGATION_ITEMS: {
@ -77,18 +79,18 @@ export const NAVIGATION_ITEMS: {
label: "Все сущности",
icon: Table,
nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{
id: "media",
label: "Медиа",
icon: BookImage,
path: "/media",
},
{
id: "articles",
label: "Статьи",
icon: Newspaper,
path: "/article",
},
{
id: "attractions",
label: "Достопримечательности",

View File

@ -15,6 +15,29 @@ type Media = {
media_type: number;
};
type ArticleListCashed = {
ru: {
data: Article[];
loaded: boolean;
};
en: {
data: Article[];
loaded: boolean;
};
zh: {
data: Article[];
loaded: boolean;
};
};
type PreviewCashed = {
ru: Article;
en: Article;
zh: Article;
};
type ArticlePreviewCashed = Record<string, PreviewCashed>;
class ArticlesStore {
constructor() {
makeAutoObservable(this);
@ -25,19 +48,47 @@ class ArticlesStore {
en: [],
zh: [],
};
articleList: Article[] = [];
articleList: ArticleListCashed = {
ru: {
data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
};
articlePreview: ArticlePreviewCashed = {};
articleData: Article | null = null;
articleMedia: Media | null = null;
articleLoading: boolean = false;
getArticleList = async () => {
const { language } = languageStore;
if (this.articleList[language].loaded) {
return;
}
const response = await authInstance.get("/article");
runInAction(() => {
this.articleList = response.data;
this.articleList[language].data = response.data;
this.articleList[language].loaded = true;
});
};
getArticlePreview = async (id: number) => {
const { language } = languageStore;
if (this.articlePreview[id][language]) {
return;
}
const response = await authInstance.get(`/article/${id}/preview`);
this.articlePreview[id][language] = response.data;
};
getArticles = async (language: Language) => {
this.articleLoading = true;
const response = await authInstance.get("/article");

View File

@ -14,23 +14,32 @@ export type Carrier = {
right_color: string;
};
type Carriers = Carrier[];
type Carriers = {
data: Carrier[];
loaded: boolean;
};
type CashedCarrier = Record<number, Carrier>;
class CarrierStore {
carriers: Carriers = [];
carriers: Carriers = {
data: [],
loaded: false,
};
carrier: CashedCarrier = {};
constructor() {
makeAutoObservable(this);
}
getCarriers = async () => {
if (this.carriers.length > 0) return;
if (this.carriers.loaded) return;
const response = await authInstance.get("/carrier");
runInAction(() => {
this.carriers = response.data;
this.carriers.data = response.data;
this.carriers.loaded = true;
});
};
@ -38,13 +47,15 @@ class CarrierStore {
await authInstance.delete(`/carrier/${id}`);
runInAction(() => {
this.carriers = this.carriers.filter((carrier) => carrier.id !== id);
this.carriers.data = this.carriers.data.filter(
(carrier) => carrier.id !== id
);
delete this.carrier[id];
});
};
getCarrier = async (id: number) => {
if (this.carrier[id]) return this.carrier[id];
if (this.carrier[id]) return;
const response = await authInstance.get(`/carrier/${id}`);
runInAction(() => {
@ -90,7 +101,7 @@ class CarrierStore {
logo: logoId,
});
runInAction(() => {
this.carriers.push(response.data);
this.carriers.data.push(response.data);
});
};
@ -137,7 +148,7 @@ class CarrierStore {
);
runInAction(() => {
this.carriers = this.carriers.map((carrier) =>
this.carriers.data = this.carriers.data.map((carrier) =>
carrier.id === id ? { ...carrier, ...response.data } : carrier
);

View File

@ -236,39 +236,41 @@ class CityStore {
(country) => country.code === country_code
);
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
country: country?.name || "",
country_code: country_code,
arms,
});
if (name) {
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
country: country?.name || "",
country_code: country_code,
arms,
});
runInAction(() => {
if (this.city[code]) {
this.city[code][language as keyof CashedCities] = {
name,
country: country?.name || "",
country_code: country_code,
arms,
};
}
runInAction(() => {
if (this.city[code]) {
this.city[code][language as keyof CashedCities] = {
name,
country: country?.name || "",
country_code: country_code,
arms,
};
}
if (this.cities[language as keyof CashedCities]) {
this.cities[language as keyof CashedCities] = this.cities[
language as keyof CashedCities
].map((city) =>
city.id === Number(code)
? {
id: city.id,
name,
country: country?.name || "",
country_code: country_code,
arms,
}
: city
);
}
});
if (this.cities[language as keyof CashedCities]) {
this.cities[language as keyof CashedCities] = this.cities[
language as keyof CashedCities
].map((city) =>
city.id === Number(code)
? {
id: city.id,
name,
country: country?.name || "",
country_code: country_code,
arms,
}
: city
);
}
});
}
}
};
}

View File

@ -19,17 +19,38 @@ export type Route = {
};
class RouteStore {
routes: Route[] = [];
routes: {
data: Route[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
route: Record<string, Route> = {};
constructor() {
makeAutoObservable(this);
}
getRoutes = async () => {
if (this.routes.loaded) return;
const response = await authInstance.get("/route");
runInAction(() => {
this.routes = response.data;
this.routes = {
data: response.data,
loaded: true,
};
});
};
createRoute = async (route: any) => {
const response = await authInstance.post("/route", route);
const id = response.data.id;
runInAction(() => {
this.route[id] = { ...route, id };
this.routes.data = [...this.routes.data, { ...route, id }];
});
};
@ -37,9 +58,12 @@ class RouteStore {
await authInstance.delete(`/route/${id}`);
runInAction(() => {
this.routes = this.routes.filter((route) => route.id !== id);
this.routes = {
data: this.routes.data.filter((route) => route.id !== id),
loaded: true,
};
});
};
}
export const routeStore = new RouteStore();
export const routeStore = new RouteStore();

View File

@ -1,6 +1,42 @@
import { authInstance } from "@shared";
import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
type Language = "ru" | "en" | "zh";
type StationLanguageData = {
name: string;
system_name: string;
description: string;
address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified
};
type StationCommonData = {
city_id: number;
direction: boolean;
icon: string;
latitude: number;
longitude: number;
offset_x: number;
offset_y: number;
transfers: {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
};
city: string;
};
type EditStationData = {
[key in Language]: StationLanguageData;
} & { common: StationCommonData };
type Station = {
id: number;
address: string;
@ -32,6 +68,77 @@ class StationsStore {
stations: Station[] = [];
station: Station | null = null;
stationLists: {
[key in Language]: {
data: Station[];
loaded: boolean;
};
} = {
ru: {
data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
};
// This will store the full station data, keyed by ID and then by language
stationPreview: Record<
string,
Record<string, { loaded: boolean; data: Station }>
> = {};
editStationData: EditStationData = {
ru: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
en: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
zh: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
common: {
city: "",
city_id: 0,
direction: false,
icon: "",
latitude: 0,
longitude: 0,
offset_x: 0,
offset_y: 0,
transfers: {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
};
constructor() {
makeAutoObservable(this);
}
@ -44,11 +151,157 @@ class StationsStore {
});
};
getStationList = async () => {
const { language } = languageStore;
if (this.stationLists[language].loaded) {
return;
}
const response = await authInstance.get("/station");
runInAction(() => {
this.stationLists[language].data = response.data;
this.stationLists[language].loaded = true;
});
};
setEditCommonData = (data: Partial<StationCommonData>) => {
this.editStationData.common = {
...this.editStationData.common,
...data,
};
};
getEditStation = async (id: number) => {
if (this.editStationData.ru.loaded) {
return;
}
const ruResponse = await languageInstance("ru").get(`/station/${id}`);
const enResponse = await languageInstance("en").get(`/station/${id}`);
const zhResponse = await languageInstance("zh").get(`/station/${id}`);
this.editStationData = {
ru: {
name: ruResponse.data.name,
system_name: ruResponse.data.system_name,
description: ruResponse.data.description,
address: ruResponse.data.address,
loaded: true,
},
en: {
name: enResponse.data.name,
system_name: enResponse.data.system_name,
description: enResponse.data.description,
address: enResponse.data.address,
loaded: true,
},
zh: {
name: zhResponse.data.name,
system_name: zhResponse.data.system_name,
description: zhResponse.data.description,
address: zhResponse.data.address,
loaded: true,
},
common: {
city: ruResponse.data.city,
city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction,
icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude,
longitude: ruResponse.data.longitude,
offset_x: ruResponse.data.offset_x,
offset_y: ruResponse.data.offset_y,
transfers: ruResponse.data.transfers,
},
};
};
// Sets language-specific station data
setLanguageEditStationData = (
language: Language,
data: Partial<StationLanguageData>
) => {
this.editStationData[language] = {
...this.editStationData[language],
...data,
};
};
editStation = async (id: number) => {
const commonDataPayload = {
city_id: this.editStationData.common.city_id,
direction: this.editStationData.common.direction,
icon: this.editStationData.common.icon,
latitude: this.editStationData.common.latitude,
longitude: this.editStationData.common.longitude,
offset_x: this.editStationData.common.offset_x,
offset_y: this.editStationData.common.offset_y,
transfers: this.editStationData.common.transfers,
city: this.editStationData.common.city,
};
for (const language of ["ru", "en", "zh"] as const) {
const { name, description, address } = this.editStationData[language];
const response = await languageInstance(language).patch(
`/station/${id}`,
{
name: name || "",
system_name: name || "", // system_name is often derived from name
description: description || "",
address: address || "",
...commonDataPayload,
}
);
runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) {
this.stationPreview[id][language] = {
...this.stationPreview[id][language], // Preserve common fields that might not be in the language-specific patch response
id: response.data.id,
name: response.data.name,
system_name: response.data.system_name,
description: response.data.description,
address: response.data.address,
...commonDataPayload,
} as Station; // Cast to Station to satisfy type
}
if (this.stationLists[language].data) {
this.stationLists[language].data = this.stationLists[
language
].data.map((station: Station) =>
station.id === id
? ({
...station,
name: response.data.name,
system_name: response.data.system_name,
description: response.data.description,
address: response.data.address,
...commonDataPayload,
} as Station)
: station
);
}
});
}
};
deleteStation = async (id: number) => {
await authInstance.delete(`/station/${id}`);
runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id);
// Also clear from stationPreview cache
if (this.stationPreview[id]) {
delete this.stationPreview[id];
}
// Clear from stationLists as well for all languages
for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.filter(
(station) => station.id !== id
);
}
}
});
};
@ -57,6 +310,29 @@ class StationsStore {
this.station = response.data;
};
getStationPreview = async (id: number) => {
const { language } = languageStore;
if (this.stationPreview[id]?.[language]?.loaded) {
return;
}
const response = await languageInstance(language).get(`/station/${id}`);
runInAction(() => {
if (!this.stationPreview[id]) {
this.stationPreview[id] = {
ru: { loaded: false, data: {} as Station },
en: { loaded: false, data: {} as Station },
zh: { loaded: false, data: {} as Station },
};
}
this.stationPreview[id][language] = {
data: response.data,
loaded: true,
};
});
};
createStation = async (
name: string,
systemName: string,
@ -69,8 +345,72 @@ class StationsStore {
});
runInAction(() => {
this.stations.push(response.data);
const newStation = response.data as Station;
if (!this.stationPreview[newStation.id]) {
this.stationPreview[newStation.id] = {
ru: { loaded: false, data: newStation },
en: { loaded: false, data: newStation },
zh: { loaded: false, data: newStation },
};
}
this.stationPreview[newStation.id]["ru"] = {
loaded: true,
data: newStation,
};
this.stationPreview[newStation.id]["en"] = {
loaded: true,
data: newStation,
};
});
};
// Reset editStationData when navigating away or after saving
resetEditStationData = () => {
this.editStationData = {
ru: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
en: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
zh: {
name: "",
system_name: "",
description: "",
address: "",
loaded: false,
},
common: {
city: "",
city_id: 0,
direction: false,
icon: "",
latitude: 0,
longitude: 0,
offset_x: 0,
offset_y: 0,
transfers: {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
};
};
}
export const stationsStore = new StationsStore();

View File

@ -10,7 +10,13 @@ export type User = {
};
class UserStore {
users: User[] = [];
users: {
data: User[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
user: Record<string, User> = {};
constructor() {
@ -18,12 +24,13 @@ class UserStore {
}
getUsers = async () => {
if (this.users.length > 0) return;
if (this.users.loaded) return;
const response = await authInstance.get("/user");
runInAction(() => {
this.users = response.data;
this.users.data = response.data;
this.users.loaded = true;
});
};
@ -42,7 +49,7 @@ class UserStore {
await authInstance.delete(`/user/${id}`);
runInAction(() => {
this.users = this.users.filter((user) => user.id !== id);
this.users.data = this.users.data.filter((user) => user.id !== id);
delete this.user[id];
});
};
@ -64,12 +71,15 @@ class UserStore {
};
createUser = async () => {
const id = this.users[this.users.length - 1].id + 1;
let id = 1;
if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1;
}
const response = await authInstance.post("/user", this.createUserData);
runInAction(() => {
this.users.push({
id,
this.users.data.push({
id: id,
...response.data,
});
});
@ -95,7 +105,7 @@ class UserStore {
const response = await authInstance.patch(`/user/${id}`, this.editUserData);
runInAction(() => {
this.users = this.users.map((user) =>
this.users.data = this.users.data.map((user) =>
user.id === id ? { ...user, ...response.data } : user
);
this.user[id] = { ...this.user[id], ...response.data };

View File

@ -21,7 +21,13 @@ export type Vehicle = {
};
class VehicleStore {
vehicles: Vehicle[] = [];
vehicles: {
data: Vehicle[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
vehicle: Record<string, Vehicle> = {};
constructor() {
@ -29,10 +35,13 @@ class VehicleStore {
}
getVehicles = async () => {
if (this.vehicles.loaded) return;
const response = await languageInstance("ru").get(`/vehicle`);
runInAction(() => {
this.vehicles = response.data;
this.vehicles.data = response.data;
this.vehicles.loaded = true;
});
};
@ -40,7 +49,7 @@ class VehicleStore {
await languageInstance("ru").delete(`/vehicle/${id}`);
runInAction(() => {
this.vehicles = this.vehicles.filter(
this.vehicles.data = this.vehicles.data.filter(
(vehicle) => vehicle.vehicle.id !== id
);
});
@ -68,7 +77,7 @@ class VehicleStore {
});
runInAction(() => {
this.vehicles.push({
this.vehicles.data.push({
vehicle: {
id: response.data.id,
tail_number: response.data.tail_number,
@ -128,7 +137,7 @@ class VehicleStore {
...response.data,
},
};
this.vehicles = this.vehicles.map((vehicle) =>
this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id
? {
...vehicle,

File diff suppressed because one or more lines are too long