3 Commits

127 changed files with 3547 additions and 14076 deletions

7
.env
View File

@@ -1,8 +1,3 @@
# VITE_API_URL='https://wn.st.unprism.ru'
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
# VITE_NEED_AUTH='true'
VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru/'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'

View File

@@ -1,7 +1,7 @@
{
"name": "white-nights",
"private": true,
"version": "1.0.6",
"version": "0.0.0",
"type": "module",
"license": "UNLICENSED",
"scripts": {
@@ -41,8 +41,7 @@
"react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8",
"three": "^0.177.0",
"uuid": "^13.0.0"
"three": "^0.177.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

View File

@@ -5,12 +5,10 @@ import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
import { TestingModeBanner } from "@widgets";
export const App: React.FC = () => (
<GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}>
<TestingModeBanner />
<ToastContainer />
<Router />
</ThemeProvider>

View File

@@ -16,17 +16,22 @@ import {
SnapshotListPage,
CarrierListPage,
StationListPage,
// VehicleListPage,
ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage,
CountryCreatePage,
CityCreatePage,
CarrierCreatePage,
VehicleCreatePage,
VehicleEditPage,
CountryEditPage,
CityEditPage,
UserCreatePage,
UserEditPage,
// VehicleEditPage,
CarrierEditPage,
StationCreatePage,
StationPreviewPage,
@@ -37,7 +42,7 @@ import {
ArticlePreviewPage,
CountryAddPage,
} from "@pages";
import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import React, { useEffect } from "react";
@@ -48,14 +53,11 @@ import {
Navigate,
Outlet,
useLocation,
useMatches,
} from "react-router-dom";
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
if (isAuthenticated || !need_auth) {
if (isAuthenticated) {
return <Navigate to="/map" replace />;
}
return <>{children}</>;
@@ -63,34 +65,17 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
const location = useLocation();
const matches = useMatches();
if (!isAuthenticated && need_auth) {
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (location.pathname === "/" && authStore.canRead("map")) {
if (location.pathname === "/") {
return <Navigate to="/map" replace />;
}
const lastMatch = matches[matches.length - 1] as
| { handle?: { permissions?: string[] } }
| undefined;
const requiredPermissions = lastMatch?.handle?.permissions ?? [];
if (
requiredPermissions.length > 0 &&
!requiredPermissions.every((permission) => authStore.canAccess(permission))
) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
@@ -116,10 +101,7 @@ const router = createBrowserRouter([
</PublicRoute>
),
},
{
path: "route-preview/:id",
element: <RoutePreview />,
},
{ path: "route-preview/:id", element: <RoutePreview /> },
{
path: "/",
element: (
@@ -132,258 +114,67 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
children: [
{
index: true,
element: <MainPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/"],
},
},
{ index: true, element: <MainPage /> },
{
path: "sight",
element: <SightListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight"],
},
},
{
path: "sight/create",
element: <CreateSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"],
},
},
{
path: "sight/:id/edit",
element: <EditSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"],
},
},
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> },
{
path: "devices",
element: <DevicesPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/devices"],
},
},
// Device
{ path: "devices", element: <DevicesPage /> },
{
path: "map",
element: <MapPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/map"],
},
},
// Map
{ path: "map", element: <MapPage /> },
{
path: "media",
element: <MediaListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media"],
},
},
{
path: "media/:id",
element: <MediaPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"],
},
},
{
path: "media/:id/edit",
element: <MediaEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"],
},
},
// Media
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
{
path: "country",
element: <CountryListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country"],
},
},
{
path: "country/create",
element: <CountryCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/create"],
},
},
{
path: "country/add",
element: <CountryAddPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/add"],
},
},
{
path: "country/:id/edit",
element: <CountryEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"],
},
},
{
path: "city",
element: <CityListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city"],
},
},
{
path: "city/create",
element: <CityCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/create"],
},
},
{
path: "city/:id/edit",
element: <CityEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"],
},
},
{
path: "route",
element: <RouteListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route"],
},
},
{
path: "route/create",
element: <RouteCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/create"],
},
},
{
path: "route/:id/edit",
element: <RouteEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"],
},
},
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> },
{
path: "user",
element: <UserListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user"],
},
},
{
path: "user/create",
element: <UserCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/create"],
},
},
{
path: "user/:id/edit",
element: <UserEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"],
},
},
{
path: "snapshot",
element: <SnapshotListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"],
},
},
{
path: "snapshot/create",
element: <SnapshotCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"],
},
},
// User
{ path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
{
path: "carrier",
element: <CarrierListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier"],
},
},
{
path: "carrier/create",
element: <CarrierCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"],
},
},
{
path: "carrier/:id/edit",
element: <CarrierEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"],
},
},
{
path: "station",
element: <StationListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station"],
},
},
{
path: "station/create",
element: <StationCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/create"],
},
},
{
path: "station/:id",
element: <StationPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"],
},
},
{
path: "station/:id/edit",
element: <StationEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"],
},
},
{
path: "vehicle/create",
element: <VehicleCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"],
},
},
{
path: "vehicle/:id/edit",
element: <VehicleEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"],
},
},
{
path: "article",
element: <ArticleListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article"],
},
},
{
path: "article/:id",
element: <ArticlePreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"],
},
},
// Carrier
{ path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> },
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ 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 /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},
]);

View File

@@ -6,7 +6,6 @@ export interface NavigationItem {
icon: LucideIcon;
path?: string;
for_admin?: boolean;
required_resource?: string;
onClick?: () => void;
nestedItems?: NavigationItem[];
}

View File

@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps {
item: NavigationItem;
@@ -30,10 +31,20 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const navigate = useNavigate();
const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore;
// @ts-ignore
const isAdmin = payload?.is_admin || false;
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const filteredNestedItems = item.nestedItems;
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const handleClick = () => {
if (item.id === "all" && !open) {

View File

@@ -1,6 +1,6 @@
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { authStore, NAVIGATION_ITEMS, ROUTE_REQUIRED_RESOURCES } from "@shared";
import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite";
@@ -9,48 +9,28 @@ interface NavigationListProps {
onDrawerOpen?: () => void;
}
const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean => {
// Для карты в навигации требуем наличие ВСЕХ трёх rw-ролей: routes/stations/sights
if (item.id === "map") {
return ["routes", "stations", "sights"].every((resource) =>
authStore.canWrite(resource),
);
}
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
const canAccessRoute = routePermissions.every((permission) =>
authStore.canAccess(permission),
);
if (!canAccessRoute) {
return false;
}
if (!item.requiredRoles || item.requiredRoles.length === 0) {
return true;
}
return item.requiredRoles.some((role) => {
const match = role.match(/^(.+)_r[ow]$/);
if (match) {
return authStore.canRead(match[1]);
}
return authStore.hasRole(role);
});
};
export const NavigationList = observer(
({ open, onDrawerOpen }: NavigationListProps) => {
const primaryItems = NAVIGATION_ITEMS.primary
.filter(isItemVisible)
.map((item) => {
if (!item.nestedItems) return item;
return {
...item,
nestedItems: item.nestedItems.filter(isItemVisible),
};
})
.filter((item) => !item.nestedItems || item.nestedItems.length > 0);
const { payload } = authStore;
// @ts-ignore
const isAdmin = Boolean(payload?.is_admin) || false;
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
if (item.for_admin) {
return isAdmin;
}
if (item.nestedItems && item.nestedItems.length > 0) {
return item.nestedItems.some((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
}
return true;
});
return (
<>
@@ -71,7 +51,7 @@ export const NavigationList = observer(
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ?? undefined}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -17,12 +17,6 @@ export const ArticleListPage = observer(() => {
const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const canWriteArticles = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchArticles = async () => {
@@ -53,12 +47,13 @@ export const ArticleListPage = observer(() => {
field: "actions",
headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{canWriteArticles && (
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -67,22 +62,17 @@ export const ArticleListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
),
</div>
);
},
},
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return articleList[language].data
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
}, [articleList[language].data, searchQuery]);
const rows = articleList[language].data.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
return (
<>
@@ -93,55 +83,31 @@ export const ArticleListPage = observer(() => {
<h1 className="text-2xl">Статьи</h1>
</div>
{canWriteArticles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteArticles}
disableRowSelectionExcludeModel
hideFooterPagination
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteArticles
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
slots={{
noRowsOverlay: () => (
<Box

View File

@@ -6,7 +6,6 @@ import { observer } from "mobx-react-lite";
export const PreviewLeftWidget = observer(() => {
const { articleMedia, articleData } = articlesStore;
const { language } = languageStore;
const body = articleData?.[language]?.body;
return (
<Paper
@@ -67,7 +66,7 @@ export const PreviewLeftWidget = observer(() => {
{articleData?.[language]?.heading || "Название информации"}
</Typography>
</Box>
{body && (
{articleData?.[language]?.body && (
<Box
sx={{
padding: 1,
@@ -78,7 +77,7 @@ export const PreviewLeftWidget = observer(() => {
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={body} />
<ReactMarkdownComponent value={articleData?.[language]?.body} />
</Box>
)}
</Paper>

View File

@@ -1,9 +1,9 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore, LoadingSpinner } from "@shared";
import { articlesStore, languageStore } from "@shared";
import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => {
@@ -11,41 +11,18 @@ export const ArticlePreviewPage = () => {
const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
const fetchData = async () => {
if (id) {
setIsLoadingData(true);
try {
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
}
};
fetchData();
}, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных статьи..." />
</Box>
);
}
return (
<>
<div className="flex items-center gap-4 mb-10">

View File

@@ -8,31 +8,30 @@ import {
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import {
carrierStore,
cityStore,
authStore,
mediaStore,
languageStore,
isMediaIdEmpty,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity();
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -44,37 +43,12 @@ export const CarrierCreatePage = observer(() => {
>(null);
useEffect(() => {
const fetchCities = async () => {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
};
fetchCities();
cityStore.getCities("ru");
mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
@@ -83,7 +57,7 @@ export const CarrierCreatePage = observer(() => {
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
@@ -115,17 +89,13 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language,
language
);
};
const selectedMedia =
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
? null
: selectedMedia?.id ?? selectedMediaId ?? null;
const selectedMedia = selectedMediaId
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -158,11 +128,11 @@ export const CarrierCreatePage = observer(() => {
e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
language
)
}
>
{availableCities.map((city) => (
{cityStore.cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -182,7 +152,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
language
)
}
/>
@@ -199,7 +169,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
language
)
}
/>
@@ -215,7 +185,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language,
language
)
}
/>
@@ -224,10 +194,10 @@ export const CarrierCreatePage = observer(() => {
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={effectiveLogoUrl}
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? "");
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setSelectedMediaId(null);
@@ -238,7 +208,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id,
createCarrierData[language].slogan,
"",
language,
language
);
}}
onSelectFileClick={() => {

View File

@@ -6,22 +6,13 @@ import {
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
carrierStore,
cityStore,
authStore,
mediaStore,
languageStore,
isMediaIdEmpty,
LoadingSpinner,
} from "@shared";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
@@ -35,72 +26,55 @@ export const CarrierEditPage = observer(() => {
const { id } = useParams();
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [initialCityName, setInitialCityName] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
(async () => {
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
const carrierData = await getCarrier(Number(id));
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
const carrierData = await getCarrier(Number(id));
if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
setInitialCityName(carrierData.ru?.city || "");
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
mediaStore.getMedia();
})();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]);
@@ -133,53 +107,9 @@ export const CarrierEditPage = observer(() => {
);
};
const selectedMedia =
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
? null
: (selectedMedia?.id ?? editCarrierData.logo);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editCarrierData.city_id &&
!baseCities.some((city) => city.id === editCarrierData.city_id)
? [
{
id: editCarrierData.city_id,
name: initialCityName || `Город ${editCarrierData.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных перевозчика..." />
</Box>
);
}
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -215,7 +145,7 @@ export const CarrierEditPage = observer(() => {
)
}
>
{availableCities.map((city) => (
{cityStore.cities["ru"].data?.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -277,10 +207,10 @@ export const CarrierEditPage = observer(() => {
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={effectiveLogoUrl}
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? "");
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true);

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -10,31 +10,21 @@ import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities(language);
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getCities("ru");
await getCities("en");
await getCities("zh");
await getCarriers(language);
setIsLoading(false);
};
@@ -79,64 +69,56 @@ export const CarrierListPage = observer(() => {
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
const lang = language as "ru" | "en" | "zh";
const cityName = canReadCities
? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name
: authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name;
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
return (
<div className="w-full h-full flex items-center">
{cityName ?? <Minus size={20} className="text-red-500" />}
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
...(authStore.canWrite("carriers") ? [{
{
field: "actions",
headerName: "Действия",
headerAlign: "center" as const,
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
}] : []),
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const allowedCityIds = canReadCities
? null
: authStore.meCities["ru"].map((c) => c.city_id);
const canWriteCarriers = authStore.canWrite("carriers");
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (carriers[language].data ?? [])
.filter((carrier) => !allowedCityIds || allowedCityIds.includes(carrier.city_id))
.filter(
(carrier) =>
!query ||
(carrier.full_name ?? "").toLowerCase().includes(query) ||
(carrier.short_name ?? "").toLowerCase().includes(query)
)
.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city_id: carrier.city_id,
}));
}, [carriers[language].data, searchQuery, allowedCityIds]);
const rows = carriers[language].data?.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city_id: carrier.city_id,
}));
return (
<>
@@ -144,55 +126,32 @@ export const CarrierListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1>
{canWriteCarriers && (
<CreateButton label="Создать перевозчика" path="/carrier/create" />
)}
<CreateButton label="Создать перевозчика" path="/carrier/create" />
</div>
{canWriteCarriers && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteCarriers}
disableRowSelectionExcludeModel
hideFooter
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteCarriers
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(Number);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(Number);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -12,18 +12,14 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
cityStore,
countryStore,
languageStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
@@ -76,13 +72,9 @@ export const CityCreatePage = observer(() => {
);
};
const selectedMedia =
createCityData.arms && !isMediaIdEmpty(createCityData.arms)
? mediaStore.media.find((m) => m.id === createCityData.arms)
: null;
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
? null
: (selectedMedia?.id ?? createCityData.arms);
const selectedMedia = createCityData.arms
? mediaStore.media.find((m) => m.id === createCityData.arms)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -143,10 +135,10 @@ export const CityCreatePage = observer(() => {
<ImageUploadCard
title="Герб города"
imageKey="image"
imageUrl={effectiveArmsUrl}
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveArmsUrl ?? "");
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setCreateCityData(

View File

@@ -6,10 +6,10 @@ import {
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
@@ -17,20 +17,19 @@ import {
countryStore,
languageStore,
mediaStore,
isMediaIdEmpty,
CashedCities,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
@@ -45,6 +44,7 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -63,26 +63,20 @@ export const CityEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
await getCountries("ru");
await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getOneMedia(ruData.arms as string);
await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
await getMedia();
}
})();
}, [id]);
@@ -97,32 +91,13 @@ export const CityEditPage = observer(() => {
editCityData[language].name,
editCityData.country_code,
media.id,
language,
language
);
};
const selectedMedia =
editCityData.arms && !isMediaIdEmpty(editCityData.arms)
? mediaStore.media.find((m) => m.id === editCityData.arms)
: null;
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
? null
: selectedMedia?.id ?? editCityData.arms;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных города..." />
</Box>
);
}
const selectedMedia = editCityData.arms
? mediaStore.media.find((m) => m.id === editCityData.arms)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -151,7 +126,7 @@ export const CityEditPage = observer(() => {
e.target.value,
editCityData.country_code,
editCityData.arms,
language,
language
)
}
/>
@@ -167,7 +142,7 @@ export const CityEditPage = observer(() => {
editCityData[language].name,
e.target.value,
editCityData.arms,
language,
language
);
}}
>
@@ -183,17 +158,17 @@ export const CityEditPage = observer(() => {
<ImageUploadCard
title="Герб города"
imageKey="image"
imageUrl={effectiveArmsUrl}
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveArmsUrl ?? "");
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setEditCityData(
editCityData[language].name,
editCityData.country_code,
"",
language,
language
);
setActiveMenuType(null);
}}
@@ -232,7 +207,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -18,13 +18,7 @@ export const CityListPage = observer(() => {
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteCities = authStore.canWrite("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -58,18 +52,6 @@ export const CityListPage = observer(() => {
setRows(newRows2 || []);
}, [cities, countryStore.countries, language, isLoading]);
const filteredRows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return rows;
return rows.filter((row) => {
const cityName = (row.name ?? "").toLowerCase();
const countryName = (
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
).toLowerCase();
return cityName.includes(query) || countryName.includes(query);
});
}, [rows, searchQuery, countryStore.countries, language]);
const columns: GridColDef[] = [
{
field: "country",
@@ -105,30 +87,35 @@ export const CityListPage = observer(() => {
);
},
},
...(authStore.canWrite("cities") ? [{
{
field: "actions",
headerName: "Действия",
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
}] : []),
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
return (
@@ -138,59 +125,32 @@ export const CityListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1>
{canWriteCities && (
<CreateButton label="Создать город" path="/city/create" />
)}
<CreateButton label="Создать город" path="/city/create" />
</div>
{canWriteCities && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={filteredRows}
rows={rows}
columns={columns}
checkboxSelection={canWriteCities}
disableRowSelectionExcludeModel
hideFooter
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteCities
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,23 +1,23 @@
import { Button, Paper, TextField, Box } from "@mui/material";
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { countryStore, languageStore, LoadingSpinner } from "@shared";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore;
const { id } = useParams();
const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -36,39 +36,19 @@ export const CountryEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
// Set data for each language
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
}
})();
}, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных страны..." />
</Box>
);
}
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, countryStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react";
@@ -16,13 +16,7 @@ export const CountryListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteCountries = authStore.canWrite("countries");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchCountries = async () => {
@@ -50,39 +44,44 @@ export const CountryListPage = observer(() => {
);
},
},
...(authStore.canWrite("countries") ? [{
{
field: "actions",
headerName: "Действия",
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
}] : []),
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)}
>
<Pencil size={20} className="text-blue-500" />
</button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (countries[language]?.data ?? [])
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
}, [countries[language]?.data, searchQuery]);
const rows = countries[language]?.data.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
return (
<>
@@ -91,59 +90,32 @@ export const CountryListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
{canWriteCountries && (
<CreateButton label="Добавить страну" path="/country/add" />
)}
<CreateButton label="Добавить страну" path="/country/add" />
</div>
{canWriteCountries && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
rows={rows || []}
columns={columns}
checkboxSelection={canWriteCountries}
disableRowSelectionExcludeModel
hideFooter
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteCountries
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,7 +1,6 @@
import { Box, Tab, Tabs } from "@mui/material";
import {
articlesStore,
authStore,
cityStore,
createSightStore,
languageStore,
@@ -41,14 +40,7 @@ export const CreateSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getCities("ru");
await getArticles(languageStore.language);
};
fetchData();

View File

@@ -3,13 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import {
articlesStore,
authStore,
cityStore,
editSightStore,
LoadingSpinner,
} from "@shared";
import { articlesStore, cityStore, editSightStore } from "@shared";
import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) {
@@ -21,8 +15,7 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const [isLoadingData, setIsLoadingData] = useState(true);
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
const { getArticles } = articlesStore;
const { id } = useParams();
@@ -40,29 +33,13 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
// Загружаем данные правого виджета перед завершением загрузки
await getRightArticles(+id);
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
await getCities("ru");
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
}
};
fetchData();
@@ -102,25 +79,12 @@ export const EditSightPage = observer(() => {
</Tabs>
</Box>
{isLoadingData ? (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных достопримечательности..." />
</Box>
) : (
sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)
{sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}

View File

@@ -24,6 +24,7 @@ export const LoginPage = () => {
const { login } = authStore;
const { getUsers } = userStore;
useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
@@ -41,6 +42,7 @@ export const LoginPage = () => {
try {
await login(email, password);
// Save or clear credentials based on remember me checkbox
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);

View File

@@ -1,5 +1,37 @@
import * as React from "react";
import Typography from "@mui/material/Typography";
export const MainPage: React.FC = () => {
return null;
return (
<>
<Typography sx={{ marginBottom: 2 }}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus
non enim praesent elementum facilisis leo vel. Risus at ultrices mi
tempus imperdiet. Semper risus in hendrerit gravida rutrum quisque non
tellus. Convallis convallis tellus id interdum velit laoreet id donec
ultrices. Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl
suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod
quis viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet
proin fermentum leo. Mauris commodo quis imperdiet massa tincidunt. Cras
tincidunt lobortis feugiat vivamus at augue. At augue eget arcu dictum
varius duis at consectetur lorem. Velit sed ullamcorper morbi tincidunt.
Lorem donec massa sapien faucibus et molestie ac.
</Typography>
<Typography sx={{ marginBottom: 2 }}>
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est
ullamcorper eget nulla facilisi etiam dignissim diam. Pulvinar elementum
integer enim neque volutpat ac tincidunt. Ornare suspendisse sed nisi
lacus sed viverra tellus. Purus sit amet volutpat consequat mauris.
Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus sed
vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra
accumsan in. In hendrerit gravida rutrum quisque non tellus orci ac.
Pellentesque nec nam aliquam sem et tortor. Habitant morbi tristique
senectus et. Adipiscing elit duis tristique sollicitudin nibh sit.
Ornare aenean euismod elementum nisi quis eleifend. Commodo viverra
maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin
aliquam ultrices sagittis orci a.
</Typography>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,10 @@ interface ApiSight {
longitude: number;
}
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = (
path1: [number, number][],
path2: [number, number][]
@@ -69,7 +71,7 @@ class MapStore {
path: route.path,
}));
this.routes = mappedRoutes.sort((a, b) =>
a.route_number.trim().localeCompare(b.route_number.trim())
a.route_number.localeCompare(b.route_number)
);
};
@@ -134,6 +136,7 @@ class MapStore {
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -152,6 +155,7 @@ class MapStore {
path: geometry.coordinates,
};
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if (
originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path)
@@ -169,6 +173,7 @@ class MapStore {
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description ||

View File

@@ -21,7 +21,6 @@ import {
mediaStore,
MEDIA_TYPE_LABELS,
languageStore,
LoadingSpinner,
} from "@shared";
import { MediaViewer } from "@widgets";
@@ -34,7 +33,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -56,27 +55,51 @@ export const MediaEditPage = observer(() => {
setMediaFilename(media.filename);
setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]);
setAvailableMediaTypes([6]); // 3D model
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]);
setAvailableMediaTypes([2]); // Video
}
}
}
}, [media]);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
@@ -84,25 +107,26 @@ export const MediaEditPage = observer(() => {
setNewFile(file);
setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]);
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]);
setAvailableMediaTypes([2]); // Video
setMediaType(2);
}
}
setUploadDialogOpen(true);
setUploadDialogOpen(true); // Open dialog on file selection
}
};
@@ -119,6 +143,11 @@ export const MediaEditPage = observer(() => {
type: mediaType,
});
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true);
handleUploadSuccess();
} catch (err) {
@@ -129,25 +158,20 @@ export const MediaEditPage = observer(() => {
};
const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) {
mediaStore.getOneMedia(id);
}
setNewFile(null);
setNewFile(null); // Clear the new file state after successful upload
setUploadDialogOpen(false);
setSuccess(true);
};
if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных медиа..." />
<Box className="flex justify-center items-center h-screen">
<CircularProgress />
</Box>
);
}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -16,13 +16,7 @@ export const MediaListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteMedia = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchMedia = async () => {
@@ -73,15 +67,16 @@ export const MediaListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 200,
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{canWriteMedia && (
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -90,73 +85,44 @@ export const MediaListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
),
</div>
);
},
},
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return media
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
.map((item) => ({
id: item.id,
media_name: item.media_name,
media_type: item.media_type,
}));
}, [media, searchQuery]);
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
}));
return (
<>
<div className="w-full">
<SearchInput value={searchQuery} onChange={setSearchQuery} />
{canWriteMedia && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteMedia}
disableRowSelectionExcludeModel
hideFooterPagination
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
canWriteMedia
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => String(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => String(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
FormControl,
Accordion,
AccordionSummary,
@@ -34,15 +35,14 @@ import {
} from "@hello-pangea/dnd";
import {
AnimatedCircleButton,
authInstance,
languageStore,
routeStore,
selectedCityStore,
stationsStore,
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1;
const result = [...arr];
@@ -54,6 +54,7 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
return result;
}
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
@@ -78,6 +79,7 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
routeDirection?: boolean;
};
export const LinkedItems = <
@@ -99,7 +101,7 @@ export const LinkedItems = <
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
Привязанные станции
</Typography>
</AccordionSummary>
@@ -127,6 +129,7 @@ const LinkedItemsContentsInner = <
disableCreation = false,
updatedLinkedItems,
refresh,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
@@ -140,9 +143,6 @@ const LinkedItemsContentsInner = <
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
@@ -152,6 +152,13 @@ const LinkedItemsContentsInner = <
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
@@ -160,12 +167,10 @@ const LinkedItemsContentsInner = <
})
.sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
@@ -182,19 +187,6 @@ const LinkedItemsContentsInner = <
setPosition(linkedItems.length + 1);
}, [linkedItems.length]);
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
const { stationLists } = stationsStore;
for (const lang of ["ru", "en", "zh"] as const) {
const station = stationLists[lang].data.find(
(s: any) => s.id === stationId
);
if (station?.transfers) {
return station.transfers;
}
}
return fallbackTransfers;
};
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
@@ -208,13 +200,7 @@ const LinkedItemsContentsInner = <
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => {
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
stations: reorderedItems.map((item) => ({ id: item.id })),
})
.catch((error) => {
console.error("Error updating station order:", error);
@@ -252,7 +238,7 @@ const LinkedItemsContentsInner = <
})
.catch((error) => {
console.error("Error fetching all items:", error);
setError(null);
setError("Failed to load available stations");
setAllItems([]);
});
}
@@ -261,32 +247,14 @@ const LinkedItemsContentsInner = <
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const selectedItem = allItems.find((item) => item.id === selectedItemId);
const requestData = {
stations: insertAtPosition(
linkedItems.map((item) => {
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
linkedItems.map((item) => ({ id: item.id })),
position,
(() => {
if (!selectedItem) return { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem.transfers
);
return {
...selectedItem,
transfers: transfers || selectedItem.transfers,
};
})()
{ id: selectedItemId }
),
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
@@ -305,20 +273,12 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error linking item:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
@@ -330,13 +290,6 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error deleting item:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
@@ -363,25 +316,10 @@ const LinkedItemsContentsInner = <
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const selectedStations = Array.from(selectedItems).map((id) => {
const item = allItems.find((item) => item.id === id);
if (!item) return { id };
const transfers = getStationTransfers(id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
});
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
const requestData = {
stations: [
...linkedItems.map((item) => {
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
...linkedItems.map((item) => ({ id: item.id })),
...selectedStations,
],
};
@@ -397,9 +335,6 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error linking items:", error);
setError("Failed to link stations");
})
.finally(() => {
setIsLinkingBulk(false);
});
};
@@ -469,7 +404,7 @@ const LinkedItemsContentsInner = <
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
<Button
variant="outlined"
color="error"
size="small"
@@ -477,11 +412,9 @@ const LinkedItemsContentsInner = <
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</Button>
</TableCell>
)}
</TableRow>
@@ -499,13 +432,19 @@ const LinkedItemsContentsInner = <
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
Станции не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<Tabs
value={activeTab}
@@ -534,7 +473,6 @@ const LinkedItemsContentsInner = <
<TextField
{...params}
label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth
/>
)}
@@ -542,15 +480,16 @@ const LinkedItemsContentsInner = <
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options;
const query = inputValue.toLowerCase();
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const name = String(option.name || "").toLowerCase();
const description = String(
option.description || ""
).toLowerCase();
return (
name.includes(query) || description.includes(query)
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
@@ -586,15 +525,14 @@ const LinkedItemsContentsInner = <
/>
</FormControl>
<AnimatedCircleButton
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Button>
</Stack>
)}
@@ -624,14 +562,7 @@ const LinkedItemsContentsInner = <
size="small"
/>
}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
@@ -654,15 +585,14 @@ const LinkedItemsContentsInner = <
</Stack>
</Paper>
<AnimatedCircleButton
<Button
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
disabled={selectedItems.size === 0}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Button>
</Stack>
)}
</Box>

View File

@@ -1,5 +1,6 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
@@ -12,30 +13,22 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState, useMemo } 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";
import {
carrierStore,
articlesStore,
routeStore,
languageStore,
mediaStore,
isMediaIdEmpty,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
selectedCityStore,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import type { Route } from "@shared";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
@@ -45,35 +38,25 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100");
const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
const [videoTimer, setVideoTimer] = useState(60);
const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
useEffect(() => {
carrierStore.getCarriers(language);
articlesStore.getArticleList();
mediaStore.getMedia();
}, [language]);
const filteredCarriers = useMemo(() => {
@@ -130,6 +113,7 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
};
@@ -167,89 +151,26 @@ export const RouteCreatePage = observer(() => {
setIsVideoPreviewOpen(true);
};
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon;
const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview;
const handleCreateRoute = async () => {
try {
setIsLoading(true);
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier);
const governor_appeal = governorAppeal
? Number(governorAppeal)
: undefined;
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 validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.trim()
.split("\n")
@@ -261,31 +182,28 @@ export const RouteCreatePage = observer(() => {
return [lat, lon];
});
// Собираем объект маршрута
const newRoute: Partial<Route> = {
carrier:
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id,
route_number: routeNumber.trim(),
route_sys_number: govRouteNumber.trim(),
route_name: routeName.trim(),
route_number: routeNumber,
route_sys_number: govRouteNumber,
governor_appeal,
route_name: routeName,
route_direction,
scale_min: scale_min !== null ? scale_min : 0,
scale_max: scale_max !== null ? scale_max : 0,
scale_min,
scale_max,
rotate,
center_latitude,
center_longitude,
path,
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
icon: !isMediaIdEmpty(icon) ? icon : undefined,
video_timer: videoTimer,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
};
if (governor_appeal !== undefined) {
newRoute.governor_appeal = governor_appeal;
}
await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан");
navigate(-1);
@@ -297,12 +215,13 @@ export const RouteCreatePage = observer(() => {
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
return (
<div className="w-full h-full p-3 flex flex-col gap-10">
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -376,7 +295,6 @@ export const RouteCreatePage = observer(() => {
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
mt: 1,
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
@@ -385,6 +303,7 @@ export const RouteCreatePage = observer(() => {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
@@ -417,17 +336,6 @@ export const RouteCreatePage = observer(() => {
},
}}
/>
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() => setGovernorAppeal("")}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -438,41 +346,16 @@ export const RouteCreatePage = observer(() => {
</Button>
</Box>
<Box className="w-full flex justify-center gap-4 flex-wrap">
<div className="flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Иконка маршрута"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewIconOpen(true);
setPreviewIconId(selectedIconMedia?.id ?? icon ?? "");
}}
onDeleteImageClick={() => {
setIcon("");
setActiveIconMenuType(null);
}}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => setVideoPreview("")}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={videoPreview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
setVideoPreview("");
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -488,51 +371,14 @@ export const RouteCreatePage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={scaleMin}
onChange={(e) => {
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
type="number"
value={scaleMax}
required
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
@@ -553,18 +399,6 @@ export const RouteCreatePage = observer(() => {
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
<TextField
className="w-full"
label="Таймер видео (сек)"
type="number"
value={videoTimer}
onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) {
setVideoTimer(val);
}
}}
/>
</Box>
<div className="flex w-full justify-end">
<Button
@@ -594,7 +428,7 @@ export const RouteCreatePage = observer(() => {
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{effectiveVideoId && (
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
@@ -606,7 +440,7 @@ export const RouteCreatePage = observer(() => {
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: effectiveVideoId,
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
@@ -632,28 +466,6 @@ export const RouteCreatePage = observer(() => {
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={routeName || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
</div>
</Paper>
);
});

View File

@@ -1,5 +1,6 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
@@ -12,31 +13,23 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
DeleteModal,
} from "@widgets";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import {
carrierStore,
articlesStore,
routeStore,
languageStore,
mediaStore,
isMediaIdEmpty,
stationsStore,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
LoadingSpinner,
} from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => {
@@ -44,73 +37,33 @@ export const RouteEditPage = observer(() => {
const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
} finally {
setIsLoadingData(false);
}
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
};
fetchData();
}, [id]);
}, []);
useEffect(() => {
const fetchData = async () => {
await carrierStore.getCarriers(language);
await stationsStore.getStations();
await articlesStore.getArticleList();
await mediaStore.getMedia();
carrierStore.getCarriers(language);
stationsStore.getStations();
articlesStore.getArticleList();
};
fetchData();
}, [id, language]);
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({ icon: media.id });
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
editRouteData.icon && !isMediaIdEmpty(editRouteData.icon)
? mediaStore.media.find((m) => m.id === editRouteData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editRouteData.icon)
? null
: (selectedIconMedia?.id ?? editRouteData.icon);
const effectiveVideoId = isMediaIdEmpty(editRouteData.video_preview)
? null
: editRouteData.video_preview;
useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path
@@ -121,63 +74,10 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]);
const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true);
try {
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false);
}
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
setIsLoading(false);
};
const validateCoordinates = (value: string) => {
@@ -276,23 +176,8 @@ export const RouteEditPage = observer(() => {
(article) => article.id === editRouteData.governor_appeal
);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных маршрута..." />
</Box>
);
}
return (
<div className="w-full h-full p-3 flex flex-col gap-10">
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -402,7 +287,6 @@ export const RouteEditPage = observer(() => {
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
mt: 1,
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
@@ -411,6 +295,7 @@ export const RouteEditPage = observer(() => {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
@@ -448,69 +333,23 @@ export const RouteEditPage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""}
onChange={(e) => {
let value = e.target.value === "" ? null : e.target.value;
if (value && Number(value) > 297) {
value = "297";
}
if (value && Number(value) < 10) {
value = "10";
}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min: value ? Number(value) : null,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
required
label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""}
onChange={(e) => {
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: value === "" ? null : parseFloat(value),
});
}}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
@@ -546,18 +385,6 @@ export const RouteEditPage = observer(() => {
})
}
/>
<TextField
className="w-full"
label="Таймер видео (сек)"
type="number"
value={editRouteData.video_timer ?? 60}
onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) {
routeStore.setEditRouteData({ video_timer: val });
}
}}
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
@@ -575,21 +402,6 @@ export const RouteEditPage = observer(() => {
},
}}
/>
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() =>
routeStore.setEditRouteData({
governor_appeal: 0,
})
}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -600,42 +412,16 @@ export const RouteEditPage = observer(() => {
</Button>
</Box>
<Box className="w-full flex justify-center gap-4 flex-wrap">
<div className="flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Иконка маршрута"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewIconOpen(true);
setPreviewIconId(
selectedIconMedia?.id ?? editRouteData.icon ?? ""
);
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={editRouteData.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</Box>
<LinkedItems
@@ -649,6 +435,7 @@ export const RouteEditPage = observer(() => {
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
routeDirection={editRouteData.route_direction}
/>
<div className="flex w-full justify-between">
@@ -695,10 +482,10 @@ export const RouteEditPage = observer(() => {
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
{effectiveVideoId && (
{editRouteData.video_preview && (
<MediaViewer
media={{
id: effectiveVideoId,
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
@@ -722,38 +509,6 @@ export const RouteEditPage = observer(() => {
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
routeStore.setEditRouteData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</div>
</Paper>
);
});

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -17,18 +17,7 @@ export const RouteListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteRoutes = authStore.canWrite("routes");
const canShowRoutePreview =
authStore.canWrite("stations") &&
authStore.canWrite("sights") &&
authStore.canWrite("routes");
const canShowActionsColumn = canWriteRoutes || canShowRoutePreview;
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -111,69 +100,44 @@ export const RouteListPage = observer(() => {
);
},
},
...(canShowActionsColumn ? [{
{
field: "actions",
headerName: "Действия",
width: 250,
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWriteRoutes && (
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
)}
{canShowRoutePreview && (
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
)}
{canWriteRoutes && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
}] : []),
},
];
const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore;
const query = searchQuery.trim().toLowerCase();
let filtered = routes.data;
if (selectedCityId) {
const cityCarrierIds = new Set(
carriers["ru"].data
.filter((c) => c.city_id === selectedCityId)
.map((c) => c.id)
);
filtered = filtered.filter((route) => cityCarrierIds.has(route.carrier_id));
}
return filtered
.filter(
(route) =>
!query ||
(route.route_name ?? "").toLowerCase().includes(query) ||
String(route.route_number ?? "").toLowerCase().includes(query)
)
.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
}));
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
const rows = routes.data.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
}));
return (
<>
@@ -182,59 +146,32 @@ export const RouteListPage = observer(() => {
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>
{canWriteRoutes && (
<CreateButton label="Создать маршрут" path="/route/create" />
)}
<CreateButton label="Создать маршрут" path="/route/create" />
</div>
{canWriteRoutes && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteRoutes}
disableRowSelectionExcludeModel
hideFooter
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteRoutes
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) =>
Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -5,5 +5,5 @@ export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x000;
export const BACKGROUND_COLOR = 0x111111;
export const PATH_COLOR = 0xff4d4d;

View File

@@ -47,8 +47,10 @@ export function InfiniteCanvas({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
@@ -66,7 +68,7 @@ export function InfiniteCanvas({
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({
x: position.x,
y: position.y,
@@ -79,9 +81,13 @@ export function InfiniteCanvas({
e.stopPropagation();
};
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation;
@@ -91,6 +97,7 @@ export function InfiniteCanvas({
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
@@ -112,8 +119,10 @@ export function InfiniteCanvas({
e.globalX - center.x
);
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
@@ -140,13 +149,15 @@ export function InfiniteCanvas({
};
const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
@@ -155,25 +166,29 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
setIsUserInteracting(true);
setIsUserInteracting(true); // Устанавливаем флаг при зуме
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
@@ -181,6 +196,7 @@ export function InfiniteCanvas({
setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => {
setIsUserInteracting(false);
}, 100);

View File

@@ -1,19 +1,12 @@
import { Box, Stack, Typography, Button } from "@mui/material";
import { Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance, isMediaIdEmpty } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
import { authInstance } from "@shared";
type LeftSidebarProps = {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
export const LeftSidebar = observer(() => {
const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData();
@@ -42,131 +35,101 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
};
return (
<Box
sx={{
position: "relative",
height: "100%",
color: "#fff",
transition: "padding 0.3s ease",
p: open ? 2 : 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
}}
>
<Stack
direction="column"
height="100%"
width="100%"
spacing={4}
alignItems="stretch"
justifyContent="space-between"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<button
onClick={handleBack}
type="button"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff",
backgroundColor: "#222",
borderRadius: 10,
height: 40,
width: "100%",
border: "none",
cursor: "pointer",
}}
>
<div>
<Button
onClick={handleBack}
variant="contained"
color="primary"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
<p>Назад</p>
</button>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={3}
>
<div
style={{
maxWidth: 150,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
</div>
</div>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
>
<div
style={{
maxWidth: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierLogo,
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
</Stack>
<Typography
variant="h6"
textAlign="center"
sx={{ color: "#fff", marginTop: "auto" }}
>
#ВсемПоПути
</Typography>
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>{" "}
</div>
</Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
my={10}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack>
<Typography
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</Stack>
);
});

View File

@@ -36,15 +36,11 @@ const MapDataContext = createContext<{
setMapCenter: (x: number, y: number) => void;
setStationOffset: (stationId: number, x: number, y: number) => void;
setStationAlign: (stationId: number, align: number) => void;
setStationIconSize: (stationId: number, size: number) => void;
setSightCoordinates: (
sightId: number,
latitude: number,
longitude: number
) => void;
setSightIconSize: (sightId: number, size: number) => void;
setFontSize: (size: number) => void;
setRouteIconSize: (size: number) => void;
saveChanges: () => void;
}>({
originalRouteData: undefined,
@@ -64,11 +60,7 @@ const MapDataContext = createContext<{
setMapCenter: () => {},
setStationOffset: () => {},
setStationAlign: () => {},
setStationIconSize: () => {},
setSightCoordinates: () => {},
setSightIconSize: () => {},
setFontSize: () => {},
setRouteIconSize: () => {},
saveChanges: () => {},
});
@@ -149,16 +141,17 @@ export const MapDataProvider = observer(
}, [routeId]);
useEffect(() => {
if (originalRouteData) {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
}
}, [originalRouteData, routeChanges]);
useEffect(() => {
if (originalSightData) {
setSightData(originalSightData);
}
}, [originalSightData]);
if (originalSightData) setSightData(originalSightData);
}, [
originalRouteData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => {
@@ -172,57 +165,9 @@ export const MapDataProvider = observer(
});
}
function setFontSize(size: number) {
const clamped = Math.max(1, Math.min(300, size));
function setMapCenter(x: number, y: number) {
setRouteChanges((prev) => {
if (prev.font_size === clamped) {
return prev;
}
return { ...prev, font_size: clamped };
});
}
function setRouteIconSize(size: number) {
const clamped = Math.max(1, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
setRouteChanges((prev) => {
const prevLat = prev.center_latitude;
const prevLon = prev.center_longitude;
if (
prevLat !== undefined &&
prevLon !== undefined &&
Math.abs(prevLat - latitude) < epsilon &&
Math.abs(prevLon - longitude) < epsilon
) {
return prev;
}
return {
...prev,
center_latitude: latitude,
center_longitude: longitude,
};
});
setRouteData((routePrev) => {
if (!routePrev) return routePrev;
return {
...routePrev,
center_latitude: latitude,
center_longitude: longitude,
};
return { ...prev, center_latitude: x, center_longitude: y };
});
}
@@ -235,51 +180,12 @@ export const MapDataProvider = observer(
async function saveStationChanges() {
for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station);
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((s) =>
s.id === station.station_id
? {
...s,
offset_x: station.offset_x,
offset_y: station.offset_y,
align: station.align,
icon_size:
typeof station.icon_size === "number"
? station.icon_size
: s.icon_size,
}
: s
);
});
return updated;
});
}
}
async function saveSightChanges() {
for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight);
setSightData((prev) =>
prev
? prev.map((s) =>
s.id === sight.sight_id
? {
...s,
latitude: sight.latitude,
longitude: sight.longitude,
icon_size:
typeof sight.icon_size === "number"
? sight.icon_size
: s.icon_size,
}
: s
)
: prev
);
}
}
@@ -319,7 +225,6 @@ export const MapDataProvider = observer(
offset_x: x,
offset_y: y,
align: originalStation?.align ?? 1,
icon_size: originalStation?.icon_size,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
@@ -381,7 +286,6 @@ export const MapDataProvider = observer(
align: align,
offset_x: originalStation?.offset_x ?? 0,
offset_y: originalStation?.offset_y ?? 0,
icon_size: originalStation?.icon_size,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
@@ -412,83 +316,11 @@ export const MapDataProvider = observer(
});
}
function setStationIconSize(stationId: number, size: number) {
const clamped = Math.max(1, Math.min(300, Math.round(size)));
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (currentStation?.icon_size === clamped) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const next = [...prev];
next[existingIndex] = {
...next[existingIndex],
icon_size: clamped,
};
return next;
}
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
offset_x: currentStation?.offset_x ?? originalStation?.offset_x ?? 0,
offset_y: currentStation?.offset_y ?? originalStation?.offset_y ?? 0,
align: currentStation?.align ?? originalStation?.align ?? 1,
icon_size: clamped,
transfers: currentStation?.transfers ??
originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
];
});
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) =>
station.id === stationId
? { ...station, icon_size: clamped }
: station
);
});
return updated;
});
}
function setSightCoordinates(
sightId: number,
latitude: number,
longitude: number
) {
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, latitude, longitude } : sight
)
: prev
);
setSightChanges((prev) => {
const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId
@@ -514,7 +346,6 @@ export const MapDataProvider = observer(
sight_id: sightId,
latitude,
longitude,
icon_size: foundSight.icon_size,
},
];
}
@@ -523,49 +354,6 @@ export const MapDataProvider = observer(
});
}
function setSightIconSize(sightId: number, size: number) {
const clamped = Math.max(1, Math.min(300, Math.round(size)));
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, icon_size: clamped } : sight
)
: prev
);
setSightChanges((prev) => {
const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId
);
if (existingIndex !== -1) {
const next = [...prev];
next[existingIndex] = {
...next[existingIndex],
icon_size: clamped,
};
return next;
}
const foundSight =
sightData?.find((sight) => sight.id === sightId) ??
originalSightData?.find((sight) => sight.id === sightId);
if (!foundSight) {
return prev;
}
return [
...prev,
{
sight_id: sightId,
latitude: foundSight.latitude,
longitude: foundSight.longitude,
icon_size: clamped,
},
];
});
}
useEffect(() => {}, [sightChanges]);
const value = useMemo(
@@ -587,11 +375,7 @@ export const MapDataProvider = observer(
saveChanges,
setStationOffset,
setStationAlign,
setStationIconSize,
setSightCoordinates,
setSightIconSize,
setFontSize,
setRouteIconSize,
}),
[
originalRouteData,
@@ -604,10 +388,6 @@ export const MapDataProvider = observer(
isStationLoading,
isSightLoading,
selectedSight,
setStationIconSize,
setSightIconSize,
setFontSize,
setRouteIconSize,
]
);

View File

@@ -1,15 +1,8 @@
import {
Button,
Stack,
TextField,
Typography,
Slider,
CircularProgress,
Box,
} from "@mui/material";
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
@@ -21,10 +14,17 @@ export function RightSidebar() {
originalRouteData,
setMapRotation,
setMapCenter,
setFontSize: updateFontSize,
setRouteIconSize: updateRouteIconSize,
} = useMapData();
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5);
@@ -34,15 +34,14 @@ export function RightSidebar() {
});
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [fontSize, setFontSize] = useState<number>(100);
const [defaultIconSize, setDefaultIconSize] = useState<number>(100);
const [isSaving, setIsSaving] = useState<boolean>(false);
useEffect(() => {
if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -53,8 +52,6 @@ export function RightSidebar() {
x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0,
});
setFontSize(originalRouteData.font_size ?? 100);
setDefaultIconSize(originalRouteData.icon_size ?? 100);
}
}, [originalRouteData]);
@@ -66,7 +63,7 @@ export function RightSidebar() {
useEffect(() => {
setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360,
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
);
}, [rotation]);
@@ -75,55 +72,33 @@ export function RightSidebar() {
}, [rotationDegrees]);
useEffect(() => {
if (isUserEditing) {
return;
if (!isUserEditing) {
const center = screenCenter ?? { x: 0, y: 0 };
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
const latitude = routeData?.center_latitude ?? 0;
const longitude = routeData?.center_longitude ?? 0;
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
useEffect(() => {
setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180);
}
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(1, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
const handleDefaultIconSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(1, Math.min(300, Math.round(value)));
setDefaultIconSize(clamped);
updateRouteIconSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setDefaultIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
function pan({ x, y }: { x: number; y: number }) {
const coordinates = coordinatesToLocal(x, y);
setTransform(coordinates.x, coordinates.y);
}
if (!routeData) {
return null;
@@ -143,7 +118,7 @@ export function RightSidebar() {
borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Настройка маршрута
Детали о достопримечательностях
</Typography>
<Stack spacing={2} direction="row" alignItems="center">
@@ -155,26 +130,19 @@ export function RightSidebar() {
onChange={(e) => {
let newMinScale = Number(e.target.value);
if (newMinScale < 10) {
newMinScale = 10;
}
if (newMinScale > 300) {
newMinScale = 297;
// Сбрасываем к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
if (newMaxScale > 300) {
newMaxScale = 300;
}
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1);
setMinScale(1); // Сбрасываем минимальный к 1
}
setMaxScale(newMaxScale);
}
@@ -207,22 +175,19 @@ export function RightSidebar() {
onChange={(e) => {
let newMaxScale = Number(e.target.value);
if (newMaxScale < 13) {
newMaxScale = 13;
}
if (newMaxScale > 300) {
newMaxScale = 300;
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3);
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
}
setMinScale(newMinScale);
}
@@ -243,7 +208,7 @@ export function RightSidebar() {
slotProps={{
input: {
min: 3,
max: 300,
max: 10,
},
}}
/>
@@ -307,60 +272,6 @@ export function RightSidebar() {
}}
/>
<TextField
type="number"
label="Размер шрифта (%)"
variant="filled"
value={fontSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleFontSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField
type="number"
label="Размер иконок по умолчанию (%)"
variant="filled"
value={defaultIconSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleDefaultIconSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField
type="number"
label="Поворот (в градусах)"
@@ -402,15 +313,10 @@ export function RightSidebar() {
value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => {
setIsUserEditing(true);
const newValue = Number(e.target.value);
setLocalCenter((prev) => ({ ...prev, x: newValue }));
if (!isNaN(newValue) && localCenter.y !== undefined) {
setMapCenter(newValue, localCenter.y);
}
}}
onBlur={() => {
setIsUserEditing(false);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y });
}}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
@@ -426,20 +332,15 @@ export function RightSidebar() {
/>
<TextField
type="number"
label="Центр карты, долгота"
label="Центр карты, высота"
variant="filled"
value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => {
setIsUserEditing(true);
const newValue = Number(e.target.value);
setLocalCenter((prev) => ({ ...prev, y: newValue }));
if (!isNaN(newValue) && localCenter.x !== undefined) {
setMapCenter(localCenter.x, newValue);
}
}}
onBlur={() => {
setIsUserEditing(false);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) });
}}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
@@ -458,51 +359,19 @@ export function RightSidebar() {
<Button
variant="contained"
color="secondary"
sx={{ mt: 2, position: "relative" }}
disabled={isSaving}
sx={{ mt: 2 }}
onClick={async () => {
setIsSaving(true);
try {
await saveChanges();
toast.success("Изменения сохранены");
} catch (error) {
console.error(error);
toast.error("Ошибка при сохранении изменений");
} finally {
setIsSaving(false);
}
}}
>
{isSaving ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<CircularProgress size={20} sx={{ color: "inherit" }} />
<span>Сохранение...</span>
</Box>
) : (
"Сохранить изменения"
)}
Сохранить изменения
</Button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ cursor: "pointer" }}
className="absolute bottom-5 left-[-68px] z-100"
>
<path
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
fill="white"
/>
</svg>
</Stack>
);
}

View File

@@ -2,6 +2,7 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import {
BACKGROUND_COLOR,
PATH_COLOR,
@@ -14,16 +15,22 @@ import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
@@ -32,6 +39,8 @@ type LabelAlign = "left" | "center" | "right";
* Получает координату anchor.x из типа выравнивания.
*/
// --- Интерфейсы пропсов ---
interface StationProps {
station: StationData;
ruLabel: string | null;
@@ -74,6 +83,10 @@ const getAnchorFromOffset = (
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
@@ -94,6 +107,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
(g: Graphics) => {
g.clear();
// Основной фон с градиентом
g.roundRect(
-controlWidth / 2,
0,
@@ -101,8 +115,9 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" });
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
// Тонкая рамка
g.roundRect(
-controlWidth / 2,
0,
@@ -112,6 +127,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
);
g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
@@ -135,7 +151,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 });
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
@@ -214,6 +230,10 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
);
};
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer(
({
station,
@@ -254,45 +274,48 @@ const StationLabel = observer(
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true);
onTextHover?.(true); // Call the callback to indicate text is hovered
};
const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
}, 0);
}
};
const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => {
setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false);
}, 100);
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
}, 100); // Увеличиваем время до скрытия панели
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
@@ -306,6 +329,7 @@ const StationLabel = observer(
}
};
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
@@ -329,6 +353,7 @@ const StationLabel = observer(
const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
@@ -361,6 +386,7 @@ const StationLabel = observer(
y: dragStartPos.current.y + dy_screen,
};
// Проверяем, изменилась ли позиция
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
@@ -380,7 +406,7 @@ const StationLabel = observer(
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
@@ -390,29 +416,34 @@ const StationLabel = observer(
[position.x, position.y]
);
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2;
case "center":
// Центрируем относительно центра верхнего текста
return 0;
case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2;
default:
return 0;
}
};
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0;
return 0; // anchor.x = 0 (левый край)
case "center":
return 0.5;
return 0.5; // anchor.x = 0.5 (центр)
case "right":
return 1;
return 1; // anchor.x = 1 (правый край)
default:
return 0.5;
}
@@ -491,6 +522,10 @@ const StationLabel = observer(
}
);
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({
station,
ruLabel,
@@ -513,9 +548,10 @@ export const Station = ({
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) {
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
g.fill({ color: 0x00aaff }); // Highlight color when hovered
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });

View File

@@ -50,6 +50,7 @@ const TransformContext = createContext<{
setScaleAtCenter: () => {},
});
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
@@ -58,10 +59,12 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback(
(screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale;
const cosRotation = Math.cos(-rotation);
// Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -74,6 +77,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation]
);
// Inverse of screenToLocal
const localToScreen = useCallback(
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE;
@@ -116,6 +120,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta,
};
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to);
setPosition(newPosition);
},
@@ -145,11 +150,13 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
@@ -177,6 +184,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
);
const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale);
}, []);
@@ -229,6 +237,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
);
};
// Custom hook for easy access to transform values
export const useTransform = () => {
const context = useContext(TransformContext);
if (!context) {

View File

@@ -26,7 +26,7 @@ export function Widgets() {
justifyContent="center"
>
<Typography variant="h6" sx={{ color: "#fff" }}>
Остановка
Станция
</Typography>
</Stack>

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { extend } from "@pixi/react";
import { Application, extend } from "@pixi/react";
import {
Container,
Graphics,
@@ -9,18 +9,24 @@ import {
TilingSprite,
Text,
} from "pixi.js";
import { Box, Stack } from "@mui/material";
import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar";
import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
extend({
Container,
@@ -36,7 +42,7 @@ const Loading = () => {
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
@@ -45,33 +51,14 @@ const Loading = () => {
return null;
};
export const RoutePreview = () => {
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
const { routeData, stationData, sightData } = useMapData();
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading />
<Box
sx={{
position: "relative",
width: isLeftSidebarOpen ? 300 : 0,
transition: "width 0.3s ease",
overflow: "visible",
height: "100%",
bgcolor: "primary.main",
borderRight: isLeftSidebarOpen
? "1px solid rgba(255,255,255,0.08)"
: "none",
display: "flex",
justifyContent: "flex-start",
flexShrink: 0,
}}
>
<LeftSidebar
open={isLeftSidebarOpen}
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
/>
</Box>
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap />
<Widgets />
@@ -84,8 +71,15 @@ export const RoutePreview = () => {
};
export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, setTransform, screenCenter } = useTransform();
const { routeData, stationData, sightData, originalRouteData } = useMapData();
const {
routeData,
stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false);
@@ -171,7 +165,8 @@ export const RouteMap = observer(() => {
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
@@ -189,8 +184,7 @@ export const RouteMap = observer(() => {
return <Sight sight={sight} id={index} key={sight.id} />;
})}
</InfiniteCanvas>
</Application> */}
<WebGLRouteMapPrototype />
</Application>
</div>
);
});

View File

@@ -3,8 +3,6 @@ export interface RouteData {
carrier_id: number;
center_latitude: number;
center_longitude: number;
icon_size?: number;
font_size: number;
governor_appeal: number;
id: number;
path: [number, number][];
@@ -33,8 +31,6 @@ export interface StationData {
address: string;
city_id?: number;
description: string;
icon?: string;
icon_size?: number;
id: number;
latitude: number;
longitude: number;
@@ -52,33 +48,25 @@ export interface StationPatchData {
offset_y: number;
align: number;
transfers: StationTransferData;
icon_size?: number;
}
export interface SightPatchData {
sight_id: number;
latitude: number;
longitude: number;
icon_size?: number;
}
export interface SightData {
address: string;
alt_icon?: string | null; // uuid
city: string;
city_id: number;
id: number;
is_default_icon?: boolean;
icon?: string | null; // uuid
icon_size?: number;
latitude: number;
left_article: number;
longitude: number;
name: string;
offset_x?: number;
offset_y?: number;
preview_media: number;
thumbnail?: string | null; // uuid
thumbnail: string; // uuid
watermark_lu: string; // uuid
watermark_rd: string; // uuid
}

View File

@@ -1,203 +0,0 @@
import { useEffect, useRef, useState, type ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
const LANGUAGES = ["ru", "zh", "en"] as const;
type Language = (typeof LANGUAGES)[number];
type LanguageSelectorProps = {
onBack?: () => void;
isSidebarOpen?: boolean;
};
const renderLanguageIcon = (lang: Language): ReactElement => {
switch (lang) {
case "ru":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
fill="white"
/>
<path
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
fill="white"
/>
</svg>
);
case "zh":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
<path
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
fill={"white"}
/>
<path
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
fill={"white"}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
fill={"white"}
/>
</svg>
);
case "en":
default:
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
fill="white"
/>
</svg>
);
}
};
const CollapsedIcon = () => (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
fill="#FFFFFF"
/>
</svg>
);
const ArrowIcon = ({ rotation }: { rotation: number }) => (
<svg
style={{
transform: `rotate(${rotation}deg)`,
transition: "transform 0.15s ease",
}}
className="h-12 w-12"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8" y="7" width="31" height="33" fill="black" />
<path
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
fill="white"
/>
</svg>
);
const LanguageSelector = observer(
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
const { setLanguage } = languageStore;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleOutside = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => {
document.removeEventListener("pointerdown", handleOutside);
};
}, [isOpen]);
const handleSelect = (code: Language) => {
setLanguage(code);
setIsOpen(false);
};
const toggle = () => setIsOpen((prev) => !prev);
return (
<div
ref={containerRef}
className="pointer-events-auto"
style={{
width: "500px",
transition: "width 0.25s ease",
flex: "0 0 auto",
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 ">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onBack?.();
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
}
>
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
</button>
{isOpen ? (
LANGUAGES.map((lang) => (
<button
key={lang}
type="button"
onClick={(event) => {
event.stopPropagation();
handleSelect(lang);
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
>
{renderLanguageIcon(lang)}
</button>
))
) : (
<div
className="flex h-12 w-12 items-center justify-center"
onClick={toggle}
>
<CollapsedIcon />
</div>
)}
</div>
</div>
</div>
);
}
);
export default LanguageSelector;

View File

@@ -1,619 +0,0 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking station:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking stations:", error);
setError("Failed to link stations");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting stations:", error);
setError("Failed to delete stations");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all stations:", error);
setError(null);
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options;
const query = inputValue.toLowerCase();
return options.filter((option) => {
const name = String(option.name || "").toLowerCase();
const description = String(
option.description || ""
).toLowerCase();
return (
name.includes(query) || description.includes(query)
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название или описание остановки..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
width: "100%",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1,12 +1,10 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import {
authStore,
cityStore,
languageStore,
sightsStore,
selectedCityStore,
SearchInput,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
@@ -17,32 +15,21 @@ import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchSights = async () => {
setIsLoading(true);
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities(language);
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getCities(language);
await getSights();
setIsLoading(false);
};
fetchSights();
@@ -70,68 +57,61 @@ export const SightListPage = observer(() => {
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
const lang = language as "ru" | "en" | "zh";
const cityName = canReadCities
? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name
: authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name;
return (
<div className="w-full h-full flex items-center">
{cityName ?? <Minus size={20} className="text-red-500" />}
{params.value ? (
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
...(authStore.canWrite("sights") ? [{
{
field: "actions",
headerName: "Действия",
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
}] : []),
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
const allowedCityIds = canReadCities
? null
: authStore.meCities["ru"].map((c) => c.city_id);
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
return sights.filter((sight: any) => {
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
return false;
}
if (selectedCityId && sight.city_id !== selectedCityId) {
return false;
}
return true;
});
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
const query = searchQuery.trim().toLowerCase();
const rows = filteredSights
.filter((sight: any) => !query || (sight.name ?? "").toLowerCase().includes(query))
.map((sight) => ({
id: sight.id,
name: sight.name,
city_id: sight.city_id,
}));
const canWriteSights = authStore.canWrite("sights");
const rows = filteredSights.map((sight) => ({
id: sight.id,
name: sight.name,
city_id: sight.city_id,
}));
return (
<>
@@ -140,58 +120,35 @@ export const SightListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Достопримечательности</h1>
{canWriteSights && (
<CreateButton
label="Создать достопримечательность"
path="/sight/create"
/>
)}
<CreateButton
label="Создать достопримечательность"
path="/sight/create"
/>
</div>
{canWriteSights && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteSights}
disableRowSelectionExcludeModel
hideFooter
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={
canWriteSights
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(Number);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(Number);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,2 +1 @@
export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -5,10 +5,9 @@ import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { runInAction } from "mobx";
export const SnapshotCreatePage = observer(() => {
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
const { createSnapshot } = snapshotStore;
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -25,7 +24,7 @@ export const SnapshotCreatePage = observer(() => {
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
@@ -43,28 +42,13 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => {
try {
setIsLoading(true);
const id = await createSnapshot(name);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
await getStorageInfo();
navigate(-1);
}
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Ошибка при создании экспорта медиа");
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
}
@@ -72,15 +56,7 @@ export const SnapshotCreatePage = observer(() => {
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}

View File

@@ -1,76 +1,31 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, CircularProgress } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10;
const SEGMENT_COLORS = [
"#FF3B30",
"#FF9500",
"#FFCC00",
"#8E8E93",
"#AEAEB2",
"#34C759",
"#007AFF",
"#5856D6",
"#AF52DE",
"#FF2D55",
];
import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => {
const {
snapshots,
getSnapshots,
deleteSnapshot,
restoreSnapshot,
storageInfo,
getStorageInfo,
} = snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const availableGB = storageInfo ? storageInfo.available_disk_space_gb : null;
const totalGB = storageInfo ? storageInfo.total_disk_space_gb : null;
const usedGB =
totalGB !== null && availableGB !== null ? totalGB - availableGB : null;
const isLowStorage =
availableGB !== null && availableGB < LOW_STORAGE_THRESHOLD_GB;
useEffect(() => {
const fetchSnapshots = async () => {
setIsLoading(true);
await Promise.all([getSnapshots(), getStorageInfo()]);
await getSnapshots();
setIsLoading(false);
};
fetchSnapshots();
}, [language]);
const formatCreationTime = (isoString: string | undefined) => {
if (!isoString) return "";
const [datePart, timePartWithMs] = isoString.split("T");
if (!datePart || !timePartWithMs) return isoString;
const timePart = timePartWithMs.split(".")[0];
return `${datePart} - ${timePart}`;
};
const columns: GridColDef[] = [
{
field: "name",
@@ -82,204 +37,64 @@ export const SnapshotListPage = observer(() => {
headerName: "Родитель",
flex: 1,
},
{
field: "created_at",
headerName: ата создания",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
{
field: "occupied_disk_space_gb",
headerName: "Размер",
width: 120,
field: "actions",
headerName: ействия",
width: 300,
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div>
{params.value != null ? `${params.value.toFixed(1)} ГБ` : "-"}
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsRestoreModalOpen(true);
setRowId(params.row.id);
}}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
...(canManageSnapshots
? [
{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsRestoreModalOpen(true);
setRowId(params.row.id);
}}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
},
]
: []),
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return snapshots
.filter(
(snapshot) =>
!query ||
(snapshot.Name ?? "").toLowerCase().includes(query) ||
(snapshots.find((s) => s.ID === snapshot.ParentID)?.Name ?? "")
.toLowerCase()
.includes(query),
)
.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
}));
}, [snapshots, searchQuery]);
const snapshotsGB = rows.reduce(
(sum, row) => sum + (row.occupied_disk_space_gb ?? 0),
0,
);
const systemGB = usedGB !== null ? Math.max(0, usedGB - snapshotsGB) : null;
const rows = snapshots.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
}));
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && (
<CreateButton
label="Создать экспорт медиа"
path="/snapshot/create"
disabled={isLowStorage}
/>
)}
<h1 className="text-2xl ">Снапшоты</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" />
</div>
{usedGB != null && totalGB != null && (
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
<div className="flex items-baseline gap-3 mb-3">
<span className="text-lg font-semibold">Хранилище</span>
<span className="text-sm text-gray-500">
Используется: {usedGB.toFixed(2)} ГБ из {totalGB.toFixed(0)} ГБ
</span>
</div>
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row, i) => {
const pct =
row.occupied_disk_space_gb != null && totalGB > 0
? (row.occupied_disk_space_gb / totalGB) * 100
: 0;
if (pct <= 0) return null;
return (
<div
key={row.id}
style={{
width: `${pct}%`,
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
/>
);
})}
{systemGB !== null && systemGB > 0 && totalGB > 0 && (
<div
style={{
width: `${(systemGB / totalGB) * 100}%`,
backgroundColor: "#C7C7CC",
}}
title={`Системные данные: ${systemGB.toFixed(1)} ГБ`}
/>
)}
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
{rows.map((row, i) => {
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
return null;
return (
<div
key={row.id}
className="flex items-center gap-1.5 text-xs text-gray-700"
>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/>
{row.name}
</div>
);
})}
{systemGB !== null && systemGB > 0 && (
<div className="flex items-center gap-1.5 text-xs text-gray-700">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: "#C7C7CC" }}
/>
Системные данные
</div>
)}
{availableGB !== null && availableGB > 0 && (
<div className="flex items-center gap-1.5 text-xs text-gray-700">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-100" />
Свободно
</div>
)}
</div>
</div>
)}
{isLowStorage && (
<Alert severity="warning" className="mb-4">
Недостаточно места на диске! Осталось {availableGB?.toFixed(1)} ГБ
из {totalGB?.toFixed(0)} ГБ. Создание новых экспортов заблокировано.
Удалите ненужные экспорты для освобождения места или обратитесь к
администратору сервера.
</Alert>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет экспортов медиа"
)}
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
</Box>
),
}}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
@@ -15,21 +16,11 @@ import {
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
import { authInstance, languageStore, selectedCityStore } from "@shared";
type Field<T> = {
label: string;
@@ -102,26 +93,12 @@ const LinkedSightsContentsInner = <
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "station";
const childResource = "sight";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
@@ -134,11 +111,6 @@ const LinkedSightsContentsInner = <
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
@@ -149,24 +121,13 @@ const LinkedSightsContentsInner = <
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
const requestData = {
sight_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
@@ -180,23 +141,15 @@ const LinkedSightsContentsInner = <
.catch((error) => {
console.error("Error linking sight:", error);
setError("Failed to link sight");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
@@ -205,125 +158,9 @@ const LinkedSightsContentsInner = <
.catch((error) => {
console.error("Error deleting sight:", error);
setError("Failed to delete sight");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking sights:", error);
setError("Failed to link sights");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting sights:", error);
setError("Failed to delete sights");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
@@ -354,7 +191,7 @@ const LinkedSightsContentsInner = <
})
.catch((error) => {
console.error("Error fetching all sights:", error);
setError(null);
setError("Failed to load available sights");
setAllItems([]);
});
}
@@ -367,16 +204,6 @@ const LinkedSightsContentsInner = <
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
@@ -392,15 +219,6 @@ const LinkedSightsContentsInner = <
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
@@ -411,7 +229,7 @@ const LinkedSightsContentsInner = <
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
<Button
variant="outlined"
color="error"
size="small"
@@ -419,11 +237,9 @@ const LinkedSightsContentsInner = <
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</Button>
</TableCell>
)}
</TableRow>
@@ -433,20 +249,6 @@ const LinkedSightsContentsInner = <
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены
@@ -456,133 +258,53 @@ const LinkedSightsContentsInner = <
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">
Добавить достопримечательности
Добавить достопримечательность
</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск достопримечательностей"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Достопримечательности не найдены"
: "Нет доступных достопримечательностей"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
Добавить
</Button>
</Stack>
)}

View File

@@ -1,34 +1,26 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import {
stationsStore,
languageStore,
cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
useSelectedCity,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useEffect, useState } from "react";
import {
ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
} from "@widgets";
import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
@@ -40,18 +32,10 @@ export const StationCreatePage = observer(() => {
createStation,
setLanguageCreateStationData,
} = stationsStore;
const { getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
@@ -65,6 +49,7 @@ export const StationCreatePage = observer(() => {
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
@@ -73,19 +58,17 @@ export const StationCreatePage = observer(() => {
navigate("/station");
} catch (error) {
console.error("Error creating station:", error);
toast.error("Ошибка при создании остановки");
toast.error("Ошибка при создании станции");
} finally {
setIsLoading(false);
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
const isNameMissing =
!createStationData.ru.name ||
!createStationData.en.name ||
!createStationData.zh.name;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
@@ -95,64 +78,28 @@ export const StationCreatePage = observer(() => {
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchCities = async () => {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchCities();
mediaStore.getMedia();
}, []);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateCommonData({ icon: media.id });
};
const selectedMedia =
createStationData.common.icon &&
!isMediaIdEmpty(createStationData.common.icon)
? mediaStore.media.find((m) => m.id === createStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createStationData.common.icon)
? null
: selectedMedia?.id ?? createStationData.common.icon;
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
@@ -163,7 +110,7 @@ export const StationCreatePage = observer(() => {
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
return (
<Box className="w-full h-full p-3 flex flex-col gap-10">
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
@@ -191,6 +138,23 @@ export const StationCreatePage = observer(() => {
}
/>
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={createStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setCreateCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Описание"
@@ -251,7 +215,7 @@ export const StationCreatePage = observer(() => {
value={createStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = availableCities.find(
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setCreateCommonData({
@@ -260,7 +224,7 @@ export const StationCreatePage = observer(() => {
});
}}
>
{availableCities.map((city) => (
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -268,36 +232,12 @@ export const StationCreatePage = observer(() => {
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateCommonData({ icon: "" });
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -308,28 +248,6 @@ export const StationCreatePage = observer(() => {
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
@@ -338,6 +256,6 @@ export const StationCreatePage = observer(() => {
}}
/>
)}
</Box>
</Paper>
);
});

View File

@@ -1,41 +1,26 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
stationsStore,
languageStore,
cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
LoadingSpinner,
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import {
ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
DeleteModal,
} from "@widgets";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore;
const { id } = useParams();
const {
@@ -45,18 +30,9 @@ export const StationEditPage = observer(() => {
editStation,
setLanguageEditStationData,
} = stationsStore;
const { getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
@@ -74,6 +50,7 @@ export const StationEditPage = observer(() => {
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
@@ -81,15 +58,16 @@ export const StationEditPage = observer(() => {
toast.success("Остановка успешно обновлена");
} catch (error) {
console.error("Error updating station:", error);
toast.error("Ошибка при обновлении остановки");
toast.error("Ошибка при обновлении станции");
} finally {
setIsLoading(false);
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
@@ -103,102 +81,33 @@ export const StationEditPage = observer(() => {
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCommonData({ icon: media.id });
};
const selectedMedia =
editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon)
? mediaStore.media.find((m) => m.id === editStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon)
? null
: selectedMedia?.id ?? editStationData.common.icon;
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) {
setIsLoadingData(false);
return;
}
if (!id) return;
setIsLoadingData(true);
try {
const stationId = Number(id);
await getEditStation(stationId);
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
const stationId = Number(id);
await getEditStation(stationId);
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchAndSetStationData();
}, [id]);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editStationData.common.city_id &&
!baseCities.some((city) => city.id === editStationData.common.city_id)
? [
{
id: editStationData.common.city_id,
name: editStationData.common.city || `Город ${editStationData.common.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных остановки..." />
</Box>
);
}
return (
<Box className="w-full h-full p-3 flex flex-col gap-10">
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
@@ -227,6 +136,23 @@ export const StationEditPage = observer(() => {
}
/>
<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="Описание"
@@ -287,7 +213,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = availableCities.find(
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setEditCommonData({
@@ -296,7 +222,7 @@ export const StationEditPage = observer(() => {
});
}}
>
{availableCities.map((city) => (
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -304,29 +230,6 @@ export const StationEditPage = observer(() => {
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteIconModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
{id && (
<LinkedSights
parentId={Number(id)}
@@ -340,7 +243,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -350,38 +253,6 @@ export const StationEditPage = observer(() => {
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditCommonData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
@@ -391,6 +262,6 @@ export const StationEditPage = observer(() => {
}}
/>
)}
</Box>
</Paper>
);
});

View File

@@ -1,22 +1,16 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import {
authStore,
languageStore,
stationsStore,
selectedCityStore,
SearchInput,
cityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus, Route } from "lucide-react";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
CreateButton,
DeleteModal,
LanguageSwitcher,
EditStationTransfersModal,
} from "@widgets";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => {
@@ -24,24 +18,15 @@ export const StationListPage = observer(() => {
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(
null
);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteStations = authStore.canWrite("stations");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
@@ -66,8 +51,8 @@ export const StationListPage = observer(() => {
},
},
{
field: "description",
headerName: "Описание",
field: "system_name",
headerName: "Системное название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -81,65 +66,73 @@ export const StationListPage = observer(() => {
);
},
},
{
field: "direction",
headerName: "Направление",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.direction === true ? "text-green-500" : "text-red-500"
}
>
{params.row.direction ? "Прямой" : "Обратный"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 200,
align: "center" as const,
headerAlign: "center" as const,
width: 140,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && (
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
)}
{canWriteStations && (
<button
onClick={() => {
setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true);
}}
title="Редактировать пересадки"
>
<Route size={20} className="text-purple-500" />
</button>
)}
{canWriteStations && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
<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>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = useMemo(() => {
// Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore;
const query = searchQuery.trim().toLowerCase();
return stationLists[language].data
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
.filter(
(station: any) =>
!query ||
(station.name ?? "").toLowerCase().includes(query) ||
(station.description ?? "").toLowerCase().includes(query)
)
.map((station: any) => ({
id: station.id,
name: station.name,
description: station.description,
}));
}, [stationLists[language].data, selectedCityStore.selectedCityId, searchQuery]);
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
direction: station.direction,
}));
return (
<>
@@ -147,82 +140,38 @@ export const StationListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Остановки</h1>
{canWriteStations && (
<CreateButton label="Создать остановки" path="/station/create" />
)}
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать остановки" path="/station/create" />
</div>
{canWriteStations && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteStations}
disableRowSelectionExcludeModel
hideFooterPagination
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
canWriteStations
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet)
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
@@ -256,15 +205,6 @@ export const StationListPage = observer(() => {
setIsBulkDeleteModalOpen(false);
}}
/>
<EditStationTransfersModal
open={isTransfersModalOpen}
onClose={() => {
setIsTransfersModalOpen(false);
setSelectedStationId(null);
}}
stationId={selectedStationId}
/>
</>
);
});

View File

@@ -1,9 +1,9 @@
import { Paper, Box } from "@mui/material";
import { languageStore, stationsStore, LoadingSpinner } from "@shared";
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, useState } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights";
@@ -12,38 +12,15 @@ export const StationPreviewPage = observer(() => {
const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate();
const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
await getStationPreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
await getStationPreview(Number(id));
}
})();
}, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных остановки..." />
</Box>
);
}
return (
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher />
@@ -67,6 +44,21 @@ export const StationPreviewPage = observer(() => {
<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>

View File

@@ -1,34 +1,22 @@
import { Button, Paper, TextField } from "@mui/material";
import {
Button,
Paper,
TextField,
Checkbox,
FormControlLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import {
userStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
import { userStore } from "@shared";
import { useState } from "react";
export const UserCreatePage = observer(() => {
const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
mediaStore.getMedia();
}, []);
const handleCreate = async () => {
try {
@@ -43,29 +31,6 @@ export const UserCreatePage = observer(() => {
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
media.id
);
};
const selectedMedia =
createUserData.icon && !isMediaIdEmpty(createUserData.icon)
? mediaStore.media.find((m) => m.id === createUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createUserData.icon)
? null
: selectedMedia?.id ?? createUserData.icon ?? null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
@@ -89,8 +54,7 @@ export const UserCreatePage = observer(() => {
e.target.value,
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
createUserData.icon
createUserData.is_admin || false
)
}
/>
@@ -105,8 +69,7 @@ export const UserCreatePage = observer(() => {
createUserData.name || "",
e.target.value,
createUserData.password || "",
createUserData.is_admin || false,
createUserData.icon
createUserData.is_admin || false
)
}
/>
@@ -121,39 +84,27 @@ export const UserCreatePage = observer(() => {
createUserData.name || "",
createUserData.email || "",
e.target.value,
createUserData.is_admin || false,
createUserData.icon
createUserData.is_admin || false
)
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
""
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
<div className="w-full flex flex-col items-start">
<FormControlLabel
control={
<Checkbox
checked={createUserData.is_admin || false}
onChange={(e) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
e.target.checked
);
}}
/>
}
label="Администратор"
/>
</div>
@@ -173,28 +124,6 @@ export const UserCreatePage = observer(() => {
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@@ -4,239 +4,68 @@ import {
Checkbox,
Paper,
TextField,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
userStore,
languageStore,
LoadingSpinner,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
authStore,
cityStore,
MultiSelect,
type User,
type UserCity,
} from "@shared";
import { userStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
export const UserEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore;
const canReadCities = authStore.canRead("cities");
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localCityIds, setLocalCityIds] = useState<number[]>([]);
const [initialUserCities, setInitialUserCities] = useState<UserCity[]>([]);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { editUserData, editUser, getUser, setEditUserData } = userStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
await Promise.all([
mediaStore.getMedia(),
authStore.canRead("cities")
? cityStore.getRuCities()
: authStore.fetchMeCities().catch(() => undefined),
]);
const data = (await getUser(Number(id))) as User | undefined;
if (data) {
setEditUserData(
data.name || "",
data.email || "",
data.password || "",
data.is_admin || false,
data.icon || "",
);
const roles = data.roles ?? [];
setLocalRoles(roles);
setEditUserRoles(roles);
const cityIds = (data.cities ?? []).map((c) => c.city_id);
setLocalCityIds(cityIds);
setInitialUserCities(data.cities ?? []);
}
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
}
})();
}, [id]);
const handleSave = async () => {
const handleEdit = async () => {
try {
setIsLoading(true);
const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
setEditUserRoles(rolesToSave);
await editUser(Number(id));
await userStore.addUserCityAction({
id: Number(id),
city_ids: localCityIds,
});
toast.success("Пользователь успешно обновлён");
toast.success("Пользователь успешно обновлен");
navigate("/user");
} catch {
} catch (error) {
toast.error("Ошибка при обновлении пользователя");
} finally {
setIsLoading(false);
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
media.id,
);
};
useEffect(() => {
(async () => {
if (id) {
const data = await getUser(Number(id));
const selectedMedia =
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
? mediaStore.media.find((m) => m.id === editUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
? null
: (selectedMedia?.id ?? editUserData.icon ?? null);
const cityOptionsMap = new Map<number, string>();
const sourceCities: UserCity[] = canReadCities
? cityStore.ruCities.data
.filter((city) => city.id !== undefined)
.map((city) => ({
city_id: city.id as number,
name: city.name,
}))
: authStore.meCities.ru;
for (const city of sourceCities) {
cityOptionsMap.set(city.city_id, city.name);
}
for (const city of initialUserCities) {
if (!cityOptionsMap.has(city.city_id)) {
cityOptionsMap.set(city.city_id, city.name);
}
}
const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({
value,
label,
}));
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных пользователя..." />
</Box>
);
}
setEditUserData(
data?.name || "",
data?.email || "",
data?.password || "",
data?.is_admin || false
);
}
})();
}, [id]);
return (
<Paper className="w-full p-6 flex flex-col gap-8">
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
<ArrowLeft size={20} />
Назад
</button>
{/* ── Основные данные ── */}
<section className="flex flex-col gap-6">
<Typography variant="h6">Основные данные</Typography>
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<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-start">
<TextField
fullWidth
label="Имя"
@@ -247,8 +76,7 @@ export const UserEditPage = observer(() => {
e.target.value,
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
editUserData.icon,
editUserData.is_admin || false
)
}
/>
@@ -262,265 +90,56 @@ export const UserEditPage = observer(() => {
editUserData.name || "",
e.target.value,
editUserData.password || "",
editUserData.is_admin || false,
editUserData.icon,
editUserData.is_admin || false
)
}
/>
<TextField
fullWidth
label="Пароль"
placeholder="Оставить пустым, чтобы не менять"
value={editUserData.password || ""}
required
onChange={(e) =>
setEditUserData(
editUserData.name || "",
editUserData.email || "",
e.target.value,
editUserData.is_admin || false,
editUserData.icon,
editUserData.is_admin || false
)
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
</section>
<Divider />
{/* ── Права доступа ── */}
<section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography>
<FormControlLabel
control={
<Checkbox
checked={localRoles.includes("admin")}
onChange={(e) => {
if (e.target.checked) {
setLocalRoles((prev) => {
let next = prev.filter((r) => r !== "admin");
for (const { key } of ROLE_RESOURCES) {
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
next.push(`${key}_rw`);
}
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
});
} else {
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
}
}}
checked={editUserData.is_admin || false}
onChange={(e) =>
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
e.target.checked
)
}
/>
}
label="Полный доступ (admin)"
label="Администратор"
/>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Создание (snapshot_create)
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
updated.includes(`${k}_rw`),
);
if (allRw && !updated.includes("admin")) {
const next = [...updated];
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
}
if (!allRw) {
return updated.filter((r) => r !== "admin");
}
return updated;
});
};
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
/>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
</section>
<Divider />
{/* ── Города ── */}
<section className="flex flex-col gap-4">
<Typography variant="h6">Города</Typography>
<MultiSelect
options={cityOptions}
value={localCityIds}
onChange={(ids) => setLocalCityIds(ids as number[])}
label="Города"
placeholder="Выберите города"
loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData}
/>
</section>
<Button
variant="contained"
className="self-end"
startIcon={isLoading ? <Loader2 size={20} className="animate-spin" /> : <Save size={20} />}
onClick={handleSave}
disabled={isLoading || !editUserData.name || !editUserData.email}
>
Сохранить
</Button>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
"",
);
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center self-end"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editUserData.name || !editUserData.email}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, userStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -16,12 +16,6 @@ export const UserListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const canWriteUsers = authStore.canWrite("users");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchUsers = async () => {
@@ -83,104 +77,78 @@ export const UserListPage = observer(() => {
},
},
...(canWriteUsers ? [{
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
}] : []),
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button>
<Pencil
size={20}
className="text-blue-500"
onClick={() => {
navigate(`/user/${params.row.id}/edit`);
}}
/>
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (users.data ?? [])
.filter((user) =>
!query ||
(user.name ?? "").toLowerCase().includes(query) ||
(user.email ?? "").toLowerCase().includes(query)
)
.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
name: user.name,
}));
}, [users.data, searchQuery]);
const rows = users.data?.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,
name: user.name,
}));
return (
<>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Пользователи</h1>
{canWriteUsers && (
<CreateButton label="Создать пользователя" path="/user/create" />
)}
<CreateButton label="Создать пользователя" path="/user/create" />
</div>
{canWriteUsers && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel
hideFooterPagination
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
canWriteUsers
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (

View File

@@ -25,7 +25,6 @@ export const VehicleCreatePage = observer(() => {
const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
@@ -37,12 +36,11 @@ export const VehicleCreatePage = observer(() => {
try {
setIsLoading(true);
await vehicleStore.createVehicle(
tailNumber,
Number(tailNumber),
Number(type),
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string,
carrierId!,
model || undefined,
carrierId!
);
toast.success("Транспорт успешно создан");
} catch (error) {
@@ -105,14 +103,6 @@ export const VehicleCreatePage = observer(() => {
</Select>
</FormControl>
<TextField
fullWidth
label="Модель ТС"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Произвольное название модели"
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"

View File

@@ -6,9 +6,6 @@ import {
FormControl,
InputLabel,
Button,
Box,
FormControlLabel,
Checkbox,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -19,7 +16,6 @@ import {
languageStore,
VEHICLE_TYPES,
vehicleStore,
LoadingSpinner,
} from "@shared";
import { toast } from "react-toastify";
@@ -35,8 +31,6 @@ export const VehicleEditPage = observer(() => {
} = vehicleStore;
const { getCarriers } = carrierStore;
const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
@@ -44,61 +38,31 @@ export const VehicleEditPage = observer(() => {
}, []);
useEffect(() => {
const fetchAndSetVehicleData = async () => {
if (!id) {
setIsLoadingData(false);
return;
}
(async () => {
await getVehicle(Number(id));
await getCarriers(language);
setIsLoadingData(true);
try {
await getVehicle(Number(id));
await getCarriers(language);
setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
type: vehicle[Number(id)]?.vehicle.type ?? 0,
carrier: vehicle[Number(id)]?.vehicle.carrier ?? "",
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id ?? 0,
model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
});
} finally {
setIsLoadingData(false);
}
};
fetchAndSetVehicleData();
setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
type: vehicle[Number(id)]?.vehicle.type,
carrier: vehicle[Number(id)]?.vehicle.carrier,
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
});
})();
}, [id, language]);
const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => {
try {
setIsLoading(true);
await editVehicle(Number(id), editVehicleData);
toast.success("Транспортное средство успешно обновлено");
navigate("/devices");
navigate("/vehicle");
} catch (error) {
toast.error("Ошибка при обновлении транспортного средства");
} finally {
setIsLoading(false);
}
};
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных транспортного средства..." />
</Box>
);
}
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
@@ -120,7 +84,7 @@ export const VehicleEditPage = observer(() => {
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
tail_number: e.target.value,
tail_number: Number(e.target.value),
})
}
/>
@@ -164,35 +128,6 @@ export const VehicleEditPage = observer(() => {
</Select>
</FormControl>
<TextField
fullWidth
label="Модель ТС"
value={editVehicleData.model}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
model: e.target.value,
})
}
placeholder="Произвольное название модели"
/>
<FormControlLabel
control={
<Checkbox
checked={editVehicleData.snapshot_update_blocked}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
snapshot_update_blocked: e.target.checked,
})
}
color="primary"
/>
}
label="Блокировка обновления ПО"
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -18,13 +18,7 @@ export const VehicleListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore;
const canWriteVehicles = authStore.canWrite("devices");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchData = async () => {
@@ -106,116 +100,77 @@ export const VehicleListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 200,
align: "center" as const,
headerAlign: "center" as const,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const canWrite = authStore.canWrite("devices");
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWrite && (
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
)}
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{canWrite && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (vehicles.data ?? [])
.filter(
(vehicle) =>
!query ||
(vehicle.vehicle.tail_number ?? "").toLowerCase().includes(query) ||
(vehicle.vehicle.carrier ?? "").toLowerCase().includes(query)
)
.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier,
city: carriers[language].data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
}));
}, [vehicles.data, carriers[language].data, searchQuery]);
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[language].data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
}));
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1>
<CreateButton
label="Создать транспортное средство"
path="/vehicle/create"
/>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
{canWriteVehicles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
)}
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
checkboxSelection={canWriteVehicles}
disableRowSelectionExcludeModel
hideFooterPagination
checkboxSelection
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
canWriteVehicles
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (

View File

@@ -35,4 +35,3 @@ const languageInstance = (language: Language) => {
};
export { authInstance, languageInstance };
export { mobxFetch } from "./mobxFetch";

View File

@@ -1,183 +0,0 @@
import { runInAction } from "mobx";
type mobxFetchOptions<RequestType, ResponseType, Store> = {
store: Store;
value?: keyof Store;
values?: Array<keyof Store>;
loading?: keyof Store;
error?: keyof Store;
fn: RequestType extends void
? (signal?: AbortSignal) => Promise<ResponseType>
: (request: RequestType, signal?: AbortSignal) => Promise<ResponseType>;
pollingInterval?: number;
resetValue?: boolean;
transform?: (response: ResponseType) => Partial<Record<string, any>>;
onSuccess?: (response: ResponseType) => void;
};
type FetchFunction<RequestType, ResponseType> = RequestType extends void
? {
(): Promise<ResponseType | null>;
stopPolling?: () => void;
}
: {
(request: RequestType): Promise<ResponseType | null>;
stopPolling?: () => void;
};
export function mobxFetch<ResponseType, Store extends Record<string, any>>(
options: mobxFetchOptions<void, ResponseType, Store>
): FetchFunction<void, ResponseType>;
export function mobxFetch<
RequestType,
ResponseType,
Store extends Record<string, any>,
>(
options: mobxFetchOptions<RequestType, ResponseType, Store>
): FetchFunction<RequestType, ResponseType>;
export function mobxFetch<
RequestType,
ResponseType,
Store extends Record<string, any>,
>(
options: mobxFetchOptions<RequestType, ResponseType, Store>
): FetchFunction<RequestType, ResponseType> {
const {
store,
value,
values,
loading,
error,
fn,
pollingInterval,
resetValue,
transform,
onSuccess,
} = options;
let abortController: AbortController | undefined;
let pollingTimer: ReturnType<typeof setInterval> | undefined;
let currentRequest: RequestType | undefined;
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = undefined;
}
abortController?.abort();
};
const fetch = async (request?: RequestType): Promise<ResponseType | null> => {
abortController?.abort();
abortController = new AbortController();
currentRequest = request as RequestType;
runInAction(() => {
if (value) {
(store[value] as any) = resetValue ? null : store[value];
}
if (values) {
values.forEach((key) => {
(store[key] as any) = resetValue ? null : store[key];
});
}
if (error) {
(store[error] as any) = null;
}
if (loading) {
(store[loading] as any) = true;
}
});
try {
const result = await (
fn as (
request?: RequestType,
signal?: AbortSignal
) => Promise<ResponseType>
)(request, abortController.signal);
runInAction(() => {
if (values && transform) {
const transformed = transform(result) as Record<string, any>;
values.forEach((key) => {
const k = key as string;
if (k in transformed) {
(store[key] as any) = transformed[k];
}
});
} else if (value) {
(store[value] as any) = result as ResponseType;
}
if (loading) {
(store[loading] as any) = false;
}
if (error) {
(store[error] as any) = null;
}
});
if (pollingInterval && !pollingTimer) {
pollingTimer = setInterval(() => {
if (currentRequest !== undefined) {
fetch(currentRequest);
} else {
fetch();
}
}, pollingInterval);
}
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
if (!(err instanceof Error && err.name === "CanceledError")) {
runInAction(() => {
if (error) {
(store[error] as any) =
err instanceof Error ? err.message : String(err);
}
if (loading) {
(store[loading] as any) = false;
}
if (value) {
(store[value] as any) = null;
}
if (values) {
values.forEach((key) => {
(store[key] as any) = null;
});
}
});
throw err;
}
return null;
}
};
const fetchWithStopPolling = fetch as FetchFunction<
RequestType,
ResponseType
>;
if (pollingInterval) {
fetchWithStopPolling.stopPolling = stopPolling;
}
return fetchWithStopPolling;
}

View File

@@ -8,10 +8,13 @@ import {
Earth,
Landmark,
GitBranch,
// Car,
Table,
Split,
// Newspaper,
PersonStanding,
Cpu,
// BookImage,
} from "lucide-react";
import carrierIcon from "./carrier.svg";
@@ -23,63 +26,12 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
requiredRoles?: string[];
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
}
export const ROUTE_REQUIRED_RESOURCES: Record<string, string[]> = {
"/": [],
"/sight": ["sights"],
"/sight/create": ["sights"],
"/sight/:id/edit": ["sights"],
"/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"],
"/map": ["map"],
"/media": ["sights"],
"/media/:id": ["sights"],
"/media/:id/edit": ["sights"],
"/country": ["countries"],
"/country/create": ["countries"],
"/country/add": ["countries"],
"/country/:id/edit": ["countries"],
"/city": ["cities", "countries"],
"/city/create": ["cities", "countries"],
"/city/:id/edit": ["cities", "countries"],
"/route": ["routes", "carriers"],
"/route/create": ["routes", "carriers"],
"/route/:id/edit": ["routes", "carriers"],
"/user": ["users"],
"/user/create": ["users"],
"/user/:id/edit": ["users"],
"/snapshot": ["snapshot_rw"],
"/snapshot/create": ["snapshot_create", "devices_rw"],
"/carrier": ["carriers"],
"/carrier/create": ["carriers"],
"/carrier/:id/edit": ["carriers"],
"/station": ["stations"],
"/station/create": ["stations"],
"/station/:id": ["stations"],
"/station/:id/edit": ["stations"],
"/vehicle/create": ["devices"],
"/vehicle/:id/edit": ["devices"],
"/article": ["sights"],
"/article/:id": ["sights"],
};
export const NAVIGATION_ITEMS: {
primary: NavigationItem[];
secondary: NavigationItem[];
@@ -87,10 +39,10 @@ export const NAVIGATION_ITEMS: {
primary: [
{
id: "snapshots",
label: "Экспорт",
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
requiredRoles: ["snapshot_rw", "snapshot_create"],
for_admin: true,
},
{
id: "map",
@@ -103,40 +55,55 @@ export const NAVIGATION_ITEMS: {
label: "Устройства",
icon: Cpu,
path: "/devices",
requiredRoles: ["devices_ro", "devices_rw"],
for_admin: true,
},
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
requiredRoles: ["users_ro", "users_rw"],
for_admin: true,
},
{
id: "all",
label: "Справочник",
icon: Table,
nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{
id: "attractions",
label: "Достопримечательности",
icon: Landmark,
path: "/sight",
requiredRoles: ["sights_ro", "sights_rw"],
},
{
id: "stations",
label: "Остановки",
icon: PersonStanding,
path: "/station",
requiredRoles: ["stations_ro", "stations_rw"],
},
{
id: "routes",
label: "Маршруты",
icon: Split,
path: "/route",
requiredRoles: ["routes_ro", "routes_rw"],
},
{
@@ -144,22 +111,22 @@ export const NAVIGATION_ITEMS: {
label: "Страны",
icon: Earth,
path: "/country",
requiredRoles: ["countries_ro", "countries_rw"],
for_admin: true,
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
requiredRoles: ["cities_ro", "cities_rw"],
for_admin: true,
},
{
id: "carriers",
label: "Перевозчики",
// @ts-ignore
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
icon: () => <img src={carrierIcon} alt="Перевозчики"/>,
path: "/carrier",
requiredRoles: ["carriers_ro", "carriers_rw"],
for_admin: true,
},
],
},
@@ -177,31 +144,7 @@ export const NAVIGATION_ITEMS: {
],
};
function collectRoles(list: NavigationItem[]): string[] {
const roles = new Set<string>(["admin"]);
const walk = (items: NavigationItem[]) => {
for (const item of items) {
item.requiredRoles?.forEach((r) => roles.add(r));
item.nestedItems && walk(item.nestedItems);
}
};
walk(list);
return Array.from(roles);
}
export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary);
export const VEHICLE_TYPES = [
{ label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 },
{ label: "Трамвай", value: 1 },
{ label: "Электробус", value: 4 },
{ label: "Электричка", value: 5 },
{ label: "Вагон метро", value: 6 },
{ label: "Вагон ЖД", value: 7 },
{ label: "Троллейбус", value: 2 },
];
export const VEHICLE_MODELS = [
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
] as const;

View File

@@ -33,7 +33,6 @@ export const MEDIA_TYPE_VALUES = {
video: 2,
icon: 3,
thumbnail: 3,
alt_icon: 3,
watermark_lu: 4,
watermark_rd: 4,
panorama: 5,
@@ -43,4 +42,4 @@ export const MEDIA_TYPE_VALUES = {
export const RU_COUNTRIES = generateCountriesList("ru");
export const EN_COUNTRIES = generateCountriesList("en");
export const ZH_COUNTRIES = generateCountriesList("zh");
export const ZH_COUNTRIES = generateCountriesList("zh");

View File

@@ -1,3 +1,8 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null;
const initializeUseGLTF = async () => {
@@ -15,6 +20,9 @@ const initializeUseGLTF = async () => {
return useGLTF;
};
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => {
try {
const gltf = await initializeUseGLTF();
@@ -24,6 +32,9 @@ export const clearGLTFCacheForUrl = async (url: string) => {
} catch (error) {}
};
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => {
try {
const gltf = await initializeUseGLTF();
@@ -33,6 +44,9 @@ export const clearAllGLTFCache = async () => {
} catch (error) {}
};
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) {
try {
@@ -41,16 +55,25 @@ export const revokeBlobURL = (url: string) => {
}
};
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url);
};
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async (
previousMediaId: string | number | null,
newMediaType?: number
) => {
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache();
}

View File

@@ -1,19 +1,35 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
export * from "./permissions";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
*
* @param objectName - Название объекта (достопримечательности, города и т.д.)
* @param fileName - Название файла
* @param mediaType - Тип медиа (число) или название статьи
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
* @returns Строка в нужном формате
*/
export const generateDefaultMediaName = (
objectName: string,
fileName: string,
mediaType: number | string,
isArticle: boolean = false
): string => {
// Убираем расширение из названия файла
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") {
// Получаем название типа медиа
const mediaTypeLabels: Record<number, string> = {
1: "Фото",
2: "Видео",
@@ -26,20 +42,14 @@ export const generateDefaultMediaName = (
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
}
}
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
};
/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */
export const isMediaIdEmpty = (
id: string | null | undefined
): boolean => {
if (id == null || id === "") return true;
const digits = id.replace(/-/g, "");
return digits === "" || /^0+$/.test(digits);
};

View File

@@ -1,18 +0,0 @@
export const canRead = (roles: string[] | undefined, resource: string): boolean => {
if (!roles || roles.length === 0) return false;
return (
roles.includes("admin") ||
roles.includes(`${resource}_ro`) ||
roles.includes(`${resource}_rw`)
);
};
export const canWrite = (roles: string[] | undefined, resource: string): boolean => {
if (!roles || roles.length === 0) return false;
return roles.includes("admin") || roles.includes(`${resource}_rw`);
};
export const createPermissions = (roles: string[] | undefined) => ({
canRead: (resource: string) => canRead(roles, resource),
canWrite: (resource: string) => canWrite(roles, resource),
});

View File

@@ -523,6 +523,7 @@ export const ArticleSelectOrCreateDialog = observer(
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
);
// Preview-by-click logic with request serialization to avoid concurrent requests
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
const clickTimerRef = (typeof window !== "undefined"
@@ -550,7 +551,7 @@ export const ArticleSelectOrCreateDialog = observer(
if (queuedPreviewId && queuedPreviewId !== articleId) {
const nextId = queuedPreviewId;
setQueuedPreviewId(null);
// Run the next queued preview
runPreviewFetch(nextId);
} else {
setQueuedPreviewId(null);
@@ -559,6 +560,7 @@ export const ArticleSelectOrCreateDialog = observer(
};
const handleListItemClick = (articleId: number) => {
// Delay to allow double-click to cancel preview
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
clickTimerRef.current = setTimeout(() => {
if (tabValue === 0 && !selectedArticleId && !tempArticleId) {
@@ -568,6 +570,7 @@ export const ArticleSelectOrCreateDialog = observer(
};
const handleListItemDoubleClick = (articleId: number) => {
// Cancel pending single-click preview and proceed to select
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
(clickTimerRef as any).current = null;

View File

@@ -39,8 +39,6 @@ interface UploadMediaDialogProps {
afterUploadSight?: (id: string) => void;
hardcodeType?:
| "thumbnail"
| "icon"
| "alt_icon"
| "watermark_lu"
| "watermark_rd"
| "image"
@@ -53,12 +51,10 @@ interface UploadMediaDialogProps {
| "carrier"
| "country"
| "vehicle"
| "station"
| "route"
| "user";
| "station";
isArticle?: boolean;
articleName?: string;
initialFile?: File;
initialFile?: File; // <--- добавлено
}
export const UploadMediaDialog = observer(
@@ -72,7 +68,7 @@ export const UploadMediaDialog = observer(
isArticle,
articleName,
initialFile,
initialFile, // <--- добавлено
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -91,6 +87,7 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -109,6 +106,7 @@ export const UploadMediaDialog = observer(
}
}, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
@@ -118,13 +116,13 @@ export const UploadMediaDialog = observer(
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
};
}, []);
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => {
if (fileToUpload) {
setMediaFile(fileToUpload);
setMediaFilename(fileToUpload.name);
// Try to determine media type from file extension
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
@@ -136,18 +134,22 @@ export const UploadMediaDialog = observer(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
// Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
} else if (["mp4", "webm", "mov"].includes(extension)) {
// Для видео только тип Видео
setAvailableMediaTypes([2]);
setMediaType(2);
}
}
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
@@ -155,9 +157,10 @@ export const UploadMediaDialog = observer(
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1;
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
@@ -165,9 +168,10 @@ export const UploadMediaDialog = observer(
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1;
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
"",
fileToUpload.name,
@@ -181,11 +185,13 @@ export const UploadMediaDialog = observer(
}
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => {
if (mediaFilename && mediaType > 0) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
@@ -193,6 +199,7 @@ export const UploadMediaDialog = observer(
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
@@ -203,6 +210,7 @@ export const UploadMediaDialog = observer(
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
@@ -227,6 +235,7 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -236,10 +245,22 @@ export const UploadMediaDialog = observer(
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl;
setIsPreviewLoaded(false);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
}
}, [mediaFile]);
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
// event.preventDefault();
// onClose();
// }
// };
// window.addEventListener("keydown", handleKeyPress);
// return () => window.removeEventListener("keydown", handleKeyPress);
// }, [onClose]);
const handleSave = async () => {
if (!mediaFile) return;
@@ -264,10 +285,10 @@ export const UploadMediaDialog = observer(
}
}
setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000);
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
@@ -276,6 +297,7 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -288,7 +310,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null;
previousMediaUrlRef.current = null; // Очищаем ref
onClose();
};

View File

@@ -1,25 +0,0 @@
import { languageInstance } from "@shared";
import { User, UserCity } from "../UserStore";
export const getMeApi = async (): Promise<User> => {
const response = await languageInstance("ru").get("/auth/me");
return response.data as User;
};
export const getMeCitiesApi = async (): Promise<{
ru: UserCity[];
en: UserCity[];
zh: UserCity[];
}> => {
const [ru, en, zh] = await Promise.all([
languageInstance("ru").get("/auth/me"),
languageInstance("en").get("/auth/me"),
languageInstance("zh").get("/auth/me"),
]);
return {
ru: ((ru.data as User).cities ?? []),
en: ((en.data as User).cities ?? []),
zh: ((zh.data as User).cities ?? []),
};
};

View File

@@ -1,13 +1,15 @@
import { API_URL, decodeJWT, mobxFetch } from "@shared";
import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions";
import { API_URL, decodeJWT } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import axios, { AxiosError } from "axios";
import { User, UserCity } from "../UserStore";
import { getMeApi, getMeCitiesApi } from "./api";
type LoginResponse = {
token: string;
user: Pick<User, "id" | "name" | "email" | "is_admin" | "cities">;
user: {
id: number;
name: string;
email: string;
is_admin: boolean;
};
};
class AuthStore {
@@ -46,7 +48,7 @@ class AuthStore {
{
email,
password,
},
}
);
const data = response.data;
@@ -87,76 +89,6 @@ class AuthStore {
get user() {
return this.payload?.user;
}
get isAdmin(): boolean {
return (
this.me?.is_admin === true ||
(this.me?.roles ?? []).includes("admin")
);
}
me: User | null = null;
meLoading = false;
meError: string | null = null;
meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = {
ru: [],
en: [],
zh: [],
};
getMeAction = mobxFetch<void, User, AuthStore>({
store: this,
value: "me",
loading: "meLoading",
error: "meError",
fn: getMeApi,
onSuccess: () => {
this.fetchMeCities();
},
});
fetchMeCities = async () => {
const cities = await getMeCitiesApi();
runInAction(() => {
this.meCities = cities;
});
};
canWrite = (resource: string): boolean => {
const roles = this.me?.roles ?? [];
if (roles.includes("admin")) {
return true;
}
if (resource === "map") {
return roles.some((role) =>
["routes_rw", "stations_rw", "sights_rw"].includes(role),
);
}
return checkCanWrite(roles, resource);
};
hasRole = (role: string): boolean => {
const roles = this.me?.roles ?? [];
return roles.includes("admin") || roles.includes(role);
};
canRead = (resource: string): boolean => {
if (resource === "map") {
return this.canWrite("map");
}
return checkCanRead(this.me?.roles, resource);
};
canAccess = (permission: string): boolean => {
if (permission.includes("_")) {
return this.hasRole(permission);
}
return this.canRead(permission);
};
}
export const authStore = new AuthStore();

View File

@@ -1,6 +1,5 @@
import {
authInstance,
authStore,
cityStore,
languageStore,
languageInstance,
@@ -146,58 +145,19 @@ class CarrierStore {
};
};
private resolveCityName = (cityId: number, preferredLanguage: Language) => {
if (!cityId) {
return "";
}
const languages: Language[] = ["ru", "en", "zh"];
const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find(
(city) => city.id === cityId
)?.name;
if (fromCityStorePreferred) {
return fromCityStorePreferred;
}
for (const language of languages) {
const cityName = cityStore.cities[language].data.find(
(city) => city.id === cityId
)?.name;
if (cityName) {
return cityName;
}
}
const fromMePreferred = authStore.meCities[preferredLanguage].find(
(city) => city.city_id === cityId
)?.name;
if (fromMePreferred) {
return fromMePreferred;
}
for (const language of languages) {
const cityName = authStore.meCities[language].find(
(city) => city.city_id === cityId
)?.name;
if (cityName) {
return cityName;
}
}
return "";
};
createCarrier = async () => {
const { language } = languageStore;
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const payload = {
full_name: (this.createCarrierData[language].full_name || "").trim(),
short_name: (this.createCarrierData[language].short_name || "").trim(),
full_name: this.createCarrierData[language].full_name,
short_name: this.createCarrierData[language].short_name,
city: cityName,
city_id: this.createCarrierData.city_id,
slogan: (this.createCarrierData[language].slogan || "").trim(),
slogan: this.createCarrierData[language].slogan,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
@@ -211,20 +171,17 @@ class CarrierStore {
this.carriers[language].data.push(response.data);
});
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const cityNameForLang = this.resolveCityName(
this.createCarrierData.city_id,
lang as Language
);
const patchPayload = {
// @ts-ignore
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
city: cityNameForLang || cityName,
short_name: this.createCarrierData[lang as any].short_name as string,
city: cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
slogan: this.createCarrierData[lang as any].slogan as string,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
@@ -317,13 +274,15 @@ class CarrierStore {
};
editCarrier = async (id: number) => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.editCarrierData.city_id
)?.name || "";
for (const lang of ["ru", "en", "zh"] as const) {
const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
full_name: (this.editCarrierData[lang].full_name || "").trim(),
short_name: (this.editCarrierData[lang].short_name || "").trim(),
slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName,
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo

View File

@@ -171,7 +171,7 @@ class CityStore {
try {
// Create city in primary language
const cityPayload = {
name: name.trim(),
name,
country:
countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code
@@ -200,7 +200,7 @@ class CityStore {
)?.name || "";
const patchPayload = {
name: (secondaryName || "").trim(),
name: secondaryName || "",
country: countryName,
country_code: country_code || "",
...(arms ? { arms } : {}),
@@ -285,7 +285,7 @@ class CityStore {
);
await languageInstance(language as Language).patch(`/city/${code}`, {
name: (name || "").trim(),
name,
country: country?.name || "",
country_code: country_code,
arms,

View File

@@ -136,7 +136,7 @@ class CountryStore {
if (code && this.createCountryData[language].name) {
await languageInstance(language as Language).post("/country", {
code: code,
name: name.trim(),
name: name,
});
runInAction(() => {
@@ -156,7 +156,7 @@ class CountryStore {
await languageInstance(secondaryLanguage as Language).patch(
`/country/${code}`,
{
name: name.trim(),
name: name,
}
);
}
@@ -212,7 +212,7 @@ class CountryStore {
if (name) {
await languageInstance(language as Language).patch(`/country/${code}`, {
name: name.trim(),
name: name,
});
runInAction(() => {

View File

@@ -1,3 +1,4 @@
// @shared/stores/createSightStore.ts
import {
articlesStore,
Language,
@@ -26,21 +27,21 @@ type SightLanguageInfo = {
};
type SightCommonInfo = {
// id: number; // ID is 0 until created
city_id: number;
city: string;
latitude: number;
longitude: number;
is_default_icon: boolean;
thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null;
watermark_rd: string | null;
left_article: number;
left_article: number; // Can be 0 or a real ID, or placeholder like 10000000
preview_media: string | null;
video_preview: string | null;
};
// SightBaseInfo combines common info with language-specific info
// The 'id' for the sight itself will be assigned upon creation by the backend.
type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo;
};
@@ -50,10 +51,7 @@ const initialSightState: SightBaseInfo = {
city: "",
latitude: 0,
longitude: 0,
is_default_icon: false,
thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
@@ -80,7 +78,7 @@ const initialSightState: SightBaseInfo = {
};
class CreateSightStore {
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => {
@@ -95,7 +93,9 @@ class CreateSightStore {
makeAutoObservable(this);
}
// --- Right Article Management ---
createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = {
heading: "Новый заголовок (RU)",
body: "Новый текст (RU)",
@@ -125,7 +125,7 @@ class CreateSightStore {
},
},
});
const { id } = articleRes.data;
const { id } = articleRes.data; // New article's ID
runInAction(() => {
const newArticleEntry = { id, media: [] };
@@ -133,7 +133,7 @@ class CreateSightStore {
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
});
return id;
return id; // Return ID for potential immediate use
} catch (error) {
console.error("Error creating new right article:", error);
throw error;
@@ -169,7 +169,7 @@ class CreateSightStore {
});
});
return articleId;
return articleId; // Return the linked article ID
} catch (error) {
console.error("Error linking existing right article:", error);
throw error;
@@ -188,7 +188,9 @@ class CreateSightStore {
}
};
// "Unlink" in create mode means just removing from the list to be created with the sight
unlinkRightAritcle = (articleId: number) => {
// Changed from 'unlinkRightAritcle' spelling
runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId
@@ -200,12 +202,16 @@ class CreateSightStore {
(article) => article.id !== articleId
);
});
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
// Consider if an orphaned article should be deleted here or managed separately.
// For now, it just removes it from the list associated with *this specific sight creation process*.
};
deleteRightArticle = async (articleId: number) => {
try {
await authInstance.delete(`/article/${articleId}`);
await authInstance.delete(`/article/${articleId}`); // Delete from backend
runInAction(() => {
// Remove from local store for all languages
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId
);
@@ -222,11 +228,12 @@ class CreateSightStore {
}
};
// --- Right Article Media Management ---
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
try {
await authInstance.post(`/article/${articleId}/media`, {
media_id: media.id,
media_order: 1,
media_order: 1, // Or calculate based on existing media.length + 1
});
runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
@@ -235,7 +242,7 @@ class CreateSightStore {
);
if (article) {
if (!article.media) article.media = [];
article.media.unshift(media);
article.media.unshift(media); // Add to the beginning
}
});
});
@@ -266,6 +273,7 @@ class CreateSightStore {
}
};
// --- Left Article Management (largely unchanged from your provided store) ---
updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading;
this.sight[language].left.body = body;
@@ -315,7 +323,7 @@ class CreateSightStore {
deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId
@@ -336,6 +344,7 @@ class CreateSightStore {
const enName = (this.sight.en.name || "").trim();
const zhName = (this.sight.zh.name || "").trim();
// If all names are empty, skip defaulting and use empty headings
const hasAnyName = !!(ruName || enName || zhName);
const response = await languageInstance("ru").post("/article", {
@@ -354,7 +363,7 @@ class CreateSightStore {
});
runInAction(() => {
this.sight.left_article = newLeftArticleId;
this.sight.left_article = newLeftArticleId; // Store the actual ID
this.sight.ru.left = {
heading: hasAnyName ? ruName : "",
body: "",
@@ -393,8 +402,9 @@ class CreateSightStore {
return newLeftArticleId;
};
// Placeholder for a "new" unsaved left article
setNewLeftArticlePlaceholder = () => {
this.sight.left_article = 10000000;
this.sight.left_article = 10000000; // Special placeholder ID
this.sight.ru.left = {
heading: "Новая левая статья",
body: "Заполните контентом",
@@ -412,6 +422,7 @@ class CreateSightStore {
};
};
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId;
};
@@ -420,27 +431,32 @@ class CreateSightStore {
this.sight.preview_media = null;
};
// --- General Store Methods ---
clearCreateSight = () => {
this.needLeaveAgree = false;
this.sight = JSON.parse(JSON.stringify(initialSightState));
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
};
updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>,
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
language?: Language
) => {
this.needLeaveAgree = true;
if (language) {
this.sight[language] = { ...this.sight[language], ...content };
} else {
// Assuming content here is for SightCommonInfo
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
}
};
// --- Main Sight Creation Logic ---
createSight = async (primaryLanguage: Language) => {
let finalLeftArticleId = this.sight.left_article;
// 1. Handle Left Article (Create if new, or use existing ID)
if (this.sight.left_article === 10000000) {
// Placeholder for new
const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
@@ -458,6 +474,7 @@ class CreateSightStore {
this.sight.left_article !== 0 &&
this.sight.left_article !== null
) {
// Existing, ensure it's up-to-date
await languageInstance("ru").patch(
`/article/${this.sight.left_article}`,
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
@@ -471,7 +488,10 @@ class CreateSightStore {
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
);
}
// else: left_article is 0, so no left article
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
// We just need to update their content if changed before saving the sight.
for (const lang of ["ru", "en", "zh"] as Language[]) {
for (const article of this.sight[lang].right) {
if (article.id == 0 || article.id == null) {
@@ -481,23 +501,22 @@ class CreateSightStore {
heading: article.heading,
body: article.body,
});
// Media for these articles are already linked via createLinkWithRightArticle
}
}
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id
);
// 3. Create Sight object in DB
const sightPayload = {
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon,
name: (this.sight[primaryLanguage].name || "").trim(),
name: this.sight[primaryLanguage].name,
address: this.sight[primaryLanguage].address,
thumbnail: this.sight.thumbnail,
icon: this.sight.icon,
alt_icon: this.sight.alt_icon,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
@@ -509,8 +528,9 @@ class CreateSightStore {
"/sight",
sightPayload
);
const newSightId = response.data.id;
const newSightId = response.data.id; // ID of the newly created sight
// 4. Update other languages for the sight
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage
);
@@ -520,12 +540,9 @@ class CreateSightStore {
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon,
name: (this.sight[lang].name || "").trim(),
name: this.sight[lang].name,
address: this.sight[lang].address,
thumbnail: this.sight.thumbnail,
icon: this.sight.icon,
alt_icon: this.sight.alt_icon,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
@@ -534,17 +551,20 @@ class CreateSightStore {
});
}
// 5. Link Right Articles to the new Sight
for (let i = 0; i < rightArticleIdsForLink.length; i++) {
await authInstance.post(`/sight/${newSightId}/article`, {
article_id: rightArticleIdsForLink[i],
page_num: i + 1,
page_num: i + 1, // Or other logic for page_num
});
}
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false;
return newSightId;
};
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async (
filename: string,
type: number,
@@ -563,12 +583,12 @@ class CreateSightStore {
this.fileToUpload = null;
this.uploadMediaOpen = false;
});
mediaStore.getMedia();
mediaStore.getMedia(); // Refresh global media list
return {
id: response.data.id,
filename: filename,
media_name: media_name,
media_type: type,
filename: filename, // Or response.data.filename if backend returns it
media_name: media_name, // Or response.data.media_name
media_type: type, // Or response.data.type
};
} catch (error) {
console.error("Error uploading media:", error);
@@ -576,12 +596,15 @@ class CreateSightStore {
}
};
// For Left Article Media
createLinkWithLeftArticle = async (media: MediaItem) => {
if (!this.sight.left_article || this.sight.left_article === 10000000) {
console.warn(
"Left article not selected or is a placeholder. Cannot link media yet."
);
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
// For simplicity, we'll assume the article must exist.
// A more robust solution might involve creating the article first if it's a placeholder.
return;
}
try {
@@ -640,7 +663,7 @@ class CreateSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
this.needLeaveAgree = true;
};

View File

@@ -1,3 +1,4 @@
// @shared/stores/editSightStore.ts
import {
articlesStore,
authInstance,
@@ -30,16 +31,12 @@ export type SightCommonInfo = {
city: string;
latitude: number;
longitude: number;
is_default_icon: boolean;
thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null;
watermark_rd: string | null;
left_article: number;
preview_media: string | null;
video_preview: string | null;
preview_font_size?: number;
};
export type SightBaseInfo = {
@@ -55,10 +52,7 @@ class EditSightStore {
city: "",
latitude: 0,
longitude: 0,
is_default_icon: false,
thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
@@ -93,35 +87,30 @@ class EditSightStore {
}
hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => {
this.isLoading = true;
try {
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article);
}
if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article);
}
runInAction(() => {
this.sight[language] = {
...this.sight[language],
runInAction(() => {
// Обновляем языковую часть
this.sight[language] = {
...this.sight[language],
...data,
};
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
...data,
};
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
...data,
};
this.hasLoadedCommon = true;
}
});
} finally {
this.isLoading = false;
}
this.hasLoadedCommon = true;
}
});
};
updateLeftInfo = (language: Language, heading: string, body: string) => {
@@ -134,6 +123,7 @@ class EditSightStore {
let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Create a map of article IDs to their media
const mediaMap = new Map();
for (const article of responseRu.data) {
const responseMedia = await authInstance.get(
@@ -142,6 +132,7 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data);
}
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({
...article,
@@ -182,8 +173,6 @@ class EditSightStore {
clearSightInfo = () => {
this.needLeaveAgree = false;
this.hasLoadedCommon = false;
this.isLoading = false;
this.sight = {
common: {
id: 0,
@@ -191,10 +180,7 @@ class EditSightStore {
city: "",
latitude: 0,
longitude: 0,
is_default_icon: false,
thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
@@ -300,9 +286,9 @@ class EditSightStore {
...this.sight.common,
translations: {
name: {
ru: (this.sight.ru.name || "").trim(),
en: (this.sight.en.name || "").trim(),
zh: (this.sight.zh.name || "").trim(),
ru: this.sight.ru.name,
en: this.sight.en.name,
zh: this.sight.zh.name,
},
address: {
ru: this.sight.ru.address,
@@ -341,6 +327,28 @@ class EditSightStore {
articles: articleIdsInObject,
});
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false;
};
@@ -498,19 +506,18 @@ class EditSightStore {
formData.append("media_name", media_name);
}
formData.append("type", type.toString());
const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null;
this.uploadMediaOpen = false;
mediaStore.getMedia();
return {
id: response.data.id,
filename: filename,
media_name: media_name,
media_type: type,
};
try {
const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null;
this.uploadMediaOpen = false;
mediaStore.getMedia();
return {
id: response.data.id,
filename: filename,
media_name: media_name,
media_type: type,
};
} catch (error) {}
};
createLinkWithArticle = async (media: {
@@ -582,7 +589,7 @@ class EditSightStore {
});
});
return article_id;
return article_id; // Return the linked article ID
};
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -688,7 +695,7 @@ class EditSightStore {
});
});
return id;
return id; // Return the ID of the newly created article
};
createLinkWithRightArticle = async (
@@ -763,7 +770,7 @@ class EditSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
this.needLeaveAgree = true;
};

View File

@@ -6,24 +6,10 @@ class LanguageStore {
constructor() {
makeAutoObservable(this);
if (typeof window !== "undefined") {
const storedLanguage = window.localStorage.getItem("appLanguage");
if (
storedLanguage &&
["ru", "en", "zh"].includes(storedLanguage.toLowerCase())
) {
this.language = storedLanguage.toLowerCase() as Language;
}
}
}
setLanguage = (language: Language) => {
this.language = language;
if (typeof window !== "undefined") {
window.localStorage.setItem("appLanguage", language);
}
};
}

View File

@@ -39,11 +39,12 @@ class MediaStore {
updateMedia = async (id: string, data: Partial<Media>) => {
const response = await authInstance.patch(`/media/${id}`, data);
runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data };
}
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data };
}
@@ -63,11 +64,12 @@ class MediaStore {
});
runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data };
}
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data };
}

View File

@@ -15,6 +15,7 @@ class ModelLoadingStore {
makeAutoObservable(this);
}
// Начать отслеживание загрузки модели
startLoading(modelId: string) {
this.loadingStates.set(modelId, {
isLoading: true,
@@ -24,6 +25,7 @@ class ModelLoadingStore {
});
}
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId);
if (state) {
@@ -31,6 +33,7 @@ class ModelLoadingStore {
}
}
// Завершить загрузку модели
finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId);
if (state) {
@@ -39,10 +42,12 @@ class ModelLoadingStore {
}
}
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) {
this.loadingStates.delete(modelId);
}
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId);
if (state) {
@@ -51,22 +56,26 @@ class ModelLoadingStore {
}
}
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId);
}
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading
);
}
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading
);
}
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number {
const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100;
@@ -78,10 +87,12 @@ class ModelLoadingStore {
return Math.round(totalProgress / loadingModels.length);
}
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean {
return this.isAnyModelLoading;
}
// Очистить все состояния загрузки
clearAll() {
this.loadingStates.clear();
}

View File

@@ -1,10 +1,5 @@
import { makeAutoObservable, runInAction } from "mobx";
import {
authInstance,
languageInstance,
languageStore,
isMediaIdEmpty,
} from "@shared";
import { authInstance } from "@shared";
export type Route = {
route_name: string;
@@ -14,7 +9,6 @@ export type Route = {
center_longitude: number;
governor_appeal: number;
id: number;
icon: string;
path: number[][];
rotate: number;
route_direction: boolean;
@@ -23,7 +17,6 @@ export type Route = {
scale_max: number;
scale_min: number;
video_preview: string;
video_timer: number;
};
class RouteStore {
@@ -96,41 +89,11 @@ class RouteStore {
};
saveRouteStations = async (routeId: number, stationId: number) => {
const { language } = languageStore;
const stationResponse = await languageInstance(language).get(
`/station/${stationId}`
);
const fullStationData = stationResponse.data;
// Получаем отредактированные данные из локального кеша
const editedStationData = this.routeStations[routeId]?.find(
(station) => station.id === stationId
);
const dataToSend: any = {
await authInstance.patch(`/route/${routeId}/station`, {
...this.routeStations[routeId]?.find(
(station) => station.id === stationId
),
station_id: stationId,
offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0,
offset_y: editedStationData?.offset_y ?? fullStationData.offset_y ?? 0,
align: editedStationData?.align ?? fullStationData.align ?? 0,
transfers: fullStationData.transfers || {},
};
await authInstance.patch(`/route/${routeId}/station`, dataToSend);
// Обновляем локальный кеш после успешного сохранения
runInAction(() => {
if (this.routeStations[routeId]) {
this.routeStations[routeId] = this.routeStations[routeId].map(
(station) =>
station.id === stationId
? {
...station,
...dataToSend,
}
: station
);
}
});
};
@@ -142,7 +105,6 @@ class RouteStore {
center_longitude: "",
governor_appeal: 0,
id: 0,
icon: "",
path: [] as number[][],
rotate: 0,
route_direction: false,
@@ -151,7 +113,6 @@ class RouteStore {
scale_max: 0,
scale_min: 0,
video_preview: "" as string | undefined,
video_timer: 60,
};
setEditRouteData = (data: any) => {
@@ -159,30 +120,14 @@ class RouteStore {
};
editRoute = async (id: number) => {
if (
!this.editRouteData.video_preview ||
isMediaIdEmpty(this.editRouteData.video_preview)
) {
if (!this.editRouteData.video_preview) {
delete this.editRouteData.video_preview;
}
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
delete (this.editRouteData as any).icon;
}
const dataToSend: any = {
const response = await authInstance.patch(`/route/${id}`, {
...this.editRouteData,
route_name: (this.editRouteData.route_name || "").trim(),
route_number: (this.editRouteData.route_number || "").trim(),
route_sys_number: (this.editRouteData.route_sys_number || "").trim(),
center_latitude: parseFloat(this.editRouteData.center_latitude),
center_longitude: parseFloat(this.editRouteData.center_longitude),
};
if (
this.editRouteData.governor_appeal === 0 ||
!this.editRouteData.governor_appeal
) {
dataToSend.governor_appeal = null;
}
const response = await authInstance.patch(`/route/${id}`, dataToSend);
});
runInAction(() => {
this.route[id] = response.data;

View File

@@ -23,10 +23,7 @@ export type Sight = {
address: string;
latitude: number;
longitude: number;
is_default_icon: boolean;
thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null;
watermark_rd: string | null;
left_article: number;
@@ -61,6 +58,41 @@ class SightsStore {
});
};
// getSight = async (id: number) => {
// const response = await authInstance.get(`/sight/${id}`);
// runInAction(() => {
// this.sight = response.data;
// editSightStore.sightInfo = {
// ...editSightStore.sightInfo,
// id: response.data.id,
// city_id: response.data.city_id,
// city: response.data.city,
// latitude: response.data.latitude,
// longitude: response.data.longitude,
// thumbnail: response.data.thumbnail,
// watermark_lu: response.data.watermark_lu,
// watermark_rd: response.data.watermark_rd,
// left_article: response.data.left_article,
// preview_media: response.data.preview_media,
// video_preview: response.data.video_preview,
// [languageStore.language]: {
// info: {
// name: response.data.name,
// address: response.data.address,
// },
// left: {
// heading: articlesStore.articles[languageStore.language].find(
// (article) => article.id === response.data.left_article
// )?.heading,
// body: articlesStore.articles[languageStore.language].find(
// },
// },
// };
// });
// };
createSightAction = async (
city: number,
coordinates: { latitude: number; longitude: number }
@@ -177,10 +209,7 @@ class SightsStore {
city_id: this.sight?.city_id,
latitude: this.sight?.latitude,
longitude: this.sight?.longitude,
is_default_icon: this.sight?.is_default_icon,
thumbnail: this.sight?.thumbnail,
icon: this.sight?.icon,
alt_icon: this.sight?.alt_icon,
watermark_lu: this.sight?.watermark_lu,
watermark_rd: this.sight?.watermark_rd,
left_article: this.sight?.left_article,

View File

@@ -1,6 +1,8 @@
import { authInstance } from "@shared";
import { v4 as uuidv4 } from "uuid";
import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import {
articlesStore,
cityStore,
@@ -23,33 +25,19 @@ type Snapshot = {
Name: string;
ParentID: string;
CreationTime: string;
occupied_disk_space_gb: number;
};
type SnapshotStatus = {
ID: string;
Status: string;
Progress: number;
Error: string;
};
type StorageInfo = {
available_disk_space_gb: number;
total_disk_space_gb: number;
};
class SnapshotStore {
snapshots: Snapshot[] = [];
snapshot: Snapshot | null = null;
lastRequestId: string | null = null;
snapshotStatus: SnapshotStatus | null = null;
storageInfo: StorageInfo | null = null;
constructor() {
makeAutoObservable(this);
}
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
@@ -59,6 +47,7 @@ class SnapshotStore {
articlesStore.articleData = null;
articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
@@ -67,18 +56,21 @@ class SnapshotStore {
cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {};
// Сброс кешей стран
countryStore.countries = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей перевозчиков
carrierStore.carriers = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей станций
stationsStore.stationLists = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
@@ -86,28 +78,31 @@ class SnapshotStore {
};
stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = [];
sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = [];
mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse(
JSON.stringify({
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
is_default_icon: false,
thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
@@ -144,10 +139,7 @@ class SnapshotStore {
city: "",
latitude: 0,
longitude: 0,
is_default_icon: false,
thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
@@ -181,21 +173,26 @@ class SnapshotStore {
editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = [];
devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null;
authStore.error = null;
authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = [];
(window as any).mapStore.stations = [];
(window as any).mapStore.sights = [];
}
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches();
}
@@ -203,6 +200,7 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error);
}
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -210,12 +208,14 @@ class SnapshotStore {
localStorage.clear();
sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token);
if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) {
@@ -225,6 +225,7 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey);
}
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) {
try {
caches.keys().then((cacheNames) => {
@@ -239,6 +240,7 @@ class SnapshotStore {
}
}
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) {
try {
indexedDB.databases().then((databases) => {
@@ -266,17 +268,10 @@ class SnapshotStore {
};
deleteSnapshot = async (id: string) => {
const snapshot = this.snapshots.find((s) => s.ID === id);
await authInstance.delete(`/snapshots/${id}`);
runInAction(() => {
this.snapshots = this.snapshots.filter((s) => s.ID !== id);
if (this.storageInfo && snapshot?.occupied_disk_space_gb) {
this.storageInfo = {
...this.storageInfo,
available_disk_space_gb: this.storageInfo.available_disk_space_gb + snapshot.occupied_disk_space_gb,
};
}
this.snapshots = this.snapshots.filter((snapshot) => snapshot.ID !== id);
});
};
@@ -289,37 +284,15 @@ class SnapshotStore {
};
restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`);
};
createSnapshot = async (name: string) => {
this.lastRequestId = uuidv4();
const response = await authInstance.post(
`/snapshots`,
{ name: name.trim() },
{ headers: { "X-Request-ID": this.lastRequestId } }
);
return response.data.ID;
};
getSnapshotStatus = async (id: string) => {
const response = await authInstance.get(`/snapshots/status/${id}`);
runInAction(() => {
this.snapshotStatus = response.data;
});
};
getStorageInfo = async () => {
const response = await authInstance.get(`/snapshots/storage`);
runInAction(() => {
this.storageInfo = response.data;
});
await authInstance.post(`/snapshots`, { name });
};
}

View File

@@ -1,6 +1,5 @@
import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { routeStore } from "../RouteStore";
type Language = "ru" | "en" | "zh";
@@ -8,11 +7,12 @@ type StationLanguageData = {
name: string;
system_name: string;
address: string;
loaded: boolean;
loaded: boolean; // Indicates if this language's data has been loaded/modified
};
type StationCommonData = {
city_id: number;
direction: boolean;
description: string;
icon: string;
latitude: number;
@@ -43,6 +43,7 @@ type Station = {
city: string;
city_id: number;
description: string;
direction: boolean;
icon: string;
latitude: number;
longitude: number;
@@ -91,6 +92,7 @@ class StationsStore {
},
};
// This will store the full station data, keyed by ID and then by language
stationPreview: Record<
string,
Record<string, { loaded: boolean; data: Station }>
@@ -121,6 +123,7 @@ class StationsStore {
common: {
city: "",
city_id: 0,
direction: false,
description: "",
icon: "",
latitude: 0,
@@ -166,6 +169,7 @@ class StationsStore {
common: {
city: "",
city_id: 0,
direction: false,
description: "",
icon: "",
latitude: 0,
@@ -248,6 +252,7 @@ class StationsStore {
common: {
city: ruResponse.data.city,
city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction,
description: ruResponse.data.description,
icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude,
@@ -259,6 +264,7 @@ class StationsStore {
};
};
// Sets language-specific station data
setLanguageEditStationData = (
language: Language,
data: Partial<StationLanguageData>
@@ -272,6 +278,7 @@ class StationsStore {
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,
@@ -287,15 +294,16 @@ class StationsStore {
const response = await languageInstance(language).patch(
`/station/${id}`,
{
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
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] = {
loaded: true,
@@ -335,11 +343,11 @@ class StationsStore {
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(
@@ -399,6 +407,7 @@ class StationsStore {
const { language } = languageStore;
let commonDataPayload: Partial<StationCommonData> = {
city_id: this.createStationData.common.city_id,
direction: this.createStationData.common.direction,
icon: this.createStationData.common.icon,
latitude: this.createStationData.common.latitude,
longitude: this.createStationData.common.longitude,
@@ -412,13 +421,14 @@ class StationsStore {
delete commonDataPayload.icon;
}
// First create station in Russian
const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", {
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
name: name || "",
system_name: name || "", // system_name is often derived from name
description: description || "",
address: address || "",
...commonDataPayload,
});
@@ -428,6 +438,7 @@ class StationsStore {
const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language
) as Language[]) {
@@ -436,10 +447,10 @@ class StationsStore {
const response = await languageInstance(lang).patch(
`/station/${stationId}`,
{
name: (name || "").trim(),
system_name: (name || "").trim(),
description: (description || "").trim(),
address: (address || "").trim(),
name: name || "",
system_name: name || "", // system_name is often derived from name
description: description || "",
address: address || "",
...commonDataPayload,
}
);
@@ -472,6 +483,7 @@ class StationsStore {
common: {
city: "",
city_id: 0,
direction: false,
icon: "",
latitude: 0,
description: "",
@@ -495,6 +507,7 @@ class StationsStore {
return response.data;
};
// Reset editStationData when navigating away or after saving
resetEditStationData = () => {
this.editStationData = {
ru: {
@@ -518,6 +531,7 @@ class StationsStore {
common: {
city: "",
city_id: 0,
direction: false,
description: "",
icon: "",
latitude: 0,
@@ -538,97 +552,6 @@ class StationsStore {
},
};
};
updateStationTransfers = async (
id: 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;
}
) => {
const { language } = languageStore;
const response = await languageInstance(language).get(`/station/${id}`);
const stationData = response.data as Station;
if (!stationData) {
throw new Error("Station not found");
}
// Формируем commonDataPayload как в editStation, с обновленными transfers
const commonDataPayload = {
city_id: stationData.city_id,
latitude: stationData.latitude,
longitude: stationData.longitude,
offset_x: stationData.offset_x,
offset_y: stationData.offset_y,
transfers: transfers,
city: stationData.city || "",
};
// Отправляем один PATCH запрос, так как пересадки общие для всех языков
const patchResponse = await languageInstance(language).patch(
`/station/${id}`,
{
name: stationData.name || "",
system_name: stationData.system_name || "",
description: stationData.description || "",
address: stationData.address || "",
...commonDataPayload,
}
);
// Обновляем данные для всех языков в локальном состоянии
runInAction(() => {
const updatedTransfers = patchResponse.data.transfers;
for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationPreview[id]) {
this.stationPreview[id][lang] = {
...this.stationPreview[id][lang],
data: {
...this.stationPreview[id][lang].data,
transfers: updatedTransfers,
},
};
}
if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.map(
(station: Station) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
// Обновляем пересадки в RouteStore.routeStations для всех маршрутов
if (routeStore?.routeStations) {
for (const routeId in routeStore.routeStations) {
routeStore.routeStations[routeId] = routeStore.routeStations[
routeId
].map((station: any) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
});
};
}
export const stationsStore = new StationsStore();

View File

@@ -1,18 +0,0 @@
import { authInstance } from "@shared";
export type TestingModeResponse = {
enabled: boolean;
updated_at: string;
};
export const getTestingModeApi = async (): Promise<TestingModeResponse> => {
const response = await authInstance.get("/testing-mode");
return response.data as TestingModeResponse;
};
export const setTestingModeApi = async (request: {
enabled: boolean;
}): Promise<TestingModeResponse> => {
const response = await authInstance.post("/testing-mode", request);
return response.data as TestingModeResponse;
};

View File

@@ -1,62 +0,0 @@
import { makeAutoObservable } from "mobx";
import { mobxFetch } from "@shared";
import {
TestingModeResponse,
getTestingModeApi,
setTestingModeApi,
} from "./api";
const POLLING_INTERVAL = 10_000;
class TestingModeStore {
testingMode: TestingModeResponse | null = null;
testingModeLoading = false;
testingModeError: string | null = null;
setTestingModeResult: TestingModeResponse | null = null;
setTestingModeLoading = false;
setTestingModeError: string | null = null;
constructor() {
makeAutoObservable(this);
}
get isEnabled(): boolean {
return this.testingMode?.enabled ?? false;
}
fetchTestingModeAction = mobxFetch<TestingModeResponse, TestingModeStore>({
store: this,
value: "testingMode",
loading: "testingModeLoading",
error: "testingModeError",
fn: getTestingModeApi,
pollingInterval: POLLING_INTERVAL,
});
setTestingModeAction = mobxFetch<
{ enabled: boolean },
TestingModeResponse,
TestingModeStore
>({
store: this,
value: "setTestingModeResult",
loading: "setTestingModeLoading",
error: "setTestingModeError",
fn: setTestingModeApi,
onSuccess: (result) => {
this.testingMode = result;
},
});
startPolling() {
this.fetchTestingModeAction();
}
stopPolling() {
this.fetchTestingModeAction.stopPolling?.();
}
}
export const testingModeStore = new TestingModeStore();
export { TestingModeStore };

View File

@@ -1,14 +0,0 @@
import { authInstance } from "@shared";
import { User } from "./index";
export const addUserCityApi = async (
{ id, city_ids }: { id: number; city_ids: number[] },
signal?: AbortSignal,
): Promise<User> => {
const response = await authInstance.patch(
`/user/${id}/city`,
{ city_ids },
{ signal },
);
return response.data as User;
};

View File

@@ -1,11 +1,5 @@
import { authInstance, mobxFetch } from "@shared";
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { addUserCityApi } from "./api";
export type UserCity = {
city_id: number;
name: string;
};
export type User = {
id: number;
@@ -13,9 +7,6 @@ export type User = {
is_admin: boolean;
name: string;
password?: string;
icon?: string;
roles?: string[];
cities?: UserCity[];
};
class UserStore {
@@ -66,25 +57,15 @@ class UserStore {
email: "",
password: "",
is_admin: false,
icon: "",
roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
};
setCreateUserData = (
name: string,
email: string,
password: string,
is_admin: boolean,
icon?: string,
is_admin: boolean
) => {
this.createUserData = {
...this.createUserData,
name,
email,
password,
is_admin,
icon: icon ?? "",
};
this.createUserData = { name, email, password, is_admin };
};
createUser = async () => {
@@ -92,19 +73,7 @@ class UserStore {
if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1;
}
const payload: Partial<User> = {
...this.createUserData,
name: (this.createUserData.name || "").trim(),
email: (this.createUserData.email || "").trim(),
};
const baseRoles = new Set<string>(payload.roles ?? []);
baseRoles.add("articles_ro");
baseRoles.add("articles_rw");
baseRoles.add("media_ro");
baseRoles.add("media_rw");
payload.roles = Array.from(baseRoles);
if (!payload.icon) delete payload.icon;
const response = await authInstance.post("/user", payload);
const response = await authInstance.post("/user", this.createUserData);
runInAction(() => {
this.users.data.push({
@@ -119,75 +88,27 @@ class UserStore {
email: "",
password: "",
is_admin: false,
icon: "",
roles: [],
};
setEditUserData = (
name: string,
email: string,
password: string,
is_admin: boolean,
icon?: string,
is_admin: boolean
) => {
this.editUserData = {
...this.editUserData,
name,
email,
password,
is_admin,
icon: icon ?? "",
};
};
setEditUserRoles = (roles: string[]) => {
this.editUserData = { ...this.editUserData, roles };
this.editUserData = { name, email, password, is_admin };
};
editUser = async (id: number) => {
const payload = {
...this.editUserData,
name: (this.editUserData.name || "").trim(),
email: (this.editUserData.email || "").trim(),
};
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;
const response = await authInstance.patch(`/user/${id}`, payload);
const response = await authInstance.patch(`/user/${id}`, this.editUserData);
runInAction(() => {
this.users.data = this.users.data.map((user) =>
user.id === id ? { ...user, ...response.data } : user,
user.id === id ? { ...user, ...response.data } : user
);
this.user[id] = { ...this.user[id], ...response.data };
});
};
addUserCityResult: User | null = null;
addUserCityLoading = false;
addUserCityError: string | null = null;
addUserCityAction = mobxFetch<
{ id: number; city_ids: number[] },
User,
UserStore
>({
store: this,
value: "addUserCityResult",
loading: "addUserCityLoading",
error: "addUserCityError",
fn: addUserCityApi,
onSuccess: (result) => {
runInAction(() => {
this.users.data = this.users.data.map((user) =>
user.id === result.id ? { ...user, ...result } : user,
);
if (this.user[result.id]) {
this.user[result.id] = { ...this.user[result.id], ...result };
}
});
},
});
}
export const userStore = new UserStore();

View File

@@ -1,13 +0,0 @@
import { languageInstance } from "@shared";
import { VehicleMaintenanceSession } from "./types";
export const getVehicleSessionsApi = async (
id: number,
signal?: AbortSignal,
): Promise<VehicleMaintenanceSession[]> => {
const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, {
signal,
});
return Array.isArray(response.data) ? response.data : [];
};

View File

@@ -1,9 +1,24 @@
import { authInstance, languageInstance, mobxFetch } from "@shared";
import { languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession } from "./types";
export type { Vehicle, VehicleMaintenanceSession } from "./types";
export type Vehicle = {
vehicle: {
id: number;
tail_number: number;
type: number;
carrier_id: number;
carrier: string;
uuid?: string;
};
device_status?: {
device_uuid: string;
online: boolean;
gps_ok: boolean;
media_service_ok: boolean;
last_update: string;
is_connected: boolean;
};
};
class VehicleStore {
vehicles: {
@@ -14,83 +29,16 @@ class VehicleStore {
loaded: false,
};
vehicle: Record<string, Vehicle> = {};
vehicleSessions: VehicleMaintenanceSession[] | null = null;
vehicleSessionsLoading = false;
vehicleSessionsError: string | null = null;
constructor() {
makeAutoObservable(this);
}
private normalizeVehicleItem = (item: any): Vehicle => {
if (item && typeof item === "object" && "vehicle" in item) {
return {
vehicle: item.vehicle ?? {},
device_status: item.device_status,
} as Vehicle;
}
return {
vehicle: item ?? {},
} as Vehicle;
};
private mergeVehicleInCaches = (updatedVehicle: any) => {
if (!updatedVehicle) return;
const updatedId = updatedVehicle.id;
const updatedUuid = updatedVehicle.uuid;
const mergeItem = (item: Vehicle): Vehicle => ({
...item,
vehicle: {
...item.vehicle,
...updatedVehicle,
},
});
this.vehicles.data = this.vehicles.data.map((item) => {
const sameId = updatedId != null && item.vehicle.id === updatedId;
const sameUuid =
updatedUuid != null &&
item.vehicle.uuid != null &&
item.vehicle.uuid === updatedUuid;
if (!sameId && !sameUuid) return item;
return mergeItem(item);
});
if (updatedId != null) {
const existing = this.vehicle[updatedId];
this.vehicle[updatedId] = existing
? mergeItem(existing)
: ({ vehicle: updatedVehicle } as Vehicle);
return;
}
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
([, item]) => item.vehicle.uuid === updatedUuid,
);
if (entry) {
const [key, item] = entry;
this.vehicle[key] = mergeItem(item);
}
}
};
getVehicles = async () => {
const response = await languageInstance("ru").get(`/vehicle`);
const vehiclesList = Array.isArray(response.data)
? response.data
: Array.isArray(response.data?.vehicles)
? response.data.vehicles
: [];
runInAction(() => {
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
this.vehicles.data = response.data;
this.vehicles.loaded = true;
});
};
@@ -100,69 +48,63 @@ class VehicleStore {
runInAction(() => {
this.vehicles.data = this.vehicles.data.filter(
(vehicle) => vehicle.vehicle.id !== id,
(vehicle) => vehicle.vehicle.id !== id
);
});
};
getVehicle = async (id: number) => {
const response = await languageInstance("ru").get(`/vehicle/${id}`);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.vehicle[id] = normalizedVehicle;
this.vehicle[id] = response.data;
});
};
createVehicle = async (
tailNumber: string,
tailNumber: number,
type: number,
carrier: string,
carrierId: number,
model?: string,
carrierId: number
) => {
const payload: Record<string, unknown> = {
tail_number: tailNumber.trim(),
const response = await languageInstance("ru").post("/vehicle", {
tail_number: tailNumber,
type,
carrier: carrier.trim(),
carrier,
carrier_id: carrierId,
};
// TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model;
const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
});
runInAction(() => {
this.vehicles.data.push(normalizedVehicle);
if (normalizedVehicle.vehicle?.id != null) {
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
}
this.vehicles.data.push({
vehicle: {
id: response.data.id,
tail_number: response.data.tail_number,
type: response.data.type,
carrier_id: response.data.carrier_id,
carrier: response.data.carrier,
uuid: response.data.uuid,
},
});
});
};
editVehicleData: {
tail_number: string;
tail_number: number;
type: number;
carrier: string;
carrier_id: number;
model: string;
snapshot_update_blocked: boolean;
} = {
tail_number: "",
tail_number: 0,
type: 0,
carrier: "",
carrier_id: 0,
model: "",
snapshot_update_blocked: false,
};
setEditVehicleData = (data: {
tail_number: string;
tail_number: number;
type: number;
carrier: string;
carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
}) => {
this.editVehicleData = {
...this.editVehicleData,
@@ -173,91 +115,36 @@ class VehicleStore {
editVehicle = async (
id: number,
data: {
tail_number: string;
tail_number: number;
type: number;
carrier: string;
carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
},
}
) => {
const payload: Record<string, unknown> = {
tail_number: data.tail_number.trim(),
const response = await languageInstance("ru").patch(`/vehicle/${id}`, {
tail_number: data.tail_number,
type: data.type,
carrier: data.carrier.trim(),
carrier: data.carrier,
carrier_id: data.carrier_id,
};
if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
payload,
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
...normalizedVehicle.vehicle,
model: normalizedVehicle.vehicle.model ?? data.model,
snapshot_update_blocked:
normalizedVehicle.vehicle.snapshot_update_blocked ??
data.snapshot_update_blocked,
};
});
runInAction(() => {
this.mergeVehicleInCaches({
...updatedVehiclePayload,
id,
});
this.vehicle[id] = {
vehicle: {
...this.vehicle[id].vehicle,
...response.data,
},
};
this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id
? {
...vehicle,
...response.data,
}
: vehicle
);
});
};
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(
`/devices/${uuid}/maintenance-mode`,
{
enabled,
},
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
maintenance_mode_on:
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
});
});
};
setDemoMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
enabled,
});
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
demo_mode_enabled:
normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
});
});
};
getVehicleSessions = mobxFetch<
number,
VehicleMaintenanceSession[],
VehicleStore
>({
store: this,
value: "vehicleSessions",
loading: "vehicleSessionsLoading",
error: "vehicleSessionsError",
resetValue: true,
fn: getVehicleSessionsApi,
});
}
export const vehicleStore = new VehicleStore();

View File

@@ -1,33 +0,0 @@
export type Vehicle = {
vehicle: {
id: number;
tail_number: string;
type: number;
carrier_id: number;
carrier: string;
uuid?: string;
model?: string;
current_snapshot_uuid?: string;
snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean;
city_id?: number;
};
device_status?: {
device_uuid: string;
online: boolean;
gps_ok: boolean;
media_service_ok: boolean;
last_update: string;
is_connected: boolean;
current_route_id?: number;
};
};
export type VehicleMaintenanceSession = {
duration_seconds: number;
ended_at: string;
id: number;
started_at: string;
vehicle_id: number;
};

View File

@@ -16,4 +16,3 @@ export * from "./CarrierStore";
export * from "./StationsStore";
export * from "./MenuStore";
export * from "./SelectedCityStore";
export * from "./TestingModeStore";

View File

@@ -1,171 +0,0 @@
import { forwardRef } from "react";
import { Button, ButtonProps, CircularProgress } from "@mui/material";
import { alpha, keyframes, styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
type AnimatedCircleButtonProps = ButtonProps & {
disableAnimation?: boolean;
loading?: boolean;
};
type StyledButtonProps = AnimatedCircleButtonProps & { theme: Theme };
const loadingPulse = keyframes`
0% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0.35;
}
50% {
transform: translate(-50%, -50%) scale(1.45);
opacity: 0.15;
}
100% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0;
}
`;
const StyledButton = styled(Button, {
shouldForwardProp: (prop) =>
prop !== "disableAnimation" && prop !== "loading",
})<AnimatedCircleButtonProps>((props: StyledButtonProps) => {
const {
theme,
disableAnimation = false,
color,
variant = "text",
disabled = false,
loading = false,
} = props;
const shouldAnimate = !disableAnimation && (!disabled || loading);
const pointerBlocked = loading;
const paletteMainMap: Record<string, string> = {
primary: theme.palette.primary.main,
secondary: theme.palette.secondary.main,
error: theme.palette.error.main,
warning: theme.palette.warning.main,
info: theme.palette.info.main,
success: theme.palette.success.main,
inherit: theme.palette.primary.main,
};
const paletteMain =
(color && paletteMainMap[String(color)]) ?? theme.palette.primary.main;
const pulseColor =
variant === "outlined" || variant === "text"
? alpha(paletteMain, 0.18)
: alpha(paletteMain, 0.3);
return {
position: "relative",
overflow: "hidden",
borderRadius: 5,
zIndex: 0,
transition: "transform 0.2s ease, box-shadow 0.2s ease",
pointerEvents: pointerBlocked ? "none" : undefined,
"&::after": shouldAnimate
? {
content: '""',
position: "absolute",
width: "12px",
height: "12px",
backgroundColor: pulseColor,
borderRadius: "50%",
top: "50%",
left: "50%",
pointerEvents: "none",
zIndex: 0,
...(loading
? {
opacity: 0.35,
transform: "translate(-50%, -50%) scale(0.6)",
animation: `${loadingPulse} 1.2s ease-in-out infinite`,
}
: {
opacity: 0,
transform: "translate(-50%, -50%) scale(0)",
transition: "transform 0.45s ease, opacity 0.45s ease",
}),
}
: {},
...(loading
? {}
: {
"&:hover": {
transform: "translateY(-1px)",
boxShadow: theme.shadows[4],
},
"&:hover::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(15)",
opacity: 1,
}
: {},
"&:active": {
transform: "translateY(0)",
boxShadow: theme.shadows[2],
},
"&:active::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(18)",
opacity: 0.4,
}
: {},
}),
"&.Mui-disabled": {
boxShadow: "none",
transform: "none",
...(loading && shouldAnimate
? {}
: {
"&::after": {
opacity: 0,
},
}),
},
...(disabled && {
boxShadow: "none",
transform: "none",
}),
"& > *": {
position: "relative",
zIndex: 1,
},
};
});
export const AnimatedCircleButton = forwardRef<
HTMLButtonElement,
AnimatedCircleButtonProps
>((props, ref) => {
const {
loading = false,
disabled,
children,
startIcon,
endIcon,
...rest
} = props;
const effectiveStartIcon = loading ? (
<CircularProgress size={16} color="inherit" />
) : (
startIcon
);
return (
<StyledButton
ref={ref}
loading={loading}
disabled={loading ? true : disabled}
startIcon={effectiveStartIcon}
endIcon={loading ? undefined : endIcon}
{...rest}
>
{children}
</StyledButton>
);
});

View File

@@ -1,11 +1,10 @@
import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
import { Modal as MuiModal, Typography, Box } from "@mui/material";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
sx?: SxProps<Theme>;
}
const style = {
@@ -20,7 +19,7 @@ const style = {
borderRadius: 2,
};
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
return (
<MuiModal
open={open}
@@ -28,7 +27,7 @@ export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={{ ...style, ...sx }}>
<Box sx={style}>
{title && (
<Typography
id="modal-modal-title"

View File

@@ -1,95 +0,0 @@
import {
Autocomplete,
Checkbox,
CircularProgress,
TextField,
} from "@mui/material";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
export interface MultiSelectOption<TValue = number | string> {
readonly value: TValue;
readonly label: string;
}
interface MultiSelectProps<TValue = number | string> {
readonly options: MultiSelectOption<TValue>[];
readonly value: TValue[];
readonly onChange: (values: TValue[]) => void;
readonly label?: string;
readonly placeholder?: string;
readonly loading?: boolean;
readonly disabled?: boolean;
readonly error?: boolean;
readonly helperText?: string;
readonly size?: "small" | "medium";
readonly fullWidth?: boolean;
}
export function MultiSelect<TValue = number | string>({
options,
value,
onChange,
label,
placeholder,
loading = false,
disabled = false,
error = false,
helperText,
size = "small",
fullWidth = true,
}: MultiSelectProps<TValue>) {
const selectedOptions = options.filter((opt) => value.includes(opt.value));
return (
<Autocomplete
multiple
disableCloseOnSelect
fullWidth={fullWidth}
size={size}
disabled={disabled}
loading={loading}
options={options}
value={selectedOptions}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, selected) => option.value === selected.value}
onChange={(_, newSelected) => {
onChange(newSelected.map((opt) => opt.value));
}}
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props as React.HTMLAttributes<HTMLLIElement> & { key: React.Key };
return (
<li key={key} {...rest}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={selected}
/>
{option.label}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={label}
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
error={error}
helperText={helperText}
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{loading && <CircularProgress color="inherit" size={16} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
);
}

View File

@@ -1,37 +0,0 @@
import { Search, X } from "lucide-react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export const SearchInput = ({
value,
onChange,
placeholder = "Поиск...",
}: SearchInputProps) => {
return (
<div className="relative mb-4 w-full max-w-sm">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-md text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
)}
</div>
);
};

View File

@@ -2,7 +2,3 @@ export * from "./TabPanel";
export * from "./BackButton";
export * from "./Modal";
export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
export * from "./MultiSelect";
export * from "./SearchInput";

1
src/vite-env.d.ts vendored
View File

@@ -1,2 +1 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -8,40 +8,16 @@ import {
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
import { cityStore, selectedCityStore } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
useEffect(() => {
if (canLoadAllCities) {
cityStore.getCities("ru");
return;
}
authStore.fetchMeCities().catch(() => undefined);
}, [canLoadAllCities]);
const baseCities: City[] = canLoadAllCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((uc) => ({
id: uc.city_id,
name: uc.name,
country: "",
country_code: "",
arms: "",
}));
const currentCities: City[] = selectedCity?.id
? (() => {
const exists = baseCities.some((city) => city.id === selectedCity.id);
if (exists) {
return baseCities;
}
return [selectedCity, ...baseCities];
})()
: baseCities;
getCities("ru");
}, []);
const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value;
@@ -50,12 +26,14 @@ export const CitySelector: React.FC = observer(() => {
return;
}
const city = currentCities.find((c) => c.id === Number(cityId));
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
const currentCities = cities["ru"].data;
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" />
@@ -73,13 +51,16 @@ export const CitySelector: React.FC = observer(() => {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
MenuProps={{
PaperProps: {},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>

View File

@@ -5,10 +5,9 @@ import { useNavigate } from "react-router-dom";
interface CreateButtonProps {
label: string;
path: string;
disabled?: boolean;
}
export const CreateButton = ({ label, path, disabled }: CreateButtonProps) => {
export const CreateButton = ({ label, path }: CreateButtonProps) => {
const navigate = useNavigate();
return (
@@ -19,7 +18,6 @@ export const CreateButton = ({ label, path, disabled }: CreateButtonProps) => {
navigate(path);
}}
startIcon={<Plus size={20} />}
disabled={disabled}
>
{label}
</Button>

View File

@@ -1,338 +0,0 @@
import { API_URL, authInstance, Modal } from "@shared";
import { Button, CircularProgress, TextField } from "@mui/material";
import { useEffect, useState, useMemo } from "react";
import { toast } from "react-toastify";
interface DeviceLogChunk {
date?: string;
lines?: string[];
}
interface DeviceLogsModalProps {
open: boolean;
deviceUuid: string | null;
onClose: () => void;
}
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
const shiftYYYYMMDD = (value: string, days: number) => {
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return value;
d.setUTCDate(d.getUTCDate() + days);
return toYYYYMMDD(d);
};
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
info: {
badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]",
},
debug: {
badge: "bg-gray-100 text-gray-600",
text: "text-gray-600",
},
warn: {
badge: "bg-amber-100 text-amber-700",
text: "text-amber-800",
},
error: {
badge: "bg-red-100 text-red-700",
text: "text-red-700",
},
fatal: {
badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold",
},
unknown: {
badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]",
},
};
const formatTs = (raw: string): string => {
try {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return raw;
}
};
const parseJsonLogLine = (line: string) => {
try {
const obj = JSON.parse(line);
if (obj && typeof obj === "object") {
const level: LogLevel =
obj.level && obj.level in LOG_LEVEL_STYLES
? (obj.level as LogLevel)
: "unknown";
const ts: string = obj.ts ? formatTs(obj.ts) : "";
const msg: string = obj.msg ?? "";
const extra: Record<string, unknown> = { ...obj };
delete extra.level;
delete extra.ts;
delete extra.msg;
delete extra.caller;
const extraStr = Object.keys(extra).length
? " " +
Object.entries(extra)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" ")
: "";
return { ts, level, msg, extraStr };
}
} catch {
return null;
}
return null;
};
const parseLogLine = (line: string, index: number) => {
const json = parseJsonLogLine(line);
if (json) {
return {
id: index,
time: json.ts,
text: json.msg + json.extraStr,
level: json.level,
sortKey: json.ts,
};
}
const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/);
if (bracketMatch) {
const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim();
return {
id: index,
time: formatTs(rawTime),
text: bracketMatch[2].trim() || line,
level: "unknown" as LogLevel,
sortKey: rawTime,
};
}
return {
id: index,
time: "",
text: line,
level: "unknown" as LogLevel,
sortKey: "",
};
};
export const DeviceLogsModal = ({
open,
deviceUuid,
onClose,
}: DeviceLogsModalProps) => {
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
const handleDateFromChange = (value: string) => {
setDateFrom(value);
if (!dateTo || dateTo <= value) {
setDateTo(shiftYYYYMMDD(value, 1));
}
};
const handleDateToChange = (value: string) => {
if (value <= dateFrom) {
toast.info("Дата 'До' должна быть позже даты 'От'");
setDateTo(shiftYYYYMMDD(dateFrom, 1));
return;
}
setDateTo(value);
};
useEffect(() => {
if (!open || !deviceUuid) return;
const fetchLogs = async () => {
setIsLoading(true);
setError(null);
try {
const { data } = await authInstance.get<DeviceLogChunk[]>(
`${API_URL}/devices/${deviceUuid}/logs`,
{
params: {
from: dateFrom,
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
},
}
);
setChunks(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message?: string }).message)
: "Ошибка загрузки логов";
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, [open, deviceUuid, dateFrom, dateTo]);
const logs = useMemo(() => {
const parsed = chunks.flatMap((chunk, chunkIdx) =>
(chunk.lines ?? []).map((line, i) =>
parseLogLine(line, chunkIdx * 10000 + i)
)
);
parsed.sort((a, b) => {
if (!a.sortKey && !b.sortKey) return 0;
if (!a.sortKey) return 1;
if (!b.sortKey) return -1;
return b.sortKey.localeCompare(a.sortKey);
});
return parsed;
}, [chunks]);
const logsText = useMemo(
() =>
logs
.map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`;
})
.join("\n"),
[logs]
);
const handleDownloadLogs = () => {
if (!logsText) {
toast.info("Нет логов для сохранения");
return;
}
try {
const safeDeviceUuid = (deviceUuid ?? "device").replace(
/[^a-zA-Z0-9_-]/g,
"_"
);
const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`;
const blob = new Blob([`\uFEFF${logsText}`], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Логи сохранены в .txt");
} catch {
toast.error("Не удалось сохранить логи");
}
};
return (
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
<div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-4 items-center">
<TextField
type="date"
label="От"
size="small"
value={dateFrom}
onChange={(e) => handleDateFromChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { max: dateFromMax },
}}
/>
<TextField
type="date"
label="До"
size="small"
value={dateTo}
onChange={(e) => handleDateToChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { min: dateToMin },
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || logs.length === 0}
>
Скачать .txt
</Button>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 w-full">
{isLoading && (
<div className="w-full h-full flex items-center justify-center">
<CircularProgress />
</div>
)}
{!isLoading && error && (
<div className="w-full h-full flex items-center justify-center text-red-500">
{error}
</div>
)}
{!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{logs.length > 0 ? (
logs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level];
return (
<div
key={log.id}
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
>
<span
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
>
{log.level === "unknown" ? "LOG" : log.level}
</span>
<span className="text-gray-400 shrink-0 whitespace-nowrap">
{log.time || null}
</span>
<span className="break-all">{log.text}</span>
</div>
);
})
) : (
<div className="h-full flex items-center justify-center text-gray-500 py-10">
Логи отсутствуют.
</div>
)}
</div>
</div>
)}
</div>
</div>
</Modal>
);
};

View File

@@ -1,180 +0,0 @@
import { Modal, vehicleStore } from "@shared";
import {
Box,
Button,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
interface VehicleSessionsModalProps {
open: boolean;
vehicleId: number | null;
tailNumber?: string | null;
onClose: () => void;
}
const formatDateTime = (value: string) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
};
const formatDuration = (durationSeconds: number) => {
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
return "-";
}
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
return [hours, minutes, seconds]
.map((part) => String(part).padStart(2, "0"))
.join(":");
};
export const VehicleSessionsModal = observer(
({ open, vehicleId, tailNumber, onClose }: VehicleSessionsModalProps) => {
const {
vehicleSessions,
vehicleSessionsLoading,
vehicleSessionsError,
getVehicleSessions,
} = vehicleStore;
useEffect(() => {
if (!open || vehicleId == null) return;
getVehicleSessions(vehicleId).catch(() => undefined);
}, [open, vehicleId, getVehicleSessions]);
const title =
tailNumber && tailNumber !== ""
? `Сессии ТО: ${tailNumber}`
: "Сессии ТО";
return (
<Modal
open={open}
onClose={onClose}
sx={{ width: "min(1080px, 95vw)", p: 3 }}
>
<div className="flex flex-col gap-4 max-h-[82vh]">
<Typography variant="h6" component="h2">
{title}
</Typography>
<Box sx={{ minHeight: 220 }}>
{vehicleSessionsLoading && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={24} />
</Box>
)}
{!vehicleSessionsLoading && vehicleSessionsError && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "error.main",
}}
>
{vehicleSessionsError}
</Box>
)}
{!vehicleSessionsLoading &&
!vehicleSessionsError &&
vehicleSessions &&
vehicleSessions.length === 0 && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
}}
>
По этому транспорту нет сессий ТО.
</Box>
)}
{!vehicleSessionsLoading &&
!vehicleSessionsError &&
vehicleSessions &&
vehicleSessions.length > 0 && (
<TableContainer
sx={{ maxHeight: "60vh", border: 1, borderColor: "divider" }}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Начало</TableCell>
<TableCell>Окончание</TableCell>
<TableCell align="right">Длительность</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicleSessions.map((session) => (
<TableRow key={session.id} hover>
<TableCell>{session.id}</TableCell>
<TableCell>
{formatDateTime(session.started_at)}
</TableCell>
<TableCell>
{formatDateTime(session.ended_at)}
</TableCell>
<TableCell align="right">
{formatDuration(session.duration_seconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
<Button
variant="outlined"
color="inherit"
onClick={onClose}
fullWidth
>
Закрыть
</Button>
</div>
</Modal>
);
},
);

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More