Compare commits
45 Commits
develop
...
b42802aac0
| Author | SHA1 | Date | |
|---|---|---|---|
| b42802aac0 | |||
| 938a7e6d1e | |||
| beb9e932ef | |||
| a3a4d2eb18 | |||
| d380b2570f | |||
| fbf8232ce3 | |||
| 8d1de769c5 | |||
| 4b02c6e9d3 | |||
| a58f438dce | |||
| 5e0b56c7dc | |||
| a182a52111 | |||
| dd5aee58e6 | |||
| 442160ba38 | |||
| b6a9cecba6 | |||
| 591ca8104d | |||
| c3127b8d47 | |||
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c | |||
| d557664b25 | |||
| bbab6fc46a | |||
| 25155a66bc | |||
| a3d574a79c | |||
| 39e11ad5ca | |||
| 7e068e49f5 | |||
| 79539d0583 | |||
| c5c5f835bc | |||
| 5481d264e0 | |||
| d6772b1e3a | |||
| 11133b6839 | |||
| aaeaed3fa5 | |||
| 95fe297aae | |||
| 04a9ac452e | |||
| 85c71563c1 | |||
| 6f32c6e671 | |||
| 0a6192c7da | |||
| b1ba3b4cd5 | |||
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 |
5
.env
5
.env
@@ -1,3 +1,8 @@
|
||||
# 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'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.6",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -41,7 +41,8 @@
|
||||
"react-toastify": "^11.0.5",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"three": "^0.177.0"
|
||||
"three": "^0.177.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
|
||||
@@ -5,10 +5,12 @@ 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>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
VehicleCreatePage,
|
||||
VehicleEditPage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
ArticlePreviewPage,
|
||||
CountryAddPage,
|
||||
} from "@pages";
|
||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||
import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared";
|
||||
import { Layout } from "@widgets";
|
||||
import { runInAction } from "mobx";
|
||||
import React, { useEffect } from "react";
|
||||
@@ -47,11 +48,14 @@ import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
if (isAuthenticated) {
|
||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||
|
||||
if (isAuthenticated || !need_auth) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
@@ -59,13 +63,31 @@ 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();
|
||||
if (!isAuthenticated) {
|
||||
const matches = useMatches();
|
||||
|
||||
if (!isAuthenticated && need_auth) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (location.pathname === "/") {
|
||||
|
||||
if (location.pathname === "/" && authStore.canRead("map")) {
|
||||
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}</>;
|
||||
};
|
||||
|
||||
@@ -94,7 +116,10 @@ const router = createBrowserRouter([
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{ path: "route-preview/:id", element: <RoutePreview /> },
|
||||
{
|
||||
path: "route-preview/:id",
|
||||
element: <RoutePreview />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
@@ -107,47 +132,258 @@ const router = createBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <MainPage /> },
|
||||
{
|
||||
index: true,
|
||||
element: <MainPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "sight", element: <SightListPage /> },
|
||||
{ path: "sight/create", element: <CreateSightPage /> },
|
||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "devices", element: <DevicesPage /> },
|
||||
{
|
||||
path: "devices",
|
||||
element: <DevicesPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/devices"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "map", element: <MapPage /> },
|
||||
{
|
||||
path: "map",
|
||||
element: <MapPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/map"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "media", element: <MediaListPage /> },
|
||||
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/add", element: <CountryAddPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
{ path: "city/create", element: <CityCreatePage /> },
|
||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||
{ path: "route", element: <RouteListPage /> },
|
||||
{ path: "route/create", element: <RouteCreatePage /> },
|
||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "user", element: <UserListPage /> },
|
||||
{ path: "user/create", element: <UserCreatePage /> },
|
||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "carrier", element: <CarrierListPage /> },
|
||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||
{ path: "station", element: <StationListPage /> },
|
||||
{ path: "station/create", element: <StationCreatePage /> },
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface NavigationItem {
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
required_resource?: string;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -31,20 +30,10 @@ 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?.filter((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const filteredNestedItems = item.nestedItems;
|
||||
|
||||
const handleClick = () => {
|
||||
if (item.id === "all" && !open) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||
import { authStore, NAVIGATION_ITEMS, ROUTE_REQUIRED_RESOURCES } from "@shared";
|
||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
@@ -9,28 +9,48 @@ 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 { 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;
|
||||
});
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,7 +71,7 @@ export const NavigationList = observer(
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onClick={item.onClick ?? undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -17,6 +17,12 @@ 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 () => {
|
||||
@@ -47,13 +53,12 @@ export const ArticleListPage = observer(() => {
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -62,17 +67,22 @@ export const ArticleListPage = observer(() => {
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rows = articleList[language].data.map((article) => ({
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -83,10 +93,8 @@ export const ArticleListPage = observer(() => {
|
||||
<h1 className="text-2xl">Статьи</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -95,19 +103,45 @@ export const ArticleListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteArticles}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
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
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box
|
||||
|
||||
@@ -6,6 +6,7 @@ import { observer } from "mobx-react-lite";
|
||||
export const PreviewLeftWidget = observer(() => {
|
||||
const { articleMedia, articleData } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const body = articleData?.[language]?.body;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -66,7 +67,7 @@ export const PreviewLeftWidget = observer(() => {
|
||||
{articleData?.[language]?.heading || "Название информации"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{articleData?.[language]?.body && (
|
||||
{body && (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
@@ -77,7 +78,7 @@ export const PreviewLeftWidget = observer(() => {
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent value={articleData?.[language]?.body} />
|
||||
<ReactMarkdownComponent value={body} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { PreviewLeftWidget } from "./PreviewLeftWidget";
|
||||
import { PreviewRightWidget } from "./PreviewRightWidget";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { articlesStore, languageStore, LoadingSpinner } from "@shared";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export const ArticlePreviewPage = () => {
|
||||
@@ -11,18 +11,41 @@ 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);
|
||||
}
|
||||
};
|
||||
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">
|
||||
|
||||
@@ -8,30 +8,31 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } 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 { selectedCityId } = useSelectedCity();
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
@@ -43,11 +44,37 @@ export const CarrierCreatePage = observer(() => {
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cityStore.getCities("ru");
|
||||
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();
|
||||
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(
|
||||
@@ -56,7 +83,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
@@ -88,13 +115,17 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = selectedMediaId
|
||||
const selectedMedia =
|
||||
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||
? null
|
||||
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -127,11 +158,11 @@ export const CarrierCreatePage = observer(() => {
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities["ru"].data.map((city) => (
|
||||
{availableCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -151,7 +182,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -168,7 +199,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -184,7 +215,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -193,10 +224,10 @@ export const CarrierCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
@@ -207,7 +238,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
|
||||
@@ -6,13 +6,22 @@ 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, mediaStore, languageStore } from "@shared";
|
||||
import {
|
||||
carrierStore,
|
||||
cityStore,
|
||||
authStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
@@ -26,22 +35,36 @@ 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");
|
||||
await cityStore.getCities("en");
|
||||
await cityStore.getCities("zh");
|
||||
} else {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
const carrierData = await getCarrier(Number(id));
|
||||
|
||||
if (carrierData) {
|
||||
@@ -69,9 +92,13 @@ export const CarrierEditPage = observer(() => {
|
||||
carrierData.zh?.logo || "",
|
||||
"zh"
|
||||
);
|
||||
setInitialCityName(carrierData.ru?.city || "");
|
||||
}
|
||||
|
||||
mediaStore.getMedia();
|
||||
await mediaStore.getMedia();
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
|
||||
languageStore.setLanguage("ru");
|
||||
@@ -106,9 +133,53 @@ export const CarrierEditPage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCarrierData.logo
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -144,7 +215,7 @@ export const CarrierEditPage = observer(() => {
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities["ru"].data?.map((city) => (
|
||||
{availableCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -206,10 +277,10 @@ export const CarrierEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, cityStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,21 +10,31 @@ 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);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
if (!authStore.me) {
|
||||
await authStore.getMeAction().catch(() => undefined);
|
||||
}
|
||||
if (authStore.canRead("cities")) {
|
||||
await cityStore.getCities(language);
|
||||
} else {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -69,36 +79,28 @@ export const CarrierListPage = observer(() => {
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const city = cities[language]?.data.find(
|
||||
(city) => city.id == params.value
|
||||
);
|
||||
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">
|
||||
{city && city.name ? (
|
||||
city.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
{cityName ?? <Minus size={20} className="text-red-500" />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...(authStore.canWrite("carriers") ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
headerAlign: "center" as const,
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
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={() => navigate(`/carrier/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -108,17 +110,33 @@ export const CarrierListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const rows = carriers[language].data?.map((carrier) => ({
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,13 +144,13 @@ 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -141,17 +159,40 @@ export const CarrierListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteCarriers}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
onRowSelectionModelChange={
|
||||
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
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
|
||||
@@ -12,14 +12,18 @@ 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();
|
||||
@@ -72,9 +76,13 @@ export const CityCreatePage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = createCityData.arms
|
||||
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);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -135,10 +143,10 @@ export const CityCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCityData(
|
||||
|
||||
@@ -6,10 +6,10 @@ 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 { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -17,19 +17,20 @@ import {
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
CashedCities,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
LoadingSpinner,
|
||||
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);
|
||||
@@ -62,6 +63,8 @@ export const CityEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getCountries("ru");
|
||||
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
@@ -75,6 +78,11 @@ export const CityEditPage = observer(() => {
|
||||
await getOneMedia(ruData.arms as string);
|
||||
|
||||
await getMedia();
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
@@ -89,13 +97,32 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCityData.arms
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -124,7 +151,7 @@ export const CityEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editCityData.country_code,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -140,7 +167,7 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
e.target.value,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -156,17 +183,17 @@ export const CityEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, cityStore, countryStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -18,7 +18,13 @@ 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 () => {
|
||||
@@ -52,6 +58,18 @@ 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",
|
||||
@@ -87,22 +105,18 @@ export const CityListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...(authStore.canWrite("cities") ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
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={() => navigate(`/city/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -113,9 +127,8 @@ export const CityListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -125,13 +138,13 @@ 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -140,17 +153,44 @@ export const CityListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={filteredRows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteCities}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
onRowSelectionModelChange={
|
||||
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
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Button, Paper, TextField } from "@mui/material";
|
||||
import { Button, Paper, TextField, 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 { countryStore, languageStore } from "@shared";
|
||||
import { countryStore, languageStore, LoadingSpinner } 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 } =
|
||||
@@ -35,6 +36,8 @@ 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");
|
||||
@@ -42,10 +45,30 @@ export const CountryEditPage = observer(() => {
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
}, [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 />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, countryStore, languageStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Minus } from "lucide-react";
|
||||
|
||||
@@ -16,7 +16,13 @@ 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 () => {
|
||||
@@ -44,24 +50,15 @@ export const CountryListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...(authStore.canWrite("countries") ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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();
|
||||
@@ -72,16 +69,20 @@ export const CountryListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const rows = countries[language]?.data.map((country) => ({
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,13 +91,13 @@ 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -105,17 +106,44 @@ export const CountryListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows || []}
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteCountries}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
onRowSelectionModelChange={
|
||||
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
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Tab, Tabs } from "@mui/material";
|
||||
import {
|
||||
articlesStore,
|
||||
authStore,
|
||||
cityStore,
|
||||
createSightStore,
|
||||
languageStore,
|
||||
@@ -40,7 +41,14 @@ 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 getArticles(languageStore.language);
|
||||
};
|
||||
fetchData();
|
||||
|
||||
@@ -3,7 +3,13 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
|
||||
import { LeftWidgetTab } from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { articlesStore, cityStore, editSightStore } from "@shared";
|
||||
import {
|
||||
articlesStore,
|
||||
authStore,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { useBlocker, useParams } from "react-router-dom";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
@@ -15,7 +21,8 @@ function a11yProps(index: number) {
|
||||
|
||||
export const EditSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||
const { getArticles } = articlesStore;
|
||||
|
||||
const { id } = useParams();
|
||||
@@ -33,13 +40,29 @@ 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);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
@@ -79,12 +102,25 @@ export const EditSightPage = observer(() => {
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{sight.common.id !== 0 && (
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
|
||||
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
import * as React from "react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
export const MainPage: React.FC = () => {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -186,7 +186,7 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(Array.from(hiddenRoutes))
|
||||
JSON.stringify(Array.from(hiddenRoutes)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save hidden routes:", error);
|
||||
@@ -221,7 +221,7 @@ class MapStore {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(!!val)
|
||||
JSON.stringify(!!val),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -239,14 +239,14 @@ class MapStore {
|
||||
|
||||
private sortFeatures<T extends ApiStation | ApiSight>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
case "name_asc":
|
||||
return sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return sorted.sort((a, b) => a.name.trim().localeCompare(b.name.trim()));
|
||||
case "name_desc":
|
||||
return sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
return sorted.sort((a, b) => b.name.trim().localeCompare(a.name.trim()));
|
||||
case "created_asc":
|
||||
return sorted.sort((a, b) => {
|
||||
if (
|
||||
@@ -324,7 +324,7 @@ class MapStore {
|
||||
return this.sortedStations;
|
||||
}
|
||||
return this.sortedStations.filter(
|
||||
(station) => station.city_id === selectedCityId
|
||||
(station) => station.city_id === selectedCityId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ class MapStore {
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
const routePromises = routesIds.map((id: number) =>
|
||||
languageInstance("ru").get(`/route/${id}`)
|
||||
languageInstance("ru").get(`/route/${id}`),
|
||||
);
|
||||
const routeResponses = await Promise.all(routePromises);
|
||||
this.routes = routeResponses.map((res) => ({
|
||||
@@ -379,7 +379,7 @@ class MapStore {
|
||||
}));
|
||||
|
||||
this.routes = this.routes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
a.route_number.trim().localeCompare(b.route_number.trim()),
|
||||
);
|
||||
|
||||
await this.preloadRouteStations(routesIds);
|
||||
@@ -391,14 +391,14 @@ class MapStore {
|
||||
const stationPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const stationsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/station`
|
||||
`/route/${routeId}/station`,
|
||||
);
|
||||
const stationIds = stationsResponse.data.map((s: any) => s.id);
|
||||
this.routeStationsCache.set(routeId, stationIds);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to preload stations for route ${routeId}:`,
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -409,7 +409,7 @@ class MapStore {
|
||||
const sightPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const sightsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/sight`
|
||||
`/route/${routeId}/sight`,
|
||||
);
|
||||
const sightIds = sightsResponse.data.map((s: any) => s.id);
|
||||
this.routeSightsCache.set(routeId, sightIds);
|
||||
@@ -488,22 +488,12 @@ class MapStore {
|
||||
const route_number = properties.name || "Маршрут 1";
|
||||
const path = geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
||||
|
||||
const lineGeom = new GeoJSON().readGeometry(geometry, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection: "EPSG:3857",
|
||||
});
|
||||
const centerCoords = getCenter(lineGeom.getExtent());
|
||||
const [center_longitude, center_latitude] = toLonLat(
|
||||
centerCoords,
|
||||
"EPSG:3857"
|
||||
);
|
||||
|
||||
let carrier_id = 0;
|
||||
let carrier = "";
|
||||
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
const carriersInCity = carrierStore.carriers.ru.data.filter(
|
||||
(c: any) => c.city_id === selectedCityStore.selectedCityId
|
||||
(c: any) => c.city_id === selectedCityStore.selectedCityId,
|
||||
);
|
||||
|
||||
if (carriersInCity.length > 0) {
|
||||
@@ -515,8 +505,8 @@ class MapStore {
|
||||
const routeData = {
|
||||
route_number,
|
||||
path,
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
center_latitude: path[0][0],
|
||||
center_longitude: path[0][1],
|
||||
carrier,
|
||||
carrier_id,
|
||||
governor_appeal: 0,
|
||||
@@ -531,7 +521,7 @@ class MapStore {
|
||||
|
||||
if (!carrier_id && selectedCityStore.selectedCityId) {
|
||||
toast.error(
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке"
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке",
|
||||
);
|
||||
}
|
||||
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
|
||||
@@ -583,7 +573,7 @@ class MapStore {
|
||||
const centerCoords = getCenter(lineGeom.getExtent());
|
||||
const [center_longitude, center_latitude] = toLonLat(
|
||||
centerCoords,
|
||||
"EPSG:3857"
|
||||
"EPSG:3857",
|
||||
);
|
||||
data = {
|
||||
route_number: properties.name,
|
||||
@@ -616,7 +606,7 @@ class MapStore {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find old data for ${featureType} with id ${numericId}`
|
||||
`Could not find old data for ${featureType} with id ${numericId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,7 +626,7 @@ class MapStore {
|
||||
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/${featureType}/${numericId}`,
|
||||
requestBody
|
||||
requestBody,
|
||||
);
|
||||
|
||||
const updateStore = (store: any[], updatedItem: any) => {
|
||||
@@ -755,7 +745,7 @@ class MapService {
|
||||
private selectInteraction: Select;
|
||||
private hoveredFeatureId: string | number | null;
|
||||
private boundHandlePointerMove: (
|
||||
event: MapBrowserEvent<PointerEvent>
|
||||
event: MapBrowserEvent<PointerEvent>,
|
||||
) => void;
|
||||
private boundHandlePointerLeave: () => void;
|
||||
private boundHandleContextMenu: (event: MouseEvent) => void;
|
||||
@@ -794,7 +784,7 @@ class MapService {
|
||||
onFeaturesChange: (features: Feature<Geometry>[]) => void,
|
||||
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
|
||||
tooltipElement: HTMLElement,
|
||||
onSelectionChange?: (ids: Set<string | number>) => void
|
||||
onSelectionChange?: (ids: Set<string | number>) => void,
|
||||
) {
|
||||
this.map = null;
|
||||
this.tooltipElement = tooltipElement;
|
||||
@@ -943,7 +933,7 @@ class MapService {
|
||||
style: (featureLike: FeatureLike) => {
|
||||
const clusterFeature = featureLike as Feature<Point>;
|
||||
const featuresInCluster = clusterFeature.get(
|
||||
"features"
|
||||
"features",
|
||||
) as Feature<Point>[];
|
||||
const size = featuresInCluster.length;
|
||||
|
||||
@@ -1001,18 +991,18 @@ class MapService {
|
||||
|
||||
this.pointSource.on(
|
||||
"addfeature",
|
||||
this.handleFeatureEvent.bind(this) as any
|
||||
this.handleFeatureEvent.bind(this) as any,
|
||||
);
|
||||
this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
|
||||
this.pointSource.on(
|
||||
"changefeature",
|
||||
this.handleFeatureChange.bind(this) as any
|
||||
this.handleFeatureChange.bind(this) as any,
|
||||
);
|
||||
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
|
||||
this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
|
||||
this.lineSource.on(
|
||||
"changefeature",
|
||||
this.handleFeatureChange.bind(this) as any
|
||||
this.handleFeatureChange.bind(this) as any,
|
||||
);
|
||||
|
||||
let renderCompleteHandled = false;
|
||||
@@ -1066,7 +1056,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1078,7 +1068,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1199,7 +1189,7 @@ class MapService {
|
||||
const feature = this.map?.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
if (!feature) return;
|
||||
@@ -1237,7 +1227,7 @@ class MapService {
|
||||
}
|
||||
|
||||
const newCoordinates = coordinates.filter(
|
||||
(_, index) => index !== closestIndex
|
||||
(_, index) => index !== closestIndex,
|
||||
);
|
||||
lineString.setCoordinates(newCoordinates);
|
||||
this.saveModifiedFeature(feature);
|
||||
@@ -1280,7 +1270,7 @@ class MapService {
|
||||
selected.add(f.getId()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.setSelectedIds(selected);
|
||||
@@ -1427,7 +1417,7 @@ class MapService {
|
||||
public loadFeaturesFromApi(
|
||||
_apiStations: typeof mapStore.stations,
|
||||
_apiRoutes: typeof mapStore.routes,
|
||||
_apiSights: typeof mapStore.sights
|
||||
_apiSights: typeof mapStore.sights,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1460,8 +1450,8 @@ class MapService {
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({ geometry: point, name: station.name });
|
||||
feature.setId(`station-${station.id}`);
|
||||
@@ -1472,7 +1462,7 @@ class MapService {
|
||||
filteredSights.forEach((sight) => {
|
||||
if (sight.longitude == null || sight.latitude == null) return;
|
||||
const point = new Point(
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -1492,7 +1482,7 @@ class MapService {
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.map((c: [number, number]) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
);
|
||||
|
||||
if (coordinates.length === 0) return;
|
||||
@@ -1578,7 +1568,7 @@ class MapService {
|
||||
|
||||
public startDrawing(
|
||||
type: "Point" | "LineString",
|
||||
featureType: FeatureType
|
||||
featureType: FeatureType,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1742,7 +1732,7 @@ class MapService {
|
||||
this.map.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
let finalFeature: Feature<Geometry> | null = null;
|
||||
@@ -1817,7 +1807,7 @@ class MapService {
|
||||
|
||||
public deleteFeature(
|
||||
featureId: string | number | undefined,
|
||||
recourse: string
|
||||
recourse: string,
|
||||
): void {
|
||||
if (featureId === undefined) return;
|
||||
|
||||
@@ -1873,7 +1863,7 @@ class MapService {
|
||||
const lineFeature = this.lineSource.getFeatureById(id);
|
||||
if (lineFeature)
|
||||
this.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
const pointFeature = this.pointSource.getFeatureById(id);
|
||||
if (pointFeature)
|
||||
@@ -1900,11 +1890,11 @@ class MapService {
|
||||
if (targetEl instanceof HTMLElement) {
|
||||
targetEl.removeEventListener(
|
||||
"contextmenu",
|
||||
this.boundHandleContextMenu
|
||||
this.boundHandleContextMenu,
|
||||
);
|
||||
targetEl.removeEventListener(
|
||||
"pointerleave",
|
||||
this.boundHandlePointerLeave
|
||||
this.boundHandlePointerLeave,
|
||||
);
|
||||
}
|
||||
this.map.un("pointermove", this.boundHandlePointerMove as any);
|
||||
@@ -1917,7 +1907,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureEvent(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
const feature = event.feature;
|
||||
@@ -1928,7 +1918,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureChange(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
this.updateFeaturesInReact();
|
||||
@@ -1966,7 +1956,7 @@ class MapService {
|
||||
});
|
||||
|
||||
this.modifyInteraction.setActive(
|
||||
this.selectInteraction.getFeatures().getLength() > 0
|
||||
this.selectInteraction.getFeatures().getLength() > 0,
|
||||
);
|
||||
this.clusterLayer.changed();
|
||||
this.routeLayer.changed();
|
||||
@@ -2036,7 +2026,7 @@ class MapService {
|
||||
if (typeof featureId === "number" || !String(featureId).includes("-")) {
|
||||
console.warn(
|
||||
"Skipping save for feature with non-standard ID:",
|
||||
featureId
|
||||
featureId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2084,7 +2074,7 @@ class MapService {
|
||||
try {
|
||||
const createdFeatureData = await mapStore.createFeature(
|
||||
featureType,
|
||||
featureGeoJSON
|
||||
featureGeoJSON,
|
||||
);
|
||||
|
||||
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
|
||||
@@ -2103,8 +2093,8 @@ class MapService {
|
||||
|
||||
const lineGeom = new LineString(
|
||||
routeData.path.map((c) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
),
|
||||
);
|
||||
feature.setGeometry(lineGeom);
|
||||
} else {
|
||||
@@ -2257,7 +2247,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const actualFeatures = useMemo(
|
||||
() => mapFeatures.filter((f) => !f.get("isProxy")),
|
||||
[mapFeatures]
|
||||
[mapFeatures],
|
||||
);
|
||||
|
||||
const allFeatures = useMemo(() => {
|
||||
@@ -2267,8 +2257,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: station.name,
|
||||
description: station.description || "",
|
||||
@@ -2285,8 +2275,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[sight.longitude, sight.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: sight.name,
|
||||
description: sight.description,
|
||||
@@ -2330,7 +2320,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
(f.get("routeNumber") as string) || "",
|
||||
];
|
||||
return candidates.some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery)
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
});
|
||||
}, [allFeatures, searchQuery]);
|
||||
@@ -2353,7 +2343,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.selectFeature(id);
|
||||
}
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -2363,7 +2353,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.deleteFeature(id, resource);
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
@@ -2375,14 +2365,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
setSelectedIds(newSet);
|
||||
mapService.setSelectedIds(newSet);
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (!mapService || selectedIds.size === 0) return;
|
||||
if (
|
||||
window.confirm(
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`,
|
||||
)
|
||||
) {
|
||||
mapService.deleteMultipleFeatures(Array.from(selectedIds));
|
||||
@@ -2396,7 +2386,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
if (!featureType || !numericId) return;
|
||||
navigate(`/${featureType}/${numericId}/edit`);
|
||||
},
|
||||
[navigate]
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleHideRoute = useCallback(
|
||||
@@ -2423,7 +2413,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.map((c: [number, number]) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
);
|
||||
|
||||
if (coordinates.length > 0) {
|
||||
@@ -2445,7 +2435,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2453,12 +2443,12 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToShow = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
for (const stationId of stationsToShow) {
|
||||
@@ -2469,8 +2459,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -2480,7 +2470,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
feature.set("featureType", "station");
|
||||
|
||||
const existingFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${station.id}`
|
||||
`station-${station.id}`,
|
||||
);
|
||||
if (!existingFeature) {
|
||||
mapService.pointSource.addFeature(feature);
|
||||
@@ -2497,7 +2487,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2505,21 +2495,21 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToHide = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
stationsToHide.forEach((stationId: number) => {
|
||||
const pointFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${stationId}`
|
||||
`station-${stationId}`,
|
||||
);
|
||||
if (pointFeature) {
|
||||
mapService.pointSource.removeFeature(
|
||||
pointFeature as Feature<Point>
|
||||
pointFeature as Feature<Point>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2527,7 +2517,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const lineFeature = mapService.lineSource.getFeatureById(routeId);
|
||||
if (lineFeature) {
|
||||
mapService.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2539,31 +2529,31 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[handleHideRoute] Error toggling route visibility:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
toast.error("Ошибка при изменении видимости маршрута");
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const sortFeaturesByType = <T extends Feature<Geometry>>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] => {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
case "name_asc":
|
||||
return sorted.sort((a, b) =>
|
||||
((a.get("name") as string) || "").localeCompare(
|
||||
(b.get("name") as string) || ""
|
||||
)
|
||||
((a.get("name") as string) || "").trim().localeCompare(
|
||||
((b.get("name") as string) || "").trim(),
|
||||
),
|
||||
);
|
||||
case "name_desc":
|
||||
return sorted.sort((a, b) =>
|
||||
((b.get("name") as string) || "").localeCompare(
|
||||
(a.get("name") as string) || ""
|
||||
)
|
||||
((b.get("name") as string) || "").trim().localeCompare(
|
||||
((a.get("name") as string) || "").trim(),
|
||||
),
|
||||
);
|
||||
case "created_asc":
|
||||
return sorted.sort((a, b) => {
|
||||
@@ -2619,13 +2609,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
};
|
||||
|
||||
const stations = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "station"
|
||||
(f) => f.get("featureType") === "station",
|
||||
);
|
||||
const lines = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "route"
|
||||
(f) => f.get("featureType") === "route",
|
||||
);
|
||||
const sights = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "sight"
|
||||
(f) => f.get("featureType") === "sight",
|
||||
);
|
||||
|
||||
const sortedStations = sortFeaturesByType(stations, stationSort);
|
||||
@@ -2634,7 +2624,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const renderFeatureList = (
|
||||
features: Feature<Geometry>[],
|
||||
featureType: "station" | "route" | "sight",
|
||||
IconComponent: React.ElementType
|
||||
IconComponent: React.ElementType,
|
||||
) => (
|
||||
<div className="space-y-1 pr-1">
|
||||
{features.length > 0 ? (
|
||||
@@ -2907,7 +2897,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
}`}
|
||||
onClick={() =>
|
||||
mapStore.setHideSightsByHiddenRoutes(
|
||||
!mapStore.hideSightsByHiddenRoutes
|
||||
!mapStore.hideSightsByHiddenRoutes,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -3002,7 +2992,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -3019,7 +3009,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const MapPage: React.FC = observer(() => {
|
||||
@@ -3035,7 +3025,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
|
||||
useState<Feature<Geometry> | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
|
||||
new Set()
|
||||
new Set(),
|
||||
);
|
||||
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
@@ -3047,7 +3037,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
|
||||
const handleFeaturesChange = useCallback(
|
||||
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFeatureSelectForSidebar = useCallback(
|
||||
@@ -3069,7 +3059,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3090,7 +3080,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapService.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load initial map data:", e);
|
||||
@@ -3109,7 +3099,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
handleFeaturesChange,
|
||||
handleFeatureSelectForSidebar,
|
||||
tooltipRef.current,
|
||||
setSelectedIds
|
||||
setSelectedIds,
|
||||
);
|
||||
setMapServiceInstance(service);
|
||||
|
||||
@@ -3120,7 +3110,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
loadInitialData(service);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`,
|
||||
);
|
||||
setIsMapLoading(false);
|
||||
setIsDataLoading(false);
|
||||
@@ -3213,7 +3203,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, mapServiceInstance, isDataLoading]);
|
||||
@@ -3226,7 +3216,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -3242,7 +3232,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
selectedFeatureForSidebar !== null || selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
|
||||
<div className="-mb-4 flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
|
||||
<div className="relative flex-grow flex">
|
||||
<div
|
||||
ref={mapRef}
|
||||
@@ -3289,35 +3279,87 @@ export const MapPage: React.FC = observer(() => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||||
title="Помощь по клавишам"
|
||||
>
|
||||
<InfoIcon size={20} />
|
||||
</button>
|
||||
|
||||
{showHelp && (
|
||||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
|
||||
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
|
||||
<ul className="text-sm space-y-2">
|
||||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-md max-h-[30vh] overflow-y-auto scrollbar-visible">
|
||||
<h4 className="font-bold mb-2">Управление картой</h4>
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Перемещение и масштаб:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Колесо мыши — приблизить / отдалить.</li>
|
||||
<li>
|
||||
Средняя кнопка мыши (колесо зажато) — перетаскивание карты.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Выделение объектов:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Одинарный клик по объекту — выделить и центрировать.</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Ctrl
|
||||
</span>{" "}
|
||||
+ клик — добавить / убрать объект из выделения.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Shift
|
||||
</span>{" "}
|
||||
- Режим выделения (лассо)
|
||||
— временно включить режим лассо (выделение области).
|
||||
</li>
|
||||
<li>Клик по пустому месту карты — снять выделение.</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Ctrl + клик
|
||||
Esc
|
||||
</span>{" "}
|
||||
- Добавить/убрать из выделения
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
|
||||
- Отменить выделение
|
||||
— снять выделение всех объектов.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">
|
||||
Рисование и редактирование:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
Кнопки в верхней панели — выбор режима: редактирование,
|
||||
добавление остановки, достопримечательности или маршрута.
|
||||
</li>
|
||||
<li>
|
||||
При рисовании маршрута: правый клик — завершить линию.
|
||||
</li>
|
||||
<li>
|
||||
В режиме редактирования: перетаскивайте точки маршрута для
|
||||
изменения траектории.
|
||||
</li>
|
||||
<li>
|
||||
Двойной клик по внутренней точке маршрута — удалить эту
|
||||
точку (при наличии не менее 2 точек).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Боковая панель:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Клик по строке в списке — перейти к объекту на карте.</li>
|
||||
<li>
|
||||
Иконка карандаша — открыть объект в режиме редактирования.
|
||||
</li>
|
||||
<li>
|
||||
Иконка карты у маршрутов — открыть предпросмотр маршрута.
|
||||
</li>
|
||||
<li>
|
||||
Иконка глаза у маршрутов — скрыть / показать маршрут и
|
||||
связанные остановки.
|
||||
</li>
|
||||
<li>Иконка корзины — удалить объект.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||||
@@ -3326,6 +3368,14 @@ export const MapPage: React.FC = observer(() => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||||
title="Помощь по управлению картой"
|
||||
>
|
||||
<InfoIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{showContent && (
|
||||
<MapSightbar
|
||||
|
||||
@@ -69,7 +69,7 @@ class MapStore {
|
||||
path: route.path,
|
||||
}));
|
||||
this.routes = mappedRoutes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
a.route_number.trim().localeCompare(b.route_number.trim())
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
@@ -138,8 +139,15 @@ export const MediaEditPage = observer(() => {
|
||||
|
||||
if (!media && id) {
|
||||
return (
|
||||
<Box className="flex justify-center items-center h-screen">
|
||||
<CircularProgress />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных медиа..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -16,7 +16,13 @@ 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 () => {
|
||||
@@ -67,16 +73,15 @@ export const MediaListPage = observer(() => {
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -85,25 +90,30 @@ export const MediaListPage = observer(() => {
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rows = media.map((media) => ({
|
||||
id: media.id,
|
||||
media_name: media.media_name,
|
||||
media_type: media.media_type,
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<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)}
|
||||
@@ -112,17 +122,41 @@ export const MediaListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteMedia}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as string[]);
|
||||
}}
|
||||
hideFooter
|
||||
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
|
||||
}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
@@ -35,10 +34,12 @@ import {
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import {
|
||||
AnimatedCircleButton,
|
||||
authInstance,
|
||||
languageStore,
|
||||
routeStore,
|
||||
selectedCityStore,
|
||||
stationsStore,
|
||||
} from "@shared";
|
||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||
|
||||
@@ -77,7 +78,6 @@ type LinkedItemsProps<T> = {
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
export const LinkedItems = <
|
||||
@@ -99,7 +99,7 @@ export const LinkedItems = <
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные станции
|
||||
Привязанные остановки
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
@@ -127,7 +127,6 @@ const LinkedItemsContentsInner = <
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -141,6 +140,9 @@ 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]);
|
||||
|
||||
@@ -149,11 +151,6 @@ 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) {
|
||||
@@ -165,7 +162,10 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const query = searchQuery.toLowerCase();
|
||||
const name = String(item.name || "").toLowerCase();
|
||||
const description = String(item.description || "").toLowerCase();
|
||||
return name.includes(query) || description.includes(query);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -182,6 +182,19 @@ 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;
|
||||
|
||||
@@ -195,7 +208,13 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
stations: reorderedItems.map((item) => ({ id: item.id })),
|
||||
stations: reorderedItems.map((item) => {
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating station order:", error);
|
||||
@@ -233,7 +252,7 @@ const LinkedItemsContentsInner = <
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all items:", error);
|
||||
setError("Failed to load available stations");
|
||||
setError(null);
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
@@ -242,14 +261,32 @@ const LinkedItemsContentsInner = <
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const selectedItem = allItems.find((item) => item.id === selectedItemId);
|
||||
const requestData = {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => ({ id: item.id })),
|
||||
linkedItems.map((item) => {
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
position,
|
||||
{ id: selectedItemId }
|
||||
(() => {
|
||||
if (!selectedItem) return { id: selectedItemId };
|
||||
const transfers = getStationTransfers(
|
||||
selectedItemId,
|
||||
selectedItem.transfers
|
||||
);
|
||||
return {
|
||||
...selectedItem,
|
||||
transfers: transfers || selectedItem.transfers,
|
||||
};
|
||||
})()
|
||||
),
|
||||
};
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
@@ -268,12 +305,20 @@ 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 },
|
||||
@@ -285,6 +330,13 @@ 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;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -311,10 +363,25 @@ const LinkedItemsContentsInner = <
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setError(null);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
|
||||
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 requestData = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => ({ id: item.id })),
|
||||
...linkedItems.map((item) => {
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
...selectedStations,
|
||||
],
|
||||
};
|
||||
@@ -330,6 +397,9 @@ const LinkedItemsContentsInner = <
|
||||
.catch((error) => {
|
||||
console.error("Error linking items:", error);
|
||||
setError("Failed to link stations");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLinkingBulk(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -399,7 +469,7 @@ const LinkedItemsContentsInner = <
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
@@ -407,9 +477,11 @@ const LinkedItemsContentsInner = <
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
disabled={detachingIds.has(item.id)}
|
||||
loading={detachingIds.has(item.id)}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
@@ -427,19 +499,13 @@ 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}
|
||||
@@ -468,6 +534,7 @@ const LinkedItemsContentsInner = <
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
placeholder="Введите название или описание остановки..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
@@ -475,16 +542,15 @@ const LinkedItemsContentsInner = <
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (!inputValue.trim()) return options;
|
||||
const query = inputValue.toLowerCase();
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
const name = String(option.name || "").toLowerCase();
|
||||
const description = String(
|
||||
option.description || ""
|
||||
).toLowerCase();
|
||||
return (
|
||||
name.includes(query) || description.includes(query)
|
||||
);
|
||||
});
|
||||
}}
|
||||
@@ -520,14 +586,15 @@ const LinkedItemsContentsInner = <
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
disabled={!selectedItemId || isLinkingSingle}
|
||||
loading={isLinkingSingle}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -587,14 +654,15 @@ const LinkedItemsContentsInner = <
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={handleBulkLink}
|
||||
disabled={selectedItems.size === 0}
|
||||
disabled={selectedItems.size === 0 || isLinkingBulk}
|
||||
loading={isLinkingBulk}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить выбранные ({selectedItems.size})
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -13,22 +12,30 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
} from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save, Plus, X } 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();
|
||||
@@ -44,19 +51,29 @@ export const RouteCreatePage = observer(() => {
|
||||
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(() => {
|
||||
@@ -150,6 +167,23 @@ 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);
|
||||
@@ -174,11 +208,6 @@ export const RouteCreatePage = observer(() => {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!governorAppeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
@@ -213,7 +242,9 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
|
||||
const carrier_id = Number(carrier);
|
||||
const governor_appeal = Number(governorAppeal);
|
||||
const governor_appeal = governorAppeal
|
||||
? Number(governorAppeal)
|
||||
: undefined;
|
||||
const rotate = turn ? Number(turn) : undefined;
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
@@ -236,10 +267,9 @@ export const RouteCreatePage = observer(() => {
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
|
||||
carrier_id,
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
governor_appeal,
|
||||
route_name: routeName,
|
||||
route_number: routeNumber.trim(),
|
||||
route_sys_number: govRouteNumber.trim(),
|
||||
route_name: routeName.trim(),
|
||||
route_direction,
|
||||
scale_min: scale_min !== null ? scale_min : 0,
|
||||
scale_max: scale_max !== null ? scale_max : 0,
|
||||
@@ -247,10 +277,15 @@ export const RouteCreatePage = observer(() => {
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
|
||||
icon: !isMediaIdEmpty(icon) ? icon : undefined,
|
||||
video_timer: videoTimer,
|
||||
};
|
||||
|
||||
if (governor_appeal !== undefined) {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(-1);
|
||||
@@ -267,7 +302,7 @@ export const RouteCreatePage = observer(() => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div 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"
|
||||
@@ -341,6 +376,7 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
mt: 1,
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
@@ -349,7 +385,6 @@ export const RouteCreatePage = observer(() => {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
@@ -382,6 +417,17 @@ 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)}
|
||||
@@ -392,16 +438,41 @@ 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={videoPreview}
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
setVideoPreview("");
|
||||
}}
|
||||
onDeleteVideoClick={() => setVideoPreview("")}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
@@ -420,7 +491,15 @@ export const RouteCreatePage = observer(() => {
|
||||
type="number"
|
||||
value={scaleMin}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
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);
|
||||
@@ -447,6 +526,10 @@ export const RouteCreatePage = observer(() => {
|
||||
value={scaleMax}
|
||||
required
|
||||
onChange={(e) => {
|
||||
if (Number(e.target.value) > 300) {
|
||||
e.target.value = "300";
|
||||
}
|
||||
|
||||
const value = e.target.value;
|
||||
setScaleMax(value);
|
||||
}}
|
||||
@@ -470,6 +553,18 @@ 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
|
||||
@@ -499,7 +594,7 @@ export const RouteCreatePage = observer(() => {
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
{effectiveVideoId && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
@@ -511,7 +606,7 @@ export const RouteCreatePage = observer(() => {
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: videoPreview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -537,6 +632,28 @@ export const RouteCreatePage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -13,23 +12,31 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { toast } from "react-toastify";
|
||||
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(() => {
|
||||
@@ -37,33 +44,73 @@ 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);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
carrierStore.getCarriers(language);
|
||||
stationsStore.getStations();
|
||||
articlesStore.getArticleList();
|
||||
await carrierStore.getCarriers(language);
|
||||
await stationsStore.getStations();
|
||||
await articlesStore.getArticleList();
|
||||
await mediaStore.getMedia();
|
||||
};
|
||||
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
|
||||
@@ -91,10 +138,6 @@ export const RouteEditPage = observer(() => {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.governor_appeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(coordinates);
|
||||
if (validationResult !== true) {
|
||||
@@ -233,8 +276,23 @@ export const RouteEditPage = observer(() => {
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<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">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -344,6 +402,7 @@ export const RouteEditPage = observer(() => {
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
mt: 1,
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
@@ -352,7 +411,6 @@ export const RouteEditPage = observer(() => {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
@@ -393,20 +451,29 @@ export const RouteEditPage = observer(() => {
|
||||
type="number"
|
||||
value={editRouteData.scale_min ?? ""}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value === "" ? null : parseFloat(e.target.value);
|
||||
let value = e.target.value === "" ? null : e.target.value;
|
||||
|
||||
if (value && Number(value) > 297) {
|
||||
value = "297";
|
||||
}
|
||||
|
||||
if (value && Number(value) < 10) {
|
||||
value = "10";
|
||||
}
|
||||
|
||||
routeStore.setEditRouteData({
|
||||
scale_min: value,
|
||||
scale_min: value ? Number(value) : null,
|
||||
});
|
||||
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||
if (
|
||||
value !== null &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
value > editRouteData.scale_max
|
||||
value &&
|
||||
Number(value) > (editRouteData.scale_max ?? 0)
|
||||
) {
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: value,
|
||||
scale_max: value ? Number(value) : null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -418,12 +485,17 @@ export const RouteEditPage = observer(() => {
|
||||
label="Масштаб (макс)"
|
||||
type="number"
|
||||
value={editRouteData.scale_max ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
scale_max:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
|
||||
if (Number(value) > 300) {
|
||||
value = "300";
|
||||
}
|
||||
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: value === "" ? null : parseFloat(value),
|
||||
});
|
||||
}}
|
||||
error={
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
@@ -474,6 +546,18 @@ 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 }}>
|
||||
Обращение к пассажирам
|
||||
@@ -491,6 +575,21 @@ 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)}
|
||||
@@ -501,9 +600,33 @@ 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={editRouteData.video_preview}
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
@@ -511,6 +634,8 @@ export const RouteEditPage = observer(() => {
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinkedItems
|
||||
@@ -524,7 +649,6 @@ export const RouteEditPage = observer(() => {
|
||||
onUpdate={() => {
|
||||
routeStore.getRoute(Number(id));
|
||||
}}
|
||||
routeDirection={editRouteData.route_direction}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-between">
|
||||
@@ -571,10 +695,10 @@ export const RouteEditPage = observer(() => {
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
{editRouteData.video_preview && (
|
||||
{effectiveVideoId && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -598,6 +722,38 @@ export const RouteEditPage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, routeStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -17,7 +17,18 @@ export const RouteListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
const canWriteRoutes = authStore.canWrite("routes");
|
||||
const canShowRoutePreview =
|
||||
authStore.canWrite("stations") &&
|
||||
authStore.canWrite("sights") &&
|
||||
authStore.canWrite("routes");
|
||||
const canShowActionsColumn = canWriteRoutes || canShowRoutePreview;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -82,6 +93,23 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_sys_number",
|
||||
headerName: "Номер трассы маршрута",
|
||||
description: "Уникальный номер трассы маршрута, в т.ч. используемый системой \"Говорящий город\"",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_direction",
|
||||
headerName: "Направление",
|
||||
@@ -100,23 +128,27 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...(canShowActionsColumn ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 250,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{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);
|
||||
@@ -125,19 +157,41 @@ export const RouteListPage = observer(() => {
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const rows = routes.data.map((route) => ({
|
||||
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_sys_number: route.route_sys_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
route_name: route.route_name,
|
||||
}));
|
||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,13 +200,13 @@ 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -161,17 +215,45 @@ export const RouteListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteRoutes}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
onRowSelectionModelChange={
|
||||
canWriteRoutes
|
||||
? (newSelection: any) => {
|
||||
if (Array.isArray(newSelection)) {
|
||||
const selectedIds = newSelection.map((id: string | number) =>
|
||||
Number(id)
|
||||
);
|
||||
setIds(selectedIds);
|
||||
} else if (
|
||||
newSelection &&
|
||||
typeof newSelection === "object" &&
|
||||
"ids" in newSelection
|
||||
) {
|
||||
const idsSet = newSelection.ids as Set<string | number>;
|
||||
const selectedIds = Array.from(idsSet).map(
|
||||
(id: string | number) => Number(id)
|
||||
);
|
||||
setIds(selectedIds);
|
||||
} else {
|
||||
setIds([]);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
|
||||
@@ -5,5 +5,5 @@ export const STATION_OUTLINE_WIDTH = 4;
|
||||
export const SIGHT_SIZE = 40;
|
||||
export const SCALE_FACTOR = 50;
|
||||
|
||||
export const BACKGROUND_COLOR = 0x111111;
|
||||
export const BACKGROUND_COLOR = 0x000;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Stack, Typography, Button } from "@mui/material";
|
||||
import { Box, 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 } from "@shared";
|
||||
import { authInstance, isMediaIdEmpty } from "@shared";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||
|
||||
export const LeftSidebar = observer(() => {
|
||||
type LeftSidebarProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||
const { routeData } = useMapData();
|
||||
@@ -35,43 +42,71 @@ export const LeftSidebar = observer(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
color: "#fff",
|
||||
backgroundColor: "#222",
|
||||
borderRadius: 10,
|
||||
height: 40,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "padding 0.3s ease",
|
||||
p: open ? 2 : 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<p>Назад</p>
|
||||
</button>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
spacing={3}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
maxWidth: 150,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
@@ -82,35 +117,33 @@ export const LeftSidebar = observer(() => {
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>{" "}
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
spacing={2}
|
||||
>
|
||||
<Button variant="outlined" color="warning" fullWidth>
|
||||
<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 variant="outlined" color="warning" fullWidth>
|
||||
</button>
|
||||
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||
Остановки
|
||||
</Button>
|
||||
</Stack>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
maxHeight={150}
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
flexGrow={1}
|
||||
>
|
||||
{carrierLogo && (
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
@@ -125,11 +158,15 @@ export const LeftSidebar = observer(() => {
|
||||
<Typography
|
||||
variant="h6"
|
||||
textAlign="center"
|
||||
mt="auto"
|
||||
sx={{ color: "#fff" }}
|
||||
sx={{ color: "#fff", marginTop: "auto" }}
|
||||
>
|
||||
#ВсемПоПути
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,11 +36,15 @@ 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,
|
||||
@@ -60,7 +64,11 @@ const MapDataContext = createContext<{
|
||||
setMapCenter: () => {},
|
||||
setStationOffset: () => {},
|
||||
setStationAlign: () => {},
|
||||
setStationIconSize: () => {},
|
||||
setSightCoordinates: () => {},
|
||||
setSightIconSize: () => {},
|
||||
setFontSize: () => {},
|
||||
setRouteIconSize: () => {},
|
||||
saveChanges: () => {},
|
||||
});
|
||||
|
||||
@@ -141,16 +149,16 @@ export const MapDataProvider = observer(
|
||||
}, [routeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData)
|
||||
if (originalRouteData) {
|
||||
setRouteData({ ...originalRouteData, ...routeChanges });
|
||||
if (originalSightData) setSightData(originalSightData);
|
||||
}, [
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
routeChanges,
|
||||
stationChanges,
|
||||
sightChanges,
|
||||
]);
|
||||
}
|
||||
}, [originalRouteData, routeChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalSightData) {
|
||||
setSightData(originalSightData);
|
||||
}
|
||||
}, [originalSightData]);
|
||||
|
||||
function setScaleRange(min: number, max: number) {
|
||||
setRouteChanges((prev) => {
|
||||
@@ -164,9 +172,57 @@ export const MapDataProvider = observer(
|
||||
});
|
||||
}
|
||||
|
||||
function setMapCenter(x: number, y: number) {
|
||||
function setFontSize(size: number) {
|
||||
const clamped = Math.max(1, Math.min(300, size));
|
||||
setRouteChanges((prev) => {
|
||||
return { ...prev, center_latitude: x, center_longitude: y };
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,12 +235,51 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +319,7 @@ 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: "",
|
||||
@@ -285,6 +381,7 @@ 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: "",
|
||||
@@ -315,11 +412,83 @@ 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
|
||||
@@ -345,6 +514,7 @@ export const MapDataProvider = observer(
|
||||
sight_id: sightId,
|
||||
latitude,
|
||||
longitude,
|
||||
icon_size: foundSight.icon_size,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -353,6 +523,49 @@ 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(
|
||||
@@ -374,7 +587,11 @@ export const MapDataProvider = observer(
|
||||
saveChanges,
|
||||
setStationOffset,
|
||||
setStationAlign,
|
||||
setStationIconSize,
|
||||
setSightCoordinates,
|
||||
setSightIconSize,
|
||||
setFontSize,
|
||||
setRouteIconSize,
|
||||
}),
|
||||
[
|
||||
originalRouteData,
|
||||
@@ -387,6 +604,10 @@ export const MapDataProvider = observer(
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
setStationIconSize,
|
||||
setSightIconSize,
|
||||
setFontSize,
|
||||
setRouteIconSize,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
|
||||
import {
|
||||
Button,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
Slider,
|
||||
CircularProgress,
|
||||
Box,
|
||||
} 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";
|
||||
|
||||
@@ -14,17 +21,10 @@ export function RightSidebar() {
|
||||
originalRouteData,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
setFontSize: updateFontSize,
|
||||
setRouteIconSize: updateRouteIconSize,
|
||||
} = useMapData();
|
||||
const {
|
||||
rotation,
|
||||
position,
|
||||
screenToLocal,
|
||||
screenCenter,
|
||||
rotateToAngle,
|
||||
setTransform,
|
||||
scale,
|
||||
setScaleAtCenter,
|
||||
} = useTransform();
|
||||
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
|
||||
|
||||
const [minScale, setMinScale] = useState<number>(1);
|
||||
const [maxScale, setMaxScale] = useState<number>(5);
|
||||
@@ -34,6 +34,9 @@ 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) {
|
||||
@@ -50,6 +53,8 @@ export function RightSidebar() {
|
||||
x: originalRouteData.center_latitude ?? 0,
|
||||
y: originalRouteData.center_longitude ?? 0,
|
||||
});
|
||||
setFontSize(originalRouteData.font_size ?? 100);
|
||||
setDefaultIconSize(originalRouteData.icon_size ?? 100);
|
||||
}
|
||||
}, [originalRouteData]);
|
||||
|
||||
@@ -61,7 +66,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]);
|
||||
|
||||
@@ -70,33 +75,55 @@ export function RightSidebar() {
|
||||
}, [rotationDegrees]);
|
||||
|
||||
useEffect(() => {
|
||||
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 });
|
||||
if (isUserEditing) {
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
position,
|
||||
screenCenter,
|
||||
screenToLocal,
|
||||
localToCoordinates,
|
||||
setLocalCenter,
|
||||
isUserEditing,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapCenter(localCenter.x, localCenter.y);
|
||||
}, [localCenter]);
|
||||
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]);
|
||||
|
||||
function setRotationFromDegrees(degrees: number) {
|
||||
rotateToAngle((degrees * Math.PI) / 180);
|
||||
}
|
||||
|
||||
function pan({ x, y }: { x: number; y: number }) {
|
||||
const coordinates = coordinatesToLocal(x, y);
|
||||
setTransform(coordinates.x, coordinates.y);
|
||||
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]);
|
||||
|
||||
if (!routeData) {
|
||||
return null;
|
||||
@@ -128,8 +155,12 @@ export function RightSidebar() {
|
||||
onChange={(e) => {
|
||||
let newMinScale = Number(e.target.value);
|
||||
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
if (newMinScale < 10) {
|
||||
newMinScale = 10;
|
||||
}
|
||||
|
||||
if (newMinScale > 300) {
|
||||
newMinScale = 297;
|
||||
}
|
||||
|
||||
setMinScale(newMinScale);
|
||||
@@ -137,6 +168,10 @@ export function RightSidebar() {
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
|
||||
if (newMaxScale > 300) {
|
||||
newMaxScale = 300;
|
||||
}
|
||||
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1);
|
||||
@@ -172,8 +207,12 @@ export function RightSidebar() {
|
||||
onChange={(e) => {
|
||||
let newMaxScale = Number(e.target.value);
|
||||
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
if (newMaxScale < 13) {
|
||||
newMaxScale = 13;
|
||||
}
|
||||
|
||||
if (newMaxScale > 300) {
|
||||
newMaxScale = 300;
|
||||
}
|
||||
|
||||
setMaxScale(newMaxScale);
|
||||
@@ -204,7 +243,7 @@ export function RightSidebar() {
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 3,
|
||||
max: 10,
|
||||
max: 300,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -268,6 +307,60 @@ 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="Поворот (в градусах)"
|
||||
@@ -309,10 +402,15 @@ export function RightSidebar() {
|
||||
value={Math.round(localCenter.x * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
||||
pan({ x: Number(e.target.value), y: localCenter.y });
|
||||
const newValue = Number(e.target.value);
|
||||
setLocalCenter((prev) => ({ ...prev, x: newValue }));
|
||||
if (!isNaN(newValue) && localCenter.y !== undefined) {
|
||||
setMapCenter(newValue, localCenter.y);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -328,15 +426,20 @@ export function RightSidebar() {
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, высота"
|
||||
label="Центр карты, долгота"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.y * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
||||
pan({ x: localCenter.x, y: Number(e.target.value) });
|
||||
const newValue = Number(e.target.value);
|
||||
setLocalCenter((prev) => ({ ...prev, y: newValue }));
|
||||
if (!isNaN(newValue) && localCenter.x !== undefined) {
|
||||
setMapCenter(localCenter.x, newValue);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
@@ -355,19 +458,51 @@ export function RightSidebar() {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
sx={{ mt: 2, position: "relative" }}
|
||||
disabled={isSaving}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Widgets() {
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Станция
|
||||
Остановка
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Widgets } from "./Widgets";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import { extend } from "@pixi/react";
|
||||
import {
|
||||
Container,
|
||||
Graphics,
|
||||
@@ -9,24 +9,18 @@ import {
|
||||
TilingSprite,
|
||||
Text,
|
||||
} from "pixi.js";
|
||||
import { Stack } from "@mui/material";
|
||||
import { Box, 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 CircularProgress from "@mui/material/CircularProgress";
|
||||
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||
import { CircularProgress } from "@mui/material";
|
||||
|
||||
extend({
|
||||
Container,
|
||||
@@ -42,7 +36,7 @@ const Loading = () => {
|
||||
|
||||
if (isRouteLoading || isStationLoading || isSightLoading) {
|
||||
return (
|
||||
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
|
||||
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
@@ -51,14 +45,33 @@ const Loading = () => {
|
||||
return null;
|
||||
};
|
||||
export const RoutePreview = () => {
|
||||
const { routeData, stationData, sightData } = useMapData();
|
||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
|
||||
return (
|
||||
<MapDataProvider>
|
||||
<TransformProvider>
|
||||
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
||||
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
|
||||
<Loading />
|
||||
<LeftSidebar />
|
||||
<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>
|
||||
<Stack direction="row" flex={1} position="relative" height="100%">
|
||||
<RouteMap />
|
||||
<Widgets />
|
||||
@@ -71,15 +84,8 @@ export const RoutePreview = () => {
|
||||
};
|
||||
|
||||
export const RouteMap = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const { setPosition, setTransform, screenCenter } = useTransform();
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
} = useMapData();
|
||||
const { routeData, stationData, sightData, originalRouteData } = useMapData();
|
||||
|
||||
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [isSetup, setIsSetup] = useState(false);
|
||||
@@ -165,8 +171,7 @@ export const RouteMap = observer(() => {
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||
<LanguageSwitcher />
|
||||
<Application resizeTo={parentRef} background="#fff" preference="webgl">
|
||||
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
|
||||
<InfiniteCanvas>
|
||||
<TravelPath points={points} />
|
||||
{stationData[language].map((obj, index) => (
|
||||
@@ -184,7 +189,8 @@ export const RouteMap = observer(() => {
|
||||
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||
})}
|
||||
</InfiniteCanvas>
|
||||
</Application>
|
||||
</Application> */}
|
||||
<WebGLRouteMapPrototype />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ 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][];
|
||||
@@ -31,6 +33,8 @@ export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
icon?: string;
|
||||
icon_size?: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -48,25 +52,33 @@ 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; // uuid
|
||||
thumbnail?: string | null; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
|
||||
203
src/pages/Route/route-preview/web-gl/LanguageSelector.tsx
Normal file
203
src/pages/Route/route-preview/web-gl/LanguageSelector.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
@@ -16,11 +15,21 @@ 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 { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import {
|
||||
AnimatedCircleButton,
|
||||
authInstance,
|
||||
languageStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
@@ -93,12 +102,26 @@ const LinkedStationsContentsInner = <
|
||||
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) => {
|
||||
@@ -110,6 +133,14 @@ const LinkedStationsContentsInner = <
|
||||
})
|
||||
.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);
|
||||
@@ -120,13 +151,24 @@ const LinkedStationsContentsInner = <
|
||||
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 = {
|
||||
station_id: selectedItemId,
|
||||
};
|
||||
const requestData = buildPayload([selectedItemId]);
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
@@ -140,15 +182,23 @@ const LinkedStationsContentsInner = <
|
||||
.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: { [`${childResource}_id`]: itemId },
|
||||
data: buildPayload([itemId]),
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
@@ -157,9 +207,125 @@ const LinkedStationsContentsInner = <
|
||||
.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);
|
||||
@@ -190,7 +356,7 @@ const LinkedStationsContentsInner = <
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all stations:", error);
|
||||
setError("Failed to load available stations");
|
||||
setError(null);
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
@@ -203,6 +369,16 @@ const LinkedStationsContentsInner = <
|
||||
<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>
|
||||
@@ -218,6 +394,15 @@ const LinkedStationsContentsInner = <
|
||||
<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)}>
|
||||
@@ -228,7 +413,7 @@ const LinkedStationsContentsInner = <
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
@@ -236,9 +421,11 @@ const LinkedStationsContentsInner = <
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
disabled={detachingIds.has(item.id)}
|
||||
loading={detachingIds.has(item.id)}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
@@ -248,6 +435,20 @@ const LinkedStationsContentsInner = <
|
||||
</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}>
|
||||
Остановки не найдены
|
||||
@@ -256,48 +457,145 @@ const LinkedStationsContentsInner = <
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановку</Typography>
|
||||
<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
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Выберите остановку" fullWidth />
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
placeholder="Введите название или описание остановки..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (!inputValue.trim()) return options;
|
||||
const query = inputValue.toLowerCase();
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
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}>
|
||||
{String(option.name)}
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
disabled={!selectedItemId || isLinkingSingle}
|
||||
loading={isLinkingSingle}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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";
|
||||
@@ -15,21 +17,32 @@ 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);
|
||||
await getCities(language);
|
||||
if (!authStore.me) {
|
||||
await authStore.getMeAction().catch(() => undefined);
|
||||
}
|
||||
if (authStore.canRead("cities")) {
|
||||
await cityStore.getCities(language);
|
||||
} else {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
await getSights();
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
@@ -38,7 +51,23 @@ export const SightListPage = observer(() => {
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Имя",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "short_name",
|
||||
headerName: "Сокращенное название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -57,33 +86,28 @@ 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">
|
||||
{params.value ? (
|
||||
cities[language].data.find((el) => el.id == params.value)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
{cityName ?? <Minus size={20} className="text-red-500" />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...(authStore.canWrite("sights") ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
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={() => navigate(`/sight/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -93,26 +117,39 @@ export const SightListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
const allowedCityIds = canReadCities
|
||||
? null
|
||||
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
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) || (sight.short_name ?? "").toLowerCase().includes(query))
|
||||
.map((sight: any) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
short_name: sight.short_name,
|
||||
city_id: sight.city_id,
|
||||
}));
|
||||
|
||||
const canWriteSights = authStore.canWrite("sights");
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
@@ -120,16 +157,16 @@ 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -138,17 +175,41 @@ export const SightListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteSights}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
onRowSelectionModelChange={
|
||||
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
|
||||
}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 } = snapshotStore;
|
||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -24,7 +25,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"
|
||||
@@ -42,13 +43,28 @@ export const SnapshotCreatePage = observer(() => {
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
toast.error("Ошибка при создании экспорта медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -56,7 +72,15 @@ 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>
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
|
||||
@@ -1,31 +1,76 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, snapshotStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
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",
|
||||
];
|
||||
|
||||
export const SnapshotListPage = observer(() => {
|
||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||
snapshotStore;
|
||||
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
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 getSnapshots();
|
||||
await Promise.all([getSnapshots(), getStorageInfo()]);
|
||||
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",
|
||||
@@ -37,16 +82,35 @@ 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,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div>
|
||||
{params.value != null ? `${params.value.toFixed(1)} ГБ` : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(canManageSnapshots
|
||||
? [
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 300,
|
||||
headerAlign: "center",
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -56,7 +120,6 @@ export const SnapshotListPage = observer(() => {
|
||||
>
|
||||
<DatabaseBackup size={20} className="text-blue-500" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -66,35 +129,157 @@ export const SnapshotListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const rows = snapshots.map((snapshot) => ({
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Снапшоты</h1>
|
||||
<CreateButton label="Создать снапшот" path="/snapshot/create" />
|
||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||
|
||||
{canCreateSnapshot && (
|
||||
<CreateButton
|
||||
label="Создать экспорт медиа"
|
||||
path="/snapshot/create"
|
||||
disabled={isLowStorage}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
@@ -16,11 +15,21 @@ 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 { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import {
|
||||
AnimatedCircleButton,
|
||||
authInstance,
|
||||
languageStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
@@ -93,12 +102,26 @@ 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) => {
|
||||
@@ -111,6 +134,11 @@ 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);
|
||||
@@ -121,13 +149,24 @@ 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 = {
|
||||
sight_id: selectedItemId,
|
||||
};
|
||||
const requestData = buildPayload([selectedItemId]);
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
@@ -141,15 +180,23 @@ 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: { [`${childResource}_id`]: itemId },
|
||||
data: buildPayload([itemId]),
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
@@ -158,9 +205,125 @@ 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);
|
||||
@@ -191,7 +354,7 @@ const LinkedSightsContentsInner = <
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all sights:", error);
|
||||
setError("Failed to load available sights");
|
||||
setError(null);
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
@@ -204,6 +367,16 @@ 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>
|
||||
@@ -219,6 +392,15 @@ 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)}>
|
||||
@@ -229,7 +411,7 @@ const LinkedSightsContentsInner = <
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
@@ -237,9 +419,11 @@ const LinkedSightsContentsInner = <
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
disabled={detachingIds.has(item.id)}
|
||||
loading={detachingIds.has(item.id)}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</AnimatedCircleButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
@@ -249,6 +433,20 @@ 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}>
|
||||
Достопримечательности не найдены
|
||||
@@ -258,14 +456,30 @@ const LinkedSightsContentsInner = <
|
||||
{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
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
@@ -275,7 +489,9 @@ const LinkedSightsContentsInner = <
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
@@ -297,14 +513,76 @@ const LinkedSightsContentsInner = <
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
<AnimatedCircleButton
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
disabled={!selectedItemId || isLinkingSingle}
|
||||
loading={isLinkingSingle}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</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>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
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 { ArrowLeft, Loader2, Save } 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 { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
} from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -32,9 +40,17 @@ export const StationCreatePage = observer(() => {
|
||||
createStation,
|
||||
setLanguageCreateStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const { getCities } = cityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
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);
|
||||
|
||||
@@ -57,7 +73,7 @@ export const StationCreatePage = observer(() => {
|
||||
navigate("/station");
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании станции");
|
||||
toast.error("Ошибка при создании остановки");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -90,14 +106,53 @@ export const StationCreatePage = observer(() => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
if (!authStore.me) {
|
||||
await authStore.getMeAction().catch(() => undefined);
|
||||
}
|
||||
if (authStore.canRead("cities")) {
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
return;
|
||||
}
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -108,7 +163,7 @@ export const StationCreatePage = observer(() => {
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<Box className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -136,23 +191,6 @@ 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="Описание"
|
||||
@@ -213,7 +251,7 @@ export const StationCreatePage = observer(() => {
|
||||
value={createStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
const selectedCity = availableCities.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setCreateCommonData({
|
||||
@@ -222,7 +260,7 @@ export const StationCreatePage = observer(() => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
{availableCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -230,6 +268,30 @@ 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"
|
||||
@@ -246,6 +308,28 @@ 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={{
|
||||
@@ -254,6 +338,6 @@ export const StationCreatePage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
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 { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore, languageStore, cityStore } from "@shared";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
authStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
DeleteModal,
|
||||
} 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 {
|
||||
@@ -30,8 +45,17 @@ export const StationEditPage = observer(() => {
|
||||
editStation,
|
||||
setLanguageEditStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const { getCities } = cityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
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);
|
||||
|
||||
@@ -57,7 +81,7 @@ export const StationEditPage = observer(() => {
|
||||
toast.success("Остановка успешно обновлена");
|
||||
} catch (error) {
|
||||
console.error("Error updating station:", error);
|
||||
toast.error("Ошибка при обновлении станции");
|
||||
toast.error("Ошибка при обновлении остановки");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -88,22 +112,93 @@ export const StationEditPage = observer(() => {
|
||||
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) return;
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
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");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
} else {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
await mediaStore.getMedia();
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<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">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -132,23 +227,6 @@ 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="Описание"
|
||||
@@ -209,7 +287,7 @@ export const StationEditPage = observer(() => {
|
||||
value={editStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
const selectedCity = availableCities.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setEditCommonData({
|
||||
@@ -218,7 +296,7 @@ export const StationEditPage = observer(() => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
{availableCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
@@ -226,6 +304,29 @@ 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)}
|
||||
@@ -249,6 +350,38 @@ 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
|
||||
@@ -258,6 +391,6 @@ export const StationEditPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
authStore,
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
SearchInput,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { Pencil, Trash2, Minus, Route } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
CreateButton,
|
||||
DeleteModal,
|
||||
LanguageSwitcher,
|
||||
EditStationTransfersModal,
|
||||
} from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const StationListPage = observer(() => {
|
||||
@@ -18,15 +24,24 @@ 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);
|
||||
};
|
||||
@@ -51,8 +66,8 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "system_name",
|
||||
headerName: "Системное название",
|
||||
field: "description",
|
||||
headerName: "Описание",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -66,42 +81,33 @@ 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: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{canWriteStations && (
|
||||
<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" />
|
||||
)}
|
||||
{canWriteStations && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedStationId(params.row.id);
|
||||
setIsTransfersModalOpen(true);
|
||||
}}
|
||||
title="Редактировать пересадки"
|
||||
>
|
||||
<Route size={20} className="text-purple-500" />
|
||||
</button>
|
||||
)}
|
||||
{canWriteStations && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -110,29 +116,30 @@ export const StationListPage = observer(() => {
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const rows = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
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,
|
||||
system_name: station.system_name,
|
||||
direction: station.direction,
|
||||
description: station.description,
|
||||
}));
|
||||
}, [stationLists[language].data, selectedCityStore.selectedCityId, searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,14 +147,14 @@ export const StationListPage = observer(() => {
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Станции</h1>
|
||||
<CreateButton label="Создать остановки" path="/station/create" />
|
||||
<h1 className="text-2xl">Остановки</h1>
|
||||
{canWriteStations && (
|
||||
<CreateButton label="Создать остановку" path="/station/create" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -156,22 +163,67 @@ export const StationListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
onRowDoubleClick={(params) => canWriteStations && navigate(`/station/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteStations}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
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
|
||||
}
|
||||
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>
|
||||
),
|
||||
}}
|
||||
@@ -205,6 +257,15 @@ export const StationListPage = observer(() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditStationTransfersModal
|
||||
open={isTransfersModalOpen}
|
||||
onClose={() => {
|
||||
setIsTransfersModalOpen(false);
|
||||
setSelectedStationId(null);
|
||||
}}
|
||||
stationId={selectedStationId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import { Paper, Box } from "@mui/material";
|
||||
import { languageStore, stationsStore, LoadingSpinner } from "@shared";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
@@ -12,15 +12,38 @@ 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);
|
||||
}
|
||||
})();
|
||||
}, [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 />
|
||||
@@ -44,21 +67,6 @@ 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>
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} 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 { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
userStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard } from "@widgets";
|
||||
|
||||
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 {
|
||||
@@ -31,6 +43,29 @@ 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">
|
||||
@@ -54,7 +89,8 @@ export const UserCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -69,7 +105,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
e.target.value,
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -84,27 +121,39 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
e.target.value,
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col items-start">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={createUserData.is_admin || false}
|
||||
onChange={(e) => {
|
||||
<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 || "",
|
||||
e.target.checked
|
||||
createUserData.is_admin || false,
|
||||
""
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Администратор"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +173,28 @@ 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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,68 +4,239 @@ 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, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore, languageStore } from "@shared";
|
||||
import {
|
||||
userStore,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
authStore,
|
||||
cityStore,
|
||||
MultiSelect,
|
||||
type User,
|
||||
type UserCity,
|
||||
} 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 } = userStore;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
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 () => {
|
||||
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));
|
||||
toast.success("Пользователь успешно обновлен");
|
||||
|
||||
await userStore.addUserCityAction({
|
||||
id: Number(id),
|
||||
city_ids: localCityIds,
|
||||
});
|
||||
|
||||
toast.success("Пользователь успешно обновлён");
|
||||
navigate("/user");
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Ошибка при обновлении пользователя");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getUser(Number(id));
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditUserData(
|
||||
data?.name || "",
|
||||
data?.email || "",
|
||||
data?.password || "",
|
||||
data?.is_admin || false
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false,
|
||||
media.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>
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-start">
|
||||
{/* ── Основные данные ── */}
|
||||
<section className="flex flex-col gap-6">
|
||||
<Typography variant="h6">Основные данные</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Имя"
|
||||
@@ -76,7 +247,8 @@ export const UserEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -90,56 +262,265 @@ export const UserEditPage = observer(() => {
|
||||
editUserData.name || "",
|
||||
e.target.value,
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Пароль"
|
||||
placeholder="Оставить пустым, чтобы не менять"
|
||||
value={editUserData.password || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
e.target.value,
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<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={editUserData.is_admin || false}
|
||||
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"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Полный доступ (admin)"
|
||||
/>
|
||||
|
||||
<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 || "",
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
editUserData.is_admin || false,
|
||||
"",
|
||||
);
|
||||
setIsDeleteIconModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteIconModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
}
|
||||
label="Администратор"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { userStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, userStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -16,6 +16,12 @@ 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 () => {
|
||||
@@ -77,25 +83,17 @@ export const UserListPage = observer(() => {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
...(canWriteUsers ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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 onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -106,30 +104,38 @@ export const UserListPage = observer(() => {
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const rows = users.data?.map((user) => ({
|
||||
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,
|
||||
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
|
||||
name: user.name,
|
||||
}));
|
||||
}, [users.data, searchQuery]);
|
||||
|
||||
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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
{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)}
|
||||
@@ -138,17 +144,43 @@ export const UserListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteUsers}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
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
|
||||
}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
|
||||
@@ -36,11 +37,12 @@ export const VehicleCreatePage = observer(() => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await vehicleStore.createVehicle(
|
||||
Number(tailNumber),
|
||||
tailNumber,
|
||||
Number(type),
|
||||
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||
?.full_name as string,
|
||||
carrierId!
|
||||
carrierId!,
|
||||
model || undefined,
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} catch (error) {
|
||||
@@ -103,6 +105,14 @@ 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"
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
languageStore,
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -31,6 +35,8 @@ export const VehicleEditPage = observer(() => {
|
||||
} = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
@@ -38,31 +44,61 @@ export const VehicleEditPage = observer(() => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const fetchAndSetVehicleData = async () => {
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
carrier: vehicle[Number(id)]?.vehicle.carrier,
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||
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();
|
||||
}, [id, language]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editVehicle(Number(id), editVehicleData);
|
||||
toast.success("Транспортное средство успешно обновлено");
|
||||
navigate("/vehicle");
|
||||
navigate("/devices");
|
||||
} 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">
|
||||
@@ -84,7 +120,7 @@ export const VehicleEditPage = observer(() => {
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
tail_number: Number(e.target.value),
|
||||
tail_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -128,6 +164,35 @@ 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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -18,7 +18,13 @@ 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 () => {
|
||||
@@ -100,19 +106,22 @@ export const VehicleListPage = observer(() => {
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
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}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -121,13 +130,23 @@ export const VehicleListPage = observer(() => {
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = vehicles.data?.map((vehicle) => ({
|
||||
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,
|
||||
@@ -136,22 +155,24 @@ export const VehicleListPage = observer(() => {
|
||||
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||
)?.city,
|
||||
}));
|
||||
}, [vehicles.data, carriers[language].data, searchQuery]);
|
||||
|
||||
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>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<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)}
|
||||
@@ -160,17 +181,41 @@ export const VehicleListPage = observer(() => {
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
checkboxSelection={canWriteVehicles}
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
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
|
||||
}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -35,3 +35,4 @@ const languageInstance = (language: Language) => {
|
||||
};
|
||||
|
||||
export { authInstance, languageInstance };
|
||||
export { mobxFetch } from "./mobxFetch";
|
||||
|
||||
183
src/shared/api/mobxFetch/index.ts
Normal file
183
src/shared/api/mobxFetch/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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;
|
||||
}
|
||||
@@ -23,12 +23,63 @@ interface NavigationItem {
|
||||
label: string;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
requiredRoles?: string[];
|
||||
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[];
|
||||
@@ -36,10 +87,10 @@ export const NAVIGATION_ITEMS: {
|
||||
primary: [
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
label: "Экспорт",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
requiredRoles: ["snapshot_rw", "snapshot_create"],
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -52,14 +103,14 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
requiredRoles: ["devices_ro", "devices_rw"],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
requiredRoles: ["users_ro", "users_rw"],
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
@@ -71,18 +122,21 @@ export const NAVIGATION_ITEMS: {
|
||||
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"],
|
||||
},
|
||||
|
||||
{
|
||||
@@ -90,14 +144,14 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
requiredRoles: ["countries_ro", "countries_rw"],
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
requiredRoles: ["cities_ro", "cities_rw"],
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
@@ -105,7 +159,7 @@ export const NAVIGATION_ITEMS: {
|
||||
// @ts-ignore
|
||||
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
requiredRoles: ["carriers_ro", "carriers_rw"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -123,7 +177,31 @@ 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: 1 },
|
||||
{ label: "Автобус", value: 3 },
|
||||
{ label: "Троллейбус", value: 2 },
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Электробус", value: 4 },
|
||||
{ label: "Электричка", value: 5 },
|
||||
{ label: "Вагон метро", value: 6 },
|
||||
{ label: "Вагон ЖД", value: 7 },
|
||||
];
|
||||
|
||||
export const VEHICLE_MODELS = [
|
||||
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
|
||||
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
|
||||
] as const;
|
||||
|
||||
@@ -33,6 +33,7 @@ export const MEDIA_TYPE_VALUES = {
|
||||
video: 2,
|
||||
icon: 3,
|
||||
thumbnail: 3,
|
||||
alt_icon: 3,
|
||||
watermark_lu: 4,
|
||||
watermark_rd: 4,
|
||||
panorama: 5,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
export * from "./permissions";
|
||||
|
||||
export const generateDefaultMediaName = (
|
||||
objectName: string,
|
||||
@@ -33,3 +34,12 @@ export const generateDefaultMediaName = (
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
18
src/shared/lib/permissions/index.ts
Normal file
18
src/shared/lib/permissions/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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),
|
||||
});
|
||||
@@ -39,6 +39,8 @@ interface UploadMediaDialogProps {
|
||||
afterUploadSight?: (id: string) => void;
|
||||
hardcodeType?:
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
@@ -51,7 +53,9 @@ interface UploadMediaDialogProps {
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
| "station"
|
||||
| "route"
|
||||
| "user";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File;
|
||||
|
||||
25
src/shared/store/AuthStore/api.ts
Normal file
25
src/shared/store/AuthStore/api.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 ?? []),
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,13 @@
|
||||
import { API_URL, decodeJWT } from "@shared";
|
||||
import { API_URL, decodeJWT, mobxFetch } from "@shared";
|
||||
import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions";
|
||||
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: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
user: Pick<User, "id" | "name" | "email" | "is_admin" | "cities">;
|
||||
};
|
||||
|
||||
class AuthStore {
|
||||
@@ -48,7 +46,7 @@ class AuthStore {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
@@ -89,6 +87,76 @@ 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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
authInstance,
|
||||
authStore,
|
||||
cityStore,
|
||||
languageStore,
|
||||
languageInstance,
|
||||
@@ -145,19 +146,58 @@ 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 =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.createCarrierData.city_id
|
||||
)?.name || "";
|
||||
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
|
||||
|
||||
const payload = {
|
||||
full_name: this.createCarrierData[language].full_name,
|
||||
short_name: this.createCarrierData[language].short_name,
|
||||
full_name: (this.createCarrierData[language].full_name || "").trim(),
|
||||
short_name: (this.createCarrierData[language].short_name || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
slogan: this.createCarrierData[language].slogan,
|
||||
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
@@ -172,15 +212,19 @@ class CarrierStore {
|
||||
});
|
||||
|
||||
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,
|
||||
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
|
||||
// @ts-ignore
|
||||
short_name: this.createCarrierData[lang as any].short_name as string,
|
||||
city: cityName,
|
||||
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
|
||||
city: cityNameForLang || cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
// @ts-ignore
|
||||
slogan: this.createCarrierData[lang as any].slogan as string,
|
||||
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
@@ -273,15 +317,13 @@ 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
|
||||
|
||||
@@ -171,7 +171,7 @@ class CityStore {
|
||||
try {
|
||||
// Create city in primary language
|
||||
const cityPayload = {
|
||||
name,
|
||||
name: name.trim(),
|
||||
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 || "",
|
||||
name: (secondaryName || "").trim(),
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
...(arms ? { arms } : {}),
|
||||
@@ -285,7 +285,7 @@ class CityStore {
|
||||
);
|
||||
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
name: (name || "").trim(),
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
|
||||
@@ -136,7 +136,7 @@ class CountryStore {
|
||||
if (code && this.createCountryData[language].name) {
|
||||
await languageInstance(language as Language).post("/country", {
|
||||
code: code,
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
@@ -156,7 +156,7 @@ class CountryStore {
|
||||
await languageInstance(secondaryLanguage as Language).patch(
|
||||
`/country/${code}`,
|
||||
{
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class CountryStore {
|
||||
|
||||
if (name) {
|
||||
await languageInstance(language as Language).patch(`/country/${code}`, {
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ type MediaItem = {
|
||||
|
||||
type SightLanguageInfo = {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
left: {
|
||||
heading: string;
|
||||
@@ -30,7 +31,10 @@ 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;
|
||||
@@ -47,7 +51,10 @@ 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,
|
||||
@@ -55,18 +62,21 @@ const initialSightState: SightBaseInfo = {
|
||||
video_preview: null,
|
||||
ru: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -486,9 +496,13 @@ class CreateSightStore {
|
||||
city: this.sight.city,
|
||||
latitude: this.sight.latitude,
|
||||
longitude: this.sight.longitude,
|
||||
name: this.sight[primaryLanguage].name,
|
||||
is_default_icon: this.sight.is_default_icon,
|
||||
name: (this.sight[primaryLanguage].name || "").trim(),
|
||||
short_name: (this.sight[primaryLanguage].short_name || "").trim(),
|
||||
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,
|
||||
@@ -511,9 +525,13 @@ class CreateSightStore {
|
||||
city: this.sight.city,
|
||||
latitude: this.sight.latitude,
|
||||
longitude: this.sight.longitude,
|
||||
name: this.sight[lang].name,
|
||||
is_default_icon: this.sight.is_default_icon,
|
||||
name: (this.sight[lang].name || "").trim(),
|
||||
short_name: (this.sight[lang].short_name || "").trim(),
|
||||
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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { makeAutoObservable, runInAction } from "mobx";
|
||||
export type SightLanguageInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
left: {
|
||||
heading: string;
|
||||
@@ -30,12 +31,16 @@ 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 = {
|
||||
@@ -51,7 +56,10 @@ 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,
|
||||
@@ -61,6 +69,7 @@ class EditSightStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -68,6 +77,7 @@ class EditSightStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -75,6 +85,7 @@ class EditSightStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -86,7 +97,11 @@ 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;
|
||||
|
||||
@@ -108,6 +123,9 @@ class EditSightStore {
|
||||
this.hasLoadedCommon = true;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
updateLeftInfo = (language: Language, heading: string, body: string) => {
|
||||
@@ -168,6 +186,8 @@ class EditSightStore {
|
||||
|
||||
clearSightInfo = () => {
|
||||
this.needLeaveAgree = false;
|
||||
this.hasLoadedCommon = false;
|
||||
this.isLoading = false;
|
||||
this.sight = {
|
||||
common: {
|
||||
id: 0,
|
||||
@@ -175,7 +195,10 @@ 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,
|
||||
@@ -185,6 +208,7 @@ class EditSightStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -193,6 +217,7 @@ class EditSightStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -201,6 +226,7 @@ class EditSightStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -281,9 +307,14 @@ class EditSightStore {
|
||||
...this.sight.common,
|
||||
translations: {
|
||||
name: {
|
||||
ru: this.sight.ru.name,
|
||||
en: this.sight.en.name,
|
||||
zh: this.sight.zh.name,
|
||||
ru: (this.sight.ru.name || "").trim(),
|
||||
en: (this.sight.en.name || "").trim(),
|
||||
zh: (this.sight.zh.name || "").trim(),
|
||||
},
|
||||
short_name: {
|
||||
ru: (this.sight.ru.short_name || "").trim(),
|
||||
en: (this.sight.en.short_name || "").trim(),
|
||||
zh: (this.sight.zh.short_name || "").trim(),
|
||||
},
|
||||
address: {
|
||||
ru: this.sight.ru.address,
|
||||
@@ -479,18 +510,19 @@ class EditSightStore {
|
||||
formData.append("media_name", media_name);
|
||||
}
|
||||
formData.append("type", type.toString());
|
||||
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: {
|
||||
|
||||
@@ -6,10 +6,24 @@ 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
} from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
@@ -9,6 +14,7 @@ export type Route = {
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
@@ -17,6 +23,7 @@ export type Route = {
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
video_preview: string;
|
||||
video_timer: number;
|
||||
};
|
||||
|
||||
class RouteStore {
|
||||
@@ -89,11 +96,41 @@ class RouteStore {
|
||||
};
|
||||
|
||||
saveRouteStations = async (routeId: number, stationId: number) => {
|
||||
await authInstance.patch(`/route/${routeId}/station`, {
|
||||
...this.routeStations[routeId]?.find(
|
||||
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 = {
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -105,6 +142,7 @@ class RouteStore {
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
rotate: 0,
|
||||
route_direction: false,
|
||||
@@ -113,6 +151,7 @@ class RouteStore {
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
video_preview: "" as string | undefined,
|
||||
video_timer: 60,
|
||||
};
|
||||
|
||||
setEditRouteData = (data: any) => {
|
||||
@@ -120,14 +159,30 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
if (!this.editRouteData.video_preview) {
|
||||
if (
|
||||
!this.editRouteData.video_preview ||
|
||||
isMediaIdEmpty(this.editRouteData.video_preview)
|
||||
) {
|
||||
delete this.editRouteData.video_preview;
|
||||
}
|
||||
const response = await authInstance.patch(`/route/${id}`, {
|
||||
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
|
||||
delete (this.editRouteData as any).icon;
|
||||
}
|
||||
const dataToSend: any = {
|
||||
...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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Language = "ru" | "en" | "zh";
|
||||
export type MultilingualContent = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@@ -18,12 +19,16 @@ export type MultilingualContent = {
|
||||
export type Sight = {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string | null;
|
||||
city_id: number;
|
||||
city: string;
|
||||
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;
|
||||
@@ -34,6 +39,7 @@ export type Sight = {
|
||||
export type CreateSight = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@@ -42,9 +48,9 @@ class SightsStore {
|
||||
sights: Sight[] = [];
|
||||
sight: Sight | null = null;
|
||||
createSight: CreateSight = {
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
ru: { name: "", short_name: "", address: "" },
|
||||
en: { name: "", short_name: "", address: "" },
|
||||
zh: { name: "", short_name: "", address: "" },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -64,6 +70,7 @@ class SightsStore {
|
||||
) => {
|
||||
const response = await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
short_name: this.createSight[languageStore.language].short_name,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -84,6 +91,7 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[0] as Language].name,
|
||||
short_name: this.createSight[anotherLanguages[0] as Language].short_name,
|
||||
address: this.createSight[anotherLanguages[0] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -95,6 +103,7 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[1] as Language].name,
|
||||
short_name: this.createSight[anotherLanguages[1] as Language].short_name,
|
||||
address: this.createSight[anotherLanguages[1] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -104,9 +113,9 @@ class SightsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.createSight = {
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
ru: { name: "", short_name: "", address: "" },
|
||||
en: { name: "", short_name: "", address: "" },
|
||||
zh: { name: "", short_name: "", address: "" },
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -153,14 +162,17 @@ class SightsStore {
|
||||
this.createSight = {
|
||||
ru: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
};
|
||||
@@ -174,7 +186,10 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import {
|
||||
articlesStore,
|
||||
@@ -23,11 +23,27 @@ 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);
|
||||
@@ -88,7 +104,10 @@ 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,
|
||||
@@ -125,7 +144,10 @@ 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,
|
||||
@@ -135,6 +157,7 @@ class SnapshotStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -142,6 +165,7 @@ class SnapshotStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -149,6 +173,7 @@ class SnapshotStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -244,10 +269,17 @@ 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((snapshot) => snapshot.ID !== id);
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -266,7 +298,31 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
createSnapshot = async (name: string) => {
|
||||
await authInstance.post(`/snapshots`, { name });
|
||||
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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { routeStore } from "../RouteStore";
|
||||
|
||||
type Language = "ru" | "en" | "zh";
|
||||
|
||||
@@ -12,7 +13,6 @@ type StationLanguageData = {
|
||||
|
||||
type StationCommonData = {
|
||||
city_id: number;
|
||||
direction: boolean;
|
||||
description: string;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
@@ -43,7 +43,6 @@ type Station = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
description: string;
|
||||
direction: boolean;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -122,7 +121,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -168,7 +166,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -251,7 +248,6 @@ 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,
|
||||
@@ -276,7 +272,6 @@ 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,
|
||||
@@ -292,10 +287,10 @@ class StationsStore {
|
||||
const response = await languageInstance(language).patch(
|
||||
`/station/${id}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
@@ -404,7 +399,6 @@ 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,
|
||||
@@ -421,10 +415,10 @@ class StationsStore {
|
||||
const { name, address } = this.createStationData[language];
|
||||
const description = this.createStationData.common.description;
|
||||
const response = await languageInstance(language).post("/station", {
|
||||
name: name || "",
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
});
|
||||
|
||||
@@ -442,10 +436,10 @@ class StationsStore {
|
||||
const response = await languageInstance(lang).patch(
|
||||
`/station/${stationId}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
@@ -478,7 +472,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
description: "",
|
||||
@@ -525,7 +518,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -546,6 +538,97 @@ 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();
|
||||
|
||||
18
src/shared/store/TestingModeStore/api.ts
Normal file
18
src/shared/store/TestingModeStore/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
};
|
||||
62
src/shared/store/TestingModeStore/index.ts
Normal file
62
src/shared/store/TestingModeStore/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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 };
|
||||
14
src/shared/store/UserStore/api.ts
Normal file
14
src/shared/store/UserStore/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, mobxFetch } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { addUserCityApi } from "./api";
|
||||
|
||||
export type UserCity = {
|
||||
city_id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
@@ -7,6 +13,9 @@ export type User = {
|
||||
is_admin: boolean;
|
||||
name: string;
|
||||
password?: string;
|
||||
icon?: string;
|
||||
roles?: string[];
|
||||
cities?: UserCity[];
|
||||
};
|
||||
|
||||
class UserStore {
|
||||
@@ -57,15 +66,25 @@ 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
|
||||
is_admin: boolean,
|
||||
icon?: string,
|
||||
) => {
|
||||
this.createUserData = { name, email, password, is_admin };
|
||||
this.createUserData = {
|
||||
...this.createUserData,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
createUser = async () => {
|
||||
@@ -73,7 +92,19 @@ class UserStore {
|
||||
if (this.users.data.length > 0) {
|
||||
id = this.users.data[this.users.data.length - 1].id + 1;
|
||||
}
|
||||
const response = await authInstance.post("/user", this.createUserData);
|
||||
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);
|
||||
|
||||
runInAction(() => {
|
||||
this.users.data.push({
|
||||
@@ -88,27 +119,75 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
roles: [],
|
||||
};
|
||||
|
||||
setEditUserData = (
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
is_admin: boolean
|
||||
is_admin: boolean,
|
||||
icon?: string,
|
||||
) => {
|
||||
this.editUserData = { name, email, password, is_admin };
|
||||
this.editUserData = {
|
||||
...this.editUserData,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
setEditUserRoles = (roles: string[]) => {
|
||||
this.editUserData = { ...this.editUserData, roles };
|
||||
};
|
||||
|
||||
editUser = async (id: number) => {
|
||||
const response = await authInstance.patch(`/user/${id}`, this.editUserData);
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
13
src/shared/store/VehicleStore/api.ts
Normal file
13
src/shared/store/VehicleStore/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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 : [];
|
||||
};
|
||||
@@ -1,24 +1,9 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { getVehicleSessionsApi } from "./api";
|
||||
import { 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;
|
||||
};
|
||||
};
|
||||
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||
|
||||
class VehicleStore {
|
||||
vehicles: {
|
||||
@@ -29,16 +14,83 @@ 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 = response.data;
|
||||
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
|
||||
this.vehicles.loaded = true;
|
||||
});
|
||||
};
|
||||
@@ -48,63 +100,69 @@ 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] = response.data;
|
||||
this.vehicle[id] = normalizedVehicle;
|
||||
});
|
||||
};
|
||||
|
||||
createVehicle = async (
|
||||
tailNumber: number,
|
||||
tailNumber: string,
|
||||
type: number,
|
||||
carrier: string,
|
||||
carrierId: number
|
||||
carrierId: number,
|
||||
model?: string,
|
||||
) => {
|
||||
const response = await languageInstance("ru").post("/vehicle", {
|
||||
tail_number: tailNumber,
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber.trim(),
|
||||
type,
|
||||
carrier,
|
||||
carrier: carrier.trim(),
|
||||
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({
|
||||
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,
|
||||
},
|
||||
});
|
||||
this.vehicles.data.push(normalizedVehicle);
|
||||
if (normalizedVehicle.vehicle?.id != null) {
|
||||
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
editVehicleData: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
} = {
|
||||
tail_number: 0,
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -115,36 +173,91 @@ class VehicleStore {
|
||||
editVehicle = async (
|
||||
id: number,
|
||||
data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
}
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
},
|
||||
) => {
|
||||
const response = await languageInstance("ru").patch(`/vehicle/${id}`, {
|
||||
tail_number: data.tail_number,
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: data.tail_number.trim(),
|
||||
type: data.type,
|
||||
carrier: data.carrier,
|
||||
carrier: data.carrier.trim(),
|
||||
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.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
|
||||
);
|
||||
this.mergeVehicleInCaches({
|
||||
...updatedVehiclePayload,
|
||||
id,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
33
src/shared/store/VehicleStore/types.ts
Normal file
33
src/shared/store/VehicleStore/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
};
|
||||
@@ -16,3 +16,4 @@ export * from "./CarrierStore";
|
||||
export * from "./StationsStore";
|
||||
export * from "./MenuStore";
|
||||
export * from "./SelectedCityStore";
|
||||
export * from "./TestingModeStore";
|
||||
|
||||
171
src/shared/ui/AnimatedCircleButton.tsx
Normal file
171
src/shared/ui/AnimatedCircleButton.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Modal as MuiModal, Typography, Box } from "@mui/material";
|
||||
import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const style = {
|
||||
@@ -19,7 +20,7 @@ const style = {
|
||||
borderRadius: 2,
|
||||
};
|
||||
|
||||
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
|
||||
return (
|
||||
<MuiModal
|
||||
open={open}
|
||||
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Box sx={{ ...style, ...sx }}>
|
||||
{title && (
|
||||
<Typography
|
||||
id="modal-modal-title"
|
||||
|
||||
95
src/shared/ui/MultiSelect/index.tsx
Normal file
95
src/shared/ui/MultiSelect/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
src/shared/ui/SearchInput/index.tsx
Normal file
37
src/shared/ui/SearchInput/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,7 @@ 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
1
src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -8,16 +8,40 @@ import {
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { cityStore, selectedCityStore } from "@shared";
|
||||
import { authStore, cityStore, selectedCityStore, type City } 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(() => {
|
||||
getCities("ru");
|
||||
}, []);
|
||||
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;
|
||||
|
||||
const handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||
const cityId = event.target.value;
|
||||
@@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
|
||||
const city = currentCities.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" />
|
||||
@@ -51,16 +73,13 @@ 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>
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useNavigate } from "react-router-dom";
|
||||
interface CreateButtonProps {
|
||||
label: string;
|
||||
path: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CreateButton = ({ label, path }: CreateButtonProps) => {
|
||||
export const CreateButton = ({ label, path, disabled }: CreateButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -18,6 +19,7 @@ export const CreateButton = ({ label, path }: CreateButtonProps) => {
|
||||
navigate(path);
|
||||
}}
|
||||
startIcon={<Plus size={20} />}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
180
src/widgets/DevicesTable/VehicleSessionsModal.tsx
Normal file
180
src/widgets/DevicesTable/VehicleSessionsModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
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
@@ -5,7 +5,13 @@ import { editSightStore } from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
interface ImageUploadCardProps {
|
||||
title: string;
|
||||
imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image";
|
||||
imageKey?:
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image";
|
||||
imageUrl: string | null | undefined;
|
||||
onImageClick: () => void;
|
||||
onDeleteImageClick: () => void;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
|
||||
import { Drawer } from "./ui/Drawer";
|
||||
import { DrawerHeader } from "./ui/DrawerHeader";
|
||||
import { NavigationList } from "@features";
|
||||
import { authStore, userStore, menuStore } from "@shared";
|
||||
import { authStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { Typography } from "@mui/material";
|
||||
@@ -27,13 +27,10 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
setIsMenuOpen(open);
|
||||
}, [open]);
|
||||
|
||||
const { getUsers, users } = userStore;
|
||||
const { getMeAction, me } = authStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
await getUsers();
|
||||
};
|
||||
fetchUsers();
|
||||
getMeAction();
|
||||
}, []);
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
@@ -67,18 +64,14 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon);
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className=" text-white">
|
||||
{
|
||||
users?.data?.find(
|
||||
// @ts-ignore
|
||||
(user) => user.id === authStore.payload?.user_id
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-white">{me?.name}</p>
|
||||
<div
|
||||
className="text-center text-xs"
|
||||
style={{
|
||||
@@ -88,19 +81,28 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
padding: "2px 10px",
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{authStore.payload?.is_admin
|
||||
{me?.roles?.includes("admin")
|
||||
? "Администратор"
|
||||
: "Режим пользователя"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${me?.icon}/download?token=${token}`}
|
||||
alt="Аватар"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="text-white" size={20} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
|
||||
<User />
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer variant="permanent" open={open}>
|
||||
@@ -138,6 +140,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
|
||||
<div className="mt-auto flex justify-center items-center pb-5 text-sm text-gray-300">
|
||||
v.{__APP_VERSION__}
|
||||
</div>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
|
||||
@@ -10,23 +10,25 @@ import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaArea = observer(
|
||||
({
|
||||
articleId,
|
||||
mediaIds,
|
||||
deleteMedia,
|
||||
onFilesDrop, // 👈 Проп для обработки загруженных файлов
|
||||
setSelectMediaDialogOpen,
|
||||
}: {
|
||||
interface MediaAreaProps {
|
||||
articleId: number;
|
||||
mediaIds: { id: string; media_type: number; filename: string }[];
|
||||
deleteMedia: (id: number, media_id: string) => void;
|
||||
onFilesDrop?: (files: File[]) => void;
|
||||
setSelectMediaDialogOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
}
|
||||
|
||||
export const MediaArea = observer(
|
||||
({
|
||||
articleId,
|
||||
mediaIds,
|
||||
deleteMedia,
|
||||
onFilesDrop,
|
||||
setSelectMediaDialogOpen,
|
||||
}: MediaAreaProps) => {
|
||||
const [mediaModal, setMediaModal] = useState<boolean>(false);
|
||||
const [mediaId, setMediaId] = useState<string>("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMediaModal = (mediaId: string) => {
|
||||
@@ -34,13 +36,11 @@ export const MediaArea = observer(
|
||||
setMediaId(mediaId);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const processFiles = (files: File[]) => {
|
||||
if (!files.length || !onFilesDrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length && onFilesDrop) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -50,7 +50,15 @@ export const MediaArea = observer(
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -68,19 +76,11 @@ export const MediaArea = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length && onFilesDrop) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
processFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
}
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,7 +96,7 @@ export const MediaArea = observer(
|
||||
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
|
||||
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
isDragging ? "bg-blue-100 border-blue-400" : ""
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
@@ -105,9 +105,11 @@ export const MediaArea = observer(
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Upload size={32} className="mb-2" />
|
||||
<span className="text-center">
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
</span>
|
||||
</div>
|
||||
<div>или</div>
|
||||
<div className="my-2">или</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
@@ -117,12 +119,14 @@ export const MediaArea = observer(
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mediaIds.length > 0 && (
|
||||
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
||||
{mediaIds.map((m) => (
|
||||
<button
|
||||
className="relative w-20 h-20"
|
||||
className="relative w-[100px] h-[80px]"
|
||||
key={m.id}
|
||||
onClick={() => handleMediaModal(m.id)}
|
||||
type="button"
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
@@ -130,20 +134,23 @@ export const MediaArea = observer(
|
||||
media_type: m.media_type,
|
||||
filename: m.filename,
|
||||
}}
|
||||
height="40px"
|
||||
compact
|
||||
/>
|
||||
<button
|
||||
className="absolute top-2 right-2"
|
||||
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMedia(articleId, m.id);
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Удалить медиа"
|
||||
>
|
||||
<X size={16} color="red" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<PreviewMediaDialog
|
||||
|
||||
@@ -11,52 +11,72 @@ import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaAreaForSight = observer(
|
||||
({
|
||||
onFilesDrop, // 👈 Проп для обработки загруженных файлов
|
||||
onFinishUpload,
|
||||
contextObjectName,
|
||||
contextType,
|
||||
isArticle,
|
||||
articleName,
|
||||
}: {
|
||||
onFilesDrop?: (files: File[]) => void;
|
||||
onFinishUpload?: (mediaId: string) => void;
|
||||
contextObjectName?: string;
|
||||
contextType?:
|
||||
type ContextType =
|
||||
| "sight"
|
||||
| "city"
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
|
||||
interface MediaAreaForSightProps {
|
||||
onFilesDrop?: (files: File[]) => void;
|
||||
onFinishUpload?: (mediaId: string) => void;
|
||||
contextObjectName?: string;
|
||||
contextType?: ContextType;
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
}) => {
|
||||
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
|
||||
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
}
|
||||
|
||||
export const MediaAreaForSight = observer(
|
||||
({
|
||||
onFilesDrop,
|
||||
onFinishUpload,
|
||||
contextObjectName,
|
||||
contextType,
|
||||
isArticle,
|
||||
articleName,
|
||||
}: MediaAreaForSightProps) => {
|
||||
const [selectMediaDialogOpen, setSelectMediaDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { setFileToUpload } = editSightStore;
|
||||
|
||||
const processFiles = (files: File[]) => {
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
// Сохраняем первый файл для загрузки
|
||||
setFileToUpload(validFiles[0]);
|
||||
|
||||
// Вызываем колбэк, если он передан
|
||||
if (onFilesDrop) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
|
||||
// Открываем диалог загрузки
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -74,22 +94,12 @@ export const MediaAreaForSight = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
onFilesDrop(validFiles);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
processFiles(files);
|
||||
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -105,7 +115,7 @@ export const MediaAreaForSight = observer(
|
||||
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`w-full h-40 flex text-center flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
|
||||
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
isDragging ? "bg-blue-100 border-blue-400" : ""
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
@@ -114,9 +124,11 @@ export const MediaAreaForSight = observer(
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Upload size={32} className="mb-2" />
|
||||
<span className="text-center">
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
</span>
|
||||
</div>
|
||||
<div>или</div>
|
||||
<div className="my-2">или</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
|
||||
@@ -129,7 +129,7 @@ export const ThreeView = ({
|
||||
>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||||
<Stage environment={null} intensity={0.6} adjustCamera={false}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cuboid } from "lucide-react";
|
||||
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
@@ -19,6 +20,7 @@ export function MediaViewer({
|
||||
width,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
compact,
|
||||
}: Readonly<{
|
||||
media?: MediaData;
|
||||
className?: string;
|
||||
@@ -26,6 +28,8 @@ export function MediaViewer({
|
||||
width?: string;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
|
||||
compact?: boolean;
|
||||
}>) {
|
||||
const token = localStorage.getItem("token");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
@@ -76,8 +80,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -88,8 +93,8 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
style={{
|
||||
width: width ? width : "100%",
|
||||
height: height ? height : "100%",
|
||||
width: compact ? "100px" : width ? width : "100%",
|
||||
height: compact ? "80px" : height ? height : "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
@@ -105,8 +110,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -117,6 +123,8 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
width: compact ? "100px" : "100%",
|
||||
height: compact ? "80px" : undefined,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
@@ -127,12 +135,32 @@ export function MediaViewer({
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
width={width ? width : "500px"}
|
||||
height={height ? height : "300px"}
|
||||
width={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"}
|
||||
height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
{media?.media_type === 6 &&
|
||||
(compact ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100px",
|
||||
height: "80px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "action.hover",
|
||||
borderRadius: 5,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Cuboid size={24} />
|
||||
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||
3D
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ThreeViewErrorBoundary
|
||||
onReset={handleReset}
|
||||
resetKey={`${media?.id}-${resetKey}`}
|
||||
@@ -146,7 +174,7 @@ export function MediaViewer({
|
||||
width={width ? width : "500px"}
|
||||
/>
|
||||
</ThreeViewErrorBoundary>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ import {
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
BackButton,
|
||||
TabPanel,
|
||||
languageStore,
|
||||
authStore,
|
||||
Language,
|
||||
cityStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
SightLanguageInfo,
|
||||
@@ -39,7 +43,7 @@ import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const CreateInformationTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const { cities } = cityStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
const [mediaId, setMediaId] = useState<string>("");
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
@@ -52,17 +56,53 @@ export const CreateInformationTab = observer(
|
||||
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "video_preview"
|
||||
| null
|
||||
>(null);
|
||||
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [hardcodeType, setHardcodeType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "video_preview"
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
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 =
|
||||
sight.city_id && !baseCities.some((city) => city.id === sight.city_id)
|
||||
? [
|
||||
{
|
||||
id: sight.city_id,
|
||||
name: sight.city || `Город ${sight.city_id}`,
|
||||
country: "",
|
||||
country_code: "",
|
||||
arms: "",
|
||||
},
|
||||
...baseCities,
|
||||
]
|
||||
: baseCities;
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,7 +143,13 @@ export const CreateInformationTab = observer(
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
},
|
||||
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
|
||||
type:
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "video_preview"
|
||||
) => {
|
||||
handleChange({
|
||||
[type]: media.id,
|
||||
@@ -191,6 +237,21 @@ export const CreateInformationTab = observer(
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={`Сокращенное название (${language.toUpperCase()})`}
|
||||
value={data.short_name || ""}
|
||||
onChange={(e) => {
|
||||
handleChange(
|
||||
{
|
||||
short_name: e.target.value,
|
||||
},
|
||||
language
|
||||
);
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Адрес"
|
||||
value={data.address}
|
||||
@@ -207,17 +268,16 @@ export const CreateInformationTab = observer(
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
options={cities["ru"]?.data ?? []}
|
||||
options={availableCities}
|
||||
value={
|
||||
cities["ru"]?.data?.find(
|
||||
(city) => city.id === sight.city_id
|
||||
) ?? null
|
||||
availableCities.find((city) => city.id === sight.city_id) ?? null
|
||||
}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
setCity(value?.id ?? 0);
|
||||
handleChange({
|
||||
city_id: value?.id ?? 0,
|
||||
city: value?.name ?? "",
|
||||
});
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
@@ -259,6 +319,20 @@ export const CreateInformationTab = observer(
|
||||
variant="outlined"
|
||||
placeholder="Введите координаты в формате: широта долгота"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!sight.is_default_icon}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
is_default_icon: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Использовать иконку по умолчанию"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -270,11 +344,14 @@ export const CreateInformationTab = observer(
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-around",
|
||||
display: "grid",
|
||||
width: "80%",
|
||||
gap: 2,
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, minmax(0, 1fr))",
|
||||
md: "repeat(4, minmax(0, 1fr))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ImageUploadCard
|
||||
@@ -305,10 +382,70 @@ export const CreateInformationTab = observer(
|
||||
}}
|
||||
/>
|
||||
|
||||
{!sight.is_default_icon && (
|
||||
<>
|
||||
<ImageUploadCard
|
||||
title="Иконка (на карте)"
|
||||
imageKey="icon"
|
||||
imageUrl={isMediaIdEmpty(sight.icon) ? null : sight.icon}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.icon ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
handleChange({
|
||||
icon: null,
|
||||
});
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("icon");
|
||||
setIsAddMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("icon");
|
||||
setHardcodeType("icon");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setHardcodeType("icon");
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImageUploadCard
|
||||
title="Иконка (при выборе)"
|
||||
imageKey="alt_icon"
|
||||
imageUrl={isMediaIdEmpty(sight.alt_icon) ? null : sight.alt_icon}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.alt_icon ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
handleChange({
|
||||
alt_icon: null,
|
||||
});
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("alt_icon");
|
||||
setIsAddMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("alt_icon");
|
||||
setHardcodeType("alt_icon");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setHardcodeType("alt_icon");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImageUploadCard
|
||||
title="Водяной знак (левый верхний)"
|
||||
imageKey="watermark_lu"
|
||||
imageUrl={sight.watermark_lu}
|
||||
imageUrl={isMediaIdEmpty(sight.watermark_lu) ? null : sight.watermark_lu}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.watermark_lu ?? "");
|
||||
@@ -363,7 +500,7 @@ export const CreateInformationTab = observer(
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={sight.video_preview}
|
||||
videoId={isMediaIdEmpty(sight.video_preview) ? null : sight.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
handleChange({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
BackButton,
|
||||
createSightStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
TabPanel,
|
||||
@@ -51,9 +52,6 @@ export const CreateRightTab = observer(
|
||||
unlinkPreviewMedia,
|
||||
createLinkWithRightArticle,
|
||||
deleteRightArticleMedia,
|
||||
setFileToUpload,
|
||||
setUploadMediaOpen,
|
||||
uploadMediaOpen,
|
||||
unlinkRightAritcle,
|
||||
deleteRightArticle,
|
||||
linkExistingRightArticle,
|
||||
@@ -62,6 +60,8 @@ export const CreateRightTab = observer(
|
||||
updateRightArticles,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
|
||||
editSightStore;
|
||||
|
||||
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
@@ -434,7 +434,6 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
) : type === "media" ? (
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
|
||||
{sight.preview_media && (
|
||||
<>
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
|
||||
@@ -462,11 +461,19 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!previewMedia && (
|
||||
<Box className="w-full h-full flex justify-center items-center">
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "500px",
|
||||
maxHeight: "100%",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
margin: "0 auto",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
@@ -476,8 +483,13 @@ export const CreateRightTab = observer(
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user