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} />
Назад

View File

@ -5,6 +5,7 @@ import React, {
useCallback,
ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { Map, View, Overlay, MapBrowserEvent } from "ol";
import TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM";
@ -14,7 +15,13 @@ import { Draw, Modify, Select } from "ol/interaction";
import { DrawEvent } from "ol/interaction/Draw";
import { ModifyEvent } from "ol/interaction/Modify";
import { SelectEvent } from "ol/interaction/Select";
import { Style, Fill, Stroke, Circle as CircleStyle } from "ol/style";
import {
Style,
Fill,
Stroke,
Circle as CircleStyle,
RegularShape,
} from "ol/style";
import { Point, LineString, Geometry } from "ol/geom";
import { transform } from "ol/proj";
import { GeoJSON } from "ol/format";
@ -25,85 +32,21 @@ import {
Trash2,
ArrowRightLeft,
Landmark,
Pencil,
Save,
Plus,
} from "lucide-react";
import { toast } from "react-toastify";
import { singleClick, doubleClick } from "ol/events/condition";
import { Feature } from "ol";
import Layer from "ol/layer/Layer";
import Source from "ol/source/Source";
import { FeatureLike } from "ol/Feature";
import { authInstance } from "@shared";
import mapStore from "./mapStore";
// --- API INTERFACES ---
interface ApiRoute {
id: number;
route_number: string;
path: [number, number][]; // [longitude, latitude][]
}
interface ApiStation {
id: number;
name: string;
latitude: number;
longitude: number;
}
interface ApiSight {
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
}
// --- MOCK API (для имитации запросов к серверу) ---
const mockApi = {
getRoutes: async (): Promise<ApiRoute[]> => {
console.log("Fetching routes...");
await new Promise((res) => setTimeout(res, 500)); // Имитация задержки сети
return [
{
id: 1,
route_number: "А-78",
path: [
[30.315, 59.934],
[30.32, 59.936],
[30.325, 59.938],
[30.33, 59.94],
],
},
];
},
getStations: async (): Promise<ApiStation[]> => {
console.log("Fetching stations...");
// const stations = await authInstance.get("/station");
await new Promise((res) => setTimeout(res, 400));
return [
{ id: 101, name: "Гостиный двор", latitude: 59.934, longitude: 30.332 },
{ id: 102, name: "Пл. Восстания", latitude: 59.931, longitude: 30.362 },
];
},
getSights: async (): Promise<ApiSight[]> => {
console.log("Fetching sights...");
await new Promise((res) => setTimeout(res, 600));
return [
{
id: 201,
name: "Спас на Крови",
description: "Храм Воскресения Христова",
latitude: 59.94,
longitude: 30.329,
},
{
id: 202,
name: "Казанский собор",
description: "Кафедральный собор",
latitude: 59.934,
longitude: 30.325,
},
];
},
};
// --- MOCK API ---
// --- CONFIGURATION ---
export const mapConfig = {
@ -154,9 +97,11 @@ interface MapServiceConfig {
interface HistoryState {
action: string;
state: string; // GeoJSON string
state: string;
}
type FeatureType = "station" | "route" | "sight";
class MapService {
private map: Map | null;
private vectorSource: VectorSource<Feature<Geometry>>;
@ -164,14 +109,15 @@ class MapService {
private tooltipElement: HTMLElement;
private tooltipOverlay: Overlay | null;
private mode: string | null;
private currentDrawingType: string | null;
private currentDrawingType: "Point" | "LineString" | null;
private currentDrawingFeatureType: FeatureType | null;
private currentInteraction: Draw | null;
private modifyInteraction: Modify;
private selectInteraction: Select;
private hoveredFeatureId: string | number | null;
private history: HistoryState[];
private historyIndex: number;
private beforeModifyState: string | null; // GeoJSON string
private beforeModifyState: string | null;
private boundHandlePointerMove: (
event: MapBrowserEvent<PointerEvent>
) => void;
@ -188,7 +134,9 @@ class MapService {
private drawBusIconStyle: Style;
private sightIconStyle: Style;
private selectedSightIconStyle: Style;
private drawSightIconStyle: Style;
private universalHoverStylePoint: Style;
private hoverSightIconStyle: Style;
private universalHoverStyleLine: Style;
// Callbacks
@ -212,6 +160,7 @@ class MapService {
this.tooltipOverlay = null;
this.mode = null;
this.currentDrawingType = null;
this.currentDrawingFeatureType = null;
this.currentInteraction = null;
this.hoveredFeatureId = null;
this.history = [];
@ -226,11 +175,11 @@ class MapService {
this.defaultStyle = new Style({
fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }),
stroke: new Stroke({ color: "#3182ce", width: 2 }),
stroke: new Stroke({ color: "#3182ce", width: 3 }),
});
this.selectedStyle = new Style({
fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }),
stroke: new Stroke({ color: "#dd6b20", width: 3 }),
stroke: new Stroke({ color: "#dd6b20", width: 4 }),
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: "#dd6b20" }),
@ -249,6 +198,7 @@ class MapService {
fill: new Fill({ color: "rgba(34, 197, 94, 0.7)" }),
}),
});
this.busIconStyle = new Style({
image: new CircleStyle({
radius: 8,
@ -270,20 +220,38 @@ class MapService {
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
});
this.sightIconStyle = new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }), // Purple
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
image: new RegularShape({
fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
}),
});
this.selectedSightIconStyle = new Style({
image: new CircleStyle({
radius: 10,
image: new RegularShape({
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
stroke: new Stroke({ color: "#ffffff", width: 2.5 }),
points: 5,
radius: 14,
radius2: 7,
angle: 0,
}),
});
this.drawSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
}),
});
this.universalHoverStylePoint = new Style({
image: new CircleStyle({
radius: 11,
@ -292,8 +260,19 @@ class MapService {
}),
zIndex: Infinity,
});
this.hoverSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
points: 5,
radius: 15,
radius2: 7.5,
angle: 0,
}),
zIndex: Infinity,
});
this.universalHoverStyleLine = new Style({
stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 4.5 }),
stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 5 }),
zIndex: Infinity,
});
@ -326,7 +305,9 @@ class MapService {
return selectedPointStyle;
}
if (isHovered) {
return this.universalHoverStylePoint;
return featureType === "sight"
? this.hoverSightIconStyle
: this.universalHoverStylePoint;
}
return defaultPointStyle;
} else if (geometryType === "LineString") {
@ -339,7 +320,7 @@ class MapService {
return this.defaultStyle;
}
return this.defaultStyle; // Fallback
return this.defaultStyle;
},
});
@ -382,7 +363,7 @@ class MapService {
if (!renderCompleteHandled) {
this.setLoading(false);
renderCompleteHandled = true;
this.setError(null);
setError(null);
}
});
setTimeout(() => {
@ -400,10 +381,27 @@ class MapService {
this.modifyInteraction = new Modify({
source: this.vectorSource,
style: (f: FeatureLike) =>
f.getGeometry()?.getType() === "Point"
? this.selectedBusIconStyle
: this.selectedStyle,
style: (feature) => {
const originalFeature = feature.get("features")[0];
if (
originalFeature &&
originalFeature.getGeometry()?.getType() === "Point"
) {
return null;
}
return new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({
color: "rgba(255, 255, 255, 0.7)",
}),
stroke: new Stroke({
color: "#0099ff",
width: 2,
}),
}),
});
},
deleteCondition: (e: MapBrowserEvent<any>) => doubleClick(e),
});
@ -411,10 +409,15 @@ class MapService {
style: (featureLike: FeatureLike) => {
if (!featureLike || !featureLike.getGeometry) return this.defaultStyle;
const feature = featureLike as Feature<Geometry>;
const featureType = feature.get("featureType");
const geometryType = feature.getGeometry()?.getType();
return geometryType === "Point"
? this.selectedBusIconStyle
: this.selectedStyle;
if (geometryType === "Point") {
return featureType === "sight"
? this.selectedSightIconStyle
: this.selectedBusIconStyle;
}
return this.selectedStyle;
},
condition: (e: MapBrowserEvent<any>) => {
const isEdit = this.mode === "edit";
@ -507,10 +510,22 @@ class MapService {
}
}
public saveMapState(): void {
const geoJSON = this.getAllFeaturesAsGeoJSON();
if (geoJSON) {
console.log("Сохранение состояния карты (GeoJSON):", geoJSON);
alert(
"Данные карты выведены в консоль разработчика и готовы к отправке!"
);
} else {
alert("Нет объектов для сохранения.");
}
}
public loadFeaturesFromApi(
apiStations: ApiStation[],
apiRoutes: ApiRoute[],
apiSights: ApiSight[]
apiStations: typeof mapStore.stations,
apiRoutes: typeof mapStore.routes,
apiSights: typeof mapStore.sights
): void {
if (!this.map) return;
@ -518,6 +533,10 @@ class MapService {
const featuresToAdd: Feature<Geometry>[] = [];
apiStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) {
console.warn(`Station ${station.id} has null coordinates, skipping...`);
return;
}
const point = new Point(
transform(
[station.longitude, station.latitude],
@ -532,9 +551,19 @@ class MapService {
});
apiRoutes.forEach((route) => {
const coordinates = route.path.map((coord) =>
transform(coord, "EPSG:4326", projection)
if (!route.path || route.path.length === 0) {
console.warn(`Route ${route.id} has no path coordinates, skipping...`);
return;
}
const coordinates = route.path
.filter((coord) => coord[0] != null && coord[1] != null)
.map((coord) => transform(coord, "EPSG:4326", projection));
if (coordinates.length === 0) {
console.warn(
`Route ${route.id} has no valid coordinates after filtering, skipping...`
);
return;
}
const line = new LineString(coordinates);
const feature = new Feature({ geometry: line, name: route.route_number });
feature.setId(`route-${route.id}`);
@ -543,6 +572,10 @@ class MapService {
});
apiSights.forEach((sight) => {
if (sight.longitude == null || sight.latitude == null) {
console.warn(`Sight ${sight.id} has null coordinates, skipping...`);
return;
}
const point = new Point(
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
);
@ -689,20 +722,35 @@ class MapService {
}
}
public startDrawing(type: "Point" | "LineString", options = {}): void {
public startDrawing(
type: "Point" | "LineString",
featureType: FeatureType
): void {
if (!this.map) return;
this.currentDrawingType = type;
const drawingMode = `drawing-${type.toLowerCase()}`;
this.currentDrawingFeatureType = featureType;
const drawingMode = `drawing-${featureType}`;
this.setMode(drawingMode);
if (this.currentInteraction instanceof Draw) {
this.map.removeInteraction(this.currentInteraction);
}
let styleForDrawing: Style;
if (featureType === "station") {
styleForDrawing = this.drawBusIconStyle;
} else if (featureType === "sight") {
styleForDrawing = this.drawSightIconStyle;
} else {
styleForDrawing = this.drawStyle;
}
this.currentInteraction = new Draw({
source: this.vectorSource,
type,
style: type === "Point" ? this.drawBusIconStyle : this.drawStyle,
...options,
style: styleForDrawing,
});
let stateBeforeDraw: string | null = null;
@ -715,23 +763,23 @@ class MapService {
this.addStateToHistory("draw-before", stateBeforeDraw);
}
const feature = event.feature as Feature<Geometry>;
const geometry = feature.getGeometry();
if (!geometry) return;
const geometryType = geometry.getType();
const fType = this.currentDrawingFeatureType;
if (!fType) return;
feature.set("featureType", fType);
let baseName = "";
let namePrefix = "";
if (geometryType === "Point") {
if (fType === "station") {
baseName = "Станция";
namePrefix = "Станция ";
feature.set("featureType", "station");
} else if (geometryType === "LineString") {
} else if (fType === "sight") {
baseName = "Достопримечательность";
namePrefix = "Достопримечательность ";
} else if (fType === "route") {
baseName = "Маршрут";
namePrefix = "Маршрут ";
feature.set("featureType", "route");
} else {
baseName = "Объект";
namePrefix = "Объект ";
}
const existingNamedFeatures = this.vectorSource
@ -739,7 +787,7 @@ class MapService {
.filter(
(f) =>
f !== feature &&
f.getGeometry()?.getType() === geometryType &&
f.get("featureType") === fType &&
f.get("name") &&
(f.get("name") as string).startsWith(namePrefix)
);
@ -757,22 +805,22 @@ class MapService {
const newNumber = maxNumber + 1;
feature.set("name", `${baseName} ${newNumber}`);
// DO NOT set style directly on the feature, so it uses the layer's style function
// which handles hover effects.
// feature.setStyle(
// type === "Point" ? this.busIconStyle : this.defaultStyle
// );
if (type === "LineString") this.finishDrawing();
if (this.currentDrawingType === "LineString") {
this.finishDrawing();
}
});
this.map.addInteraction(this.currentInteraction);
}
public startDrawingMarker(): void {
this.startDrawing("Point");
this.startDrawing("Point", "station");
}
public startDrawingLine(): void {
this.startDrawing("LineString");
this.startDrawing("LineString", "route");
}
public startDrawingSight(): void {
this.startDrawing("Point", "sight");
}
public finishDrawing(): void {
@ -782,12 +830,13 @@ class MapService {
try {
this.currentInteraction.finishDrawing();
} catch (e) {
// Drawing could not be finished (e.g., LineString with 1 point)
// Drawing could not be finished
}
}
this.map.removeInteraction(this.currentInteraction);
this.currentInteraction = null;
this.currentDrawingType = null;
this.currentDrawingFeatureType = null;
this.activateEditMode();
}
@ -839,17 +888,17 @@ class MapService {
const newHoveredFeatureId = featureAtPixel ? featureAtPixel.getId() : null;
if (this.tooltipOverlay && this.tooltipElement) {
const featureType = featureAtPixel?.get("featureType");
if (
featureAtPixel &&
(featureType === "station" || featureType === "sight")
) {
this.tooltipElement.innerHTML =
(featureAtPixel.get("name") as string) || "Объект";
if (this.mode === "edit" && featureAtPixel) {
const name = featureAtPixel.get("name");
if (name) {
this.tooltipElement.innerHTML = name as string;
this.tooltipOverlay.setPosition(event.coordinate);
} else {
this.tooltipOverlay.setPosition(undefined);
}
} else {
this.tooltipOverlay.setPosition(undefined);
}
}
if (this.hoveredFeatureId !== newHoveredFeatureId) {
@ -900,8 +949,16 @@ class MapService {
}
}
public deleteFeature(featureId: string | number | undefined): void {
public deleteFeature(
featureId: string | number | undefined,
recourse: string
): void {
if (featureId === undefined) return;
const id = featureId.toString().split("-")[1];
if (recourse) {
mapStore.deleteRecourse(recourse, Number(id));
toast.success("Объект успешно удален");
}
const feature = this.vectorSource.getFeatureById(featureId);
if (feature) {
const currentState = this.getCurrentStateAsGeoJSON();
@ -918,22 +975,20 @@ class MapService {
}
}
// --- ИСПРАВЛЕННЫЙ МЕТОД ---
public getAllFeaturesAsGeoJSON(): string | null {
if (!this.vectorSource || !this.map) return null;
const feats = this.vectorSource.getFeatures();
if (feats.length === 0) return null;
const geoJSONFmt = new GeoJSON();
const featsExp = feats.map((f) => {
const cF = f.clone();
cF.setProperties(f.getProperties(), true);
cF.setId(f.getId());
const geom = cF.getGeometry();
if (geom)
geom.transform(this.map!.getView().getProjection(), "EPSG:4326");
return cF;
// Просто передаем опции трансформации в метод writeFeatures.
// Он сам всё сделает правильно, не трогая оригинальные объекты.
return geoJSONFmt.writeFeatures(feats, {
dataProjection: "EPSG:4326", // В какую проекцию конвертировать (стандарт для GeoJSON)
featureProjection: this.map.getView().getProjection(), // В какой проекции находятся объекты на карте
});
return geoJSONFmt.writeFeatures(featsExp);
}
public destroy(): void {
@ -998,14 +1053,21 @@ const MapControls: React.FC<MapControlsProps> = ({
action: () => mapService.activateEditMode(),
},
{
mode: "drawing-point",
mode: "drawing-station",
title: "Станция",
longTitle: "Добавить станцию",
icon: <Bus size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.startDrawingMarker(),
},
{
mode: "drawing-linestring",
mode: "drawing-sight",
title: "Место",
longTitle: "Добавить достопримечательность",
icon: <Landmark size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.startDrawingSight(),
},
{
mode: "drawing-route",
title: "Маршрут",
longTitle: "Добавить маршрут",
icon: <LineIconSvg />,
@ -1046,6 +1108,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
selectedFeature,
}) => {
const [activeSection, setActiveSection] = useState<string | null>("layers");
const navigate = useNavigate();
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
@ -1057,16 +1121,33 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
);
const handleDeleteFeature = useCallback(
(id: string | number | undefined) => {
(id: string | number | undefined, recourse: string) => {
if (
mapService &&
window.confirm("Вы действительно хотите удалить этот объект?")
)
mapService.deleteFeature(id);
mapService.deleteFeature(id, recourse);
},
[mapService]
);
const handleEditFeature = useCallback(
(featureType: string | undefined, fullId: string | number | undefined) => {
if (!featureType || !fullId) return;
const numericId = String(fullId).split("-")[1];
if (!numericId) {
console.error("Could not parse numeric ID from", fullId);
return;
}
navigate(`/${featureType}/${numericId}/edit`);
},
[navigate]
);
const handleSave = useCallback(() => {
mapStore.handleSave(mapService?.getAllFeaturesAsGeoJSON() || "");
}, [mapService]);
const stations = mapFeatures.filter(
(f) =>
f.get("featureType") === "station" ||
@ -1109,7 +1190,7 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}`}
onClick={() => handleFeatureClick(sId)}
>
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2">
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0">
<div className="flex items-center">
<MapPin
size={16}
@ -1131,17 +1212,29 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId);
handleEditFeature(s.get("featureType"), sId);
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors flex-shrink-0 opacity-60 group-hover:opacity-100"
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId, "station");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить остановку"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
@ -1178,7 +1271,7 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}`}
onClick={() => handleFeatureClick(lId)}
>
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2">
<div className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0">
<div className="flex items-center mb-0.5">
<ArrowRightLeft
size={16}
@ -1205,17 +1298,29 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
</p>
)}
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(lId);
handleEditFeature(l.get("featureType"), lId);
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors flex-shrink-0 opacity-60 group-hover:opacity-100"
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(lId, "route");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить маршрут"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
@ -1245,7 +1350,7 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}`}
onClick={() => handleFeatureClick(sId)}
>
<div className="flex items-center text-gray-800 text-sm flex-grow mr-2">
<div className="flex items-center text-gray-800 text-sm flex-grow mr-2 min-w-0">
<Landmark
size={16}
className={`mr-1.5 flex-shrink-0 ${
@ -1265,17 +1370,29 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
{sName}
</span>
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId);
handleEditFeature(s.get("featureType"), sId);
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors flex-shrink-0 opacity-60 group-hover:opacity-100"
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId, "sight");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить достопримечательность"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
@ -1298,13 +1415,17 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
}
return (
<div className="w-72 md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-screen">
<div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto max-h-[70%]">
{sections.map((s) => (
<div key={s.id} className="border-b border-gray-200 last:border-b-0">
<div
key={s.id}
className="border-b border-gray-200 last:border-b-0"
>
<button
onClick={() => toggleSection(s.id)}
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
@ -1346,6 +1467,15 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
))}
</div>
</div>
<button
onClick={handleSave}
className="m-3 w-[90%] flex items-center justify-center px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
<Save size={16} className="mr-2" />
Сохранить изменения
</button>
</div>
);
};
@ -1355,7 +1485,9 @@ export const MapPage: React.FC = () => {
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [mapServiceInstance, setMapServiceInstance] =
useState<MapService | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
// --- ИЗМЕНЕНИЕ: Разделение состояния загрузки ---
const [isMapLoading, setIsMapLoading] = useState<boolean>(true); // Для рендеринга карты
const [isDataLoading, setIsDataLoading] = useState<boolean>(true); // Для загрузки данных с API
const [error, setError] = useState<string | null>(null);
const [currentMapMode, setCurrentMapMode] = useState<string>("edit");
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
@ -1376,12 +1508,38 @@ export const MapPage: React.FC = () => {
useEffect(() => {
let service: MapService | null = null;
if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
setIsLoading(true);
// Изначально оба процесса загрузки активны
setIsMapLoading(true);
setIsDataLoading(true);
setError(null);
// --- ИЗМЕНЕНИЕ: Логика загрузки данных вынесена и управляет своим состоянием ---
const loadInitialData = async (mapService: MapService) => {
console.log("Starting data load...");
try {
await Promise.all([
mapStore.getRoutes(),
mapStore.getStations(),
mapStore.getSights(),
]);
mapService.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
} catch (e) {
console.error("Failed to load initial map data:", e);
setError("Не удалось загрузить данные для карты.");
} finally {
// Завершаем состояние загрузки данных независимо от результата
setIsDataLoading(false);
}
};
try {
service = new MapService(
{ ...mapConfig, target: mapRef.current },
setIsLoading,
setIsMapLoading, // MapService теперь управляет только состоянием загрузки карты
setError,
setCurrentMapMode,
handleFeaturesChange,
@ -1390,23 +1548,7 @@ export const MapPage: React.FC = () => {
);
setMapServiceInstance(service);
const loadInitialData = async (mapService: MapService) => {
console.log("Starting data load...");
setIsLoading(true);
try {
// Замените mockApi на реальные fetch запросы к вашему API
const [routes, stations, sights] = await Promise.all([
mockApi.getRoutes(),
mockApi.getStations(),
mockApi.getSights(),
]);
mapService.loadFeaturesFromApi(stations, routes, sights);
} catch (e) {
console.error("Failed to load initial map data:", e);
setError("Не удалось загрузить данные для карты.");
}
};
// Запускаем загрузку данных
loadInitialData(service);
} catch (e: any) {
console.error("MapPage useEffect error:", e);
@ -1415,7 +1557,9 @@ export const MapPage: React.FC = () => {
e.message || "Неизвестная ошибка"
}. Пожалуйста, проверьте консоль.`
);
setIsLoading(false);
// В случае критической ошибки инициализации, завершаем все загрузки
setIsMapLoading(false);
setIsDataLoading(false);
}
}
return () => {
@ -1427,6 +1571,9 @@ export const MapPage: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error;
return (
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
<div className="relative flex-grow flex">
@ -1441,18 +1588,18 @@ export const MapPage: React.FC = () => {
style={{
position: "absolute",
pointerEvents: "none",
visibility: "hidden",
}}
></div>
{isLoading && (
{/* --- ИЗМЕНЕНИЕ: Обновленный лоадер --- */}
{showLoader && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-3"></div>
<div className="text-md font-semibold text-white drop-shadow-md">
Загрузка карты...
{isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
</div>
</div>
)}
{error && !isLoading && (
{error && !showLoader && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-red-100 border border-red-400 text-red-700 p-6 rounded-lg shadow-lg z-[1002] text-center max-w-md">
<h3 className="font-semibold text-lg mb-2">Произошла ошибка</h3>
<p className="text-sm">{error}</p>
@ -1465,14 +1612,15 @@ export const MapPage: React.FC = () => {
</div>
)}
</div>
{mapServiceInstance && !isLoading && !error && (
{/* --- ИЗМЕНЕНИЕ: Условие для отображения контента --- */}
{showContent && (
<MapControls
mapService={mapServiceInstance}
activeMode={currentMapMode}
/>
)}
</div>
{mapServiceInstance && !isLoading && !error && (
{showContent && (
<MapSightbar
mapService={mapServiceInstance}
mapFeatures={mapFeatures}

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>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
<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={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 "./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,6 +236,7 @@ class CityStore {
(country) => country.code === country_code
);
if (name) {
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
country: country?.name || "",
@ -270,6 +271,7 @@ class CityStore {
}
});
}
}
};
}

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,7 +58,10 @@ 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,
};
});
};
}

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