feat: Route list page
This commit is contained in:
@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
@ -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 &&
|
||||
|
@ -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,
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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,
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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}
|
||||
|
129
src/pages/MapPage/mapStore.ts
Normal file
129
src/pages/MapPage/mapStore.ts
Normal 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();
|
@ -164,7 +164,7 @@ export const MediaEditPage = observer(() => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={20} />}
|
||||
onClick={() => navigate("/media")}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
@ -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 ? (
|
||||
|
@ -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>
|
||||
|
@ -1 +1,2 @@
|
||||
export * from "./RouteListPage";
|
||||
export * from "./RouteCreatePage";
|
||||
|
@ -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}`)}>
|
||||
|
@ -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>
|
||||
|
179
src/pages/Station/StationEditPage/index.tsx
Normal file
179
src/pages/Station/StationEditPage/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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,
|
||||
|
77
src/pages/Station/StationPreviewPage/index.tsx
Normal file
77
src/pages/Station/StationPreviewPage/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -1 +1,4 @@
|
||||
export * from "./StationListPage";
|
||||
export * from "./StationCreatePage";
|
||||
export * from "./StationPreviewPage";
|
||||
export * from "./StationEditPage";
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
Назад
|
||||
|
@ -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 (
|
||||
|
@ -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: "Достопримечательности",
|
||||
|
@ -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");
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
|
@ -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 {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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 };
|
||||
|
@ -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
Reference in New Issue
Block a user