feat: role system
This commit is contained in:
6
.env
6
.env
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_URL='https://wn.st.unprism.ru'
|
VITE_API_URL='https://wn.krbl.ru'
|
||||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||||
VITE_NEED_AUTH='true'
|
VITE_NEED_AUTH='true'
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
ArticlePreviewPage,
|
ArticlePreviewPage,
|
||||||
CountryAddPage,
|
CountryAddPage,
|
||||||
} from "@pages";
|
} from "@pages";
|
||||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared";
|
||||||
import { Layout } from "@widgets";
|
import { Layout } from "@widgets";
|
||||||
import { runInAction } from "mobx";
|
import { runInAction } from "mobx";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Outlet,
|
Outlet,
|
||||||
useLocation,
|
useLocation,
|
||||||
|
useMatches,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -65,15 +66,28 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const matches = useMatches();
|
||||||
|
|
||||||
if (!isAuthenticated && need_auth) {
|
if (!isAuthenticated && need_auth) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname === "/") {
|
if (location.pathname === "/" && authStore.canRead("map")) {
|
||||||
return <Navigate to="/map" replace />;
|
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}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +116,10 @@ const router = createBrowserRouter([
|
|||||||
</PublicRoute>
|
</PublicRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ path: "route-preview/:id", element: <RoutePreview /> },
|
{
|
||||||
|
path: "route-preview/:id",
|
||||||
|
element: <RoutePreview />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: (
|
element: (
|
||||||
@@ -115,48 +132,258 @@ const router = createBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
children: [
|
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",
|
||||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
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",
|
||||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
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",
|
||||||
{ path: "country/add", element: <CountryAddPage /> },
|
element: <CountryListPage />,
|
||||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
handle: {
|
||||||
{ path: "city", element: <CityListPage /> },
|
permissions: ROUTE_REQUIRED_RESOURCES["/country"],
|
||||||
{ path: "city/create", element: <CityCreatePage /> },
|
},
|
||||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
},
|
||||||
{ path: "route", element: <RouteListPage /> },
|
{
|
||||||
{ path: "route/create", element: <RouteCreatePage /> },
|
path: "country/create",
|
||||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
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",
|
||||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
element: <UserListPage />,
|
||||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
handle: {
|
||||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
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",
|
||||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
element: <CarrierListPage />,
|
||||||
{ path: "station", element: <StationListPage /> },
|
handle: {
|
||||||
{ path: "station/create", element: <StationCreatePage /> },
|
permissions: ROUTE_REQUIRED_RESOURCES["/carrier"],
|
||||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
},
|
||||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
},
|
||||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
{
|
||||||
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
path: "carrier/create",
|
||||||
{ path: "article", element: <ArticleListPage /> },
|
element: <CarrierCreatePage />,
|
||||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
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;
|
icon: LucideIcon;
|
||||||
path?: string;
|
path?: string;
|
||||||
for_admin?: boolean;
|
for_admin?: boolean;
|
||||||
|
required_resource?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
nestedItems?: NavigationItem[];
|
nestedItems?: NavigationItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|||||||
import type { NavigationItem } from "../model";
|
import type { NavigationItem } from "../model";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { authStore } from "@shared";
|
|
||||||
|
|
||||||
interface NavigationItemProps {
|
interface NavigationItemProps {
|
||||||
item: NavigationItem;
|
item: NavigationItem;
|
||||||
@@ -31,22 +30,10 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const { payload } = authStore;
|
|
||||||
|
|
||||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const isAdmin = payload?.is_admin || false || !need_auth;
|
|
||||||
|
|
||||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||||
|
|
||||||
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
const filteredNestedItems = item.nestedItems;
|
||||||
if (nestedItem.for_admin) {
|
|
||||||
return isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (item.id === "all" && !open) {
|
if (item.id === "all" && !open) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import Divider from "@mui/material/Divider";
|
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 { NavigationItem, NavigationItemComponent } from "@entities";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
@@ -9,28 +9,48 @@ interface NavigationListProps {
|
|||||||
onDrawerOpen?: () => void;
|
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(
|
export const NavigationList = observer(
|
||||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||||
const { payload } = authStore;
|
const primaryItems = NAVIGATION_ITEMS.primary
|
||||||
// @ts-ignore
|
.filter(isItemVisible)
|
||||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
.map((item) => {
|
||||||
|
if (!item.nestedItems) return item;
|
||||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
return {
|
||||||
if (item.for_admin) {
|
...item,
|
||||||
return isAdmin;
|
nestedItems: item.nestedItems.filter(isItemVisible),
|
||||||
}
|
};
|
||||||
|
})
|
||||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
.filter((item) => !item.nestedItems || item.nestedItems.length > 0);
|
||||||
return item.nestedItems.some((nestedItem) => {
|
|
||||||
if (nestedItem.for_admin) {
|
|
||||||
return isAdmin;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,7 +71,7 @@ export const NavigationList = observer(
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
item={item as NavigationItem}
|
item={item as NavigationItem}
|
||||||
open={open}
|
open={open}
|
||||||
onClick={item.onClick ? item.onClick : undefined}
|
onClick={item.onClick ?? undefined}
|
||||||
onDrawerOpen={onDrawerOpen}
|
onDrawerOpen={onDrawerOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { articlesStore, languageStore } from "@shared";
|
import { authStore, articlesStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Trash2, Eye, Minus } from "lucide-react";
|
import { Trash2, Eye, Minus } from "lucide-react";
|
||||||
@@ -51,13 +51,12 @@ export const ArticleListPage = observer(() => {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<Eye size={20} className="text-green-500" />
|
||||||
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
</button>
|
||||||
<Eye size={20} className="text-green-500" />
|
{authStore.canWrite("sights") && (
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -66,9 +65,9 @@ export const ArticleListPage = observer(() => {
|
|||||||
>
|
>
|
||||||
<Trash2 size={20} className="text-red-500" />
|
<Trash2 size={20} className="text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
},
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { toast } from "react-toastify";
|
|||||||
import {
|
import {
|
||||||
carrierStore,
|
carrierStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
|
authStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
@@ -30,7 +31,8 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { selectedCityId } = useSelectedCity();
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
@@ -42,11 +44,37 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
mediaStore.getMedia();
|
||||||
languageStore.setLanguage("ru");
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedCityId && !createCarrierData.city_id) {
|
if (selectedCityId && !createCarrierData.city_id) {
|
||||||
setCreateCarrierData(
|
setCreateCarrierData(
|
||||||
@@ -134,7 +162,7 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{cityStore.cities["ru"].data.map((city) => (
|
{availableCities.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { toast } from "react-toastify";
|
|||||||
import {
|
import {
|
||||||
carrierStore,
|
carrierStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
|
authStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
@@ -34,6 +35,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
@@ -42,6 +44,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [mediaId, setMediaId] = useState("");
|
const [mediaId, setMediaId] = useState("");
|
||||||
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||||
|
const [initialCityName, setInitialCityName] = useState("");
|
||||||
const [activeMenuType, setActiveMenuType] = useState<
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -54,9 +57,14 @@ export const CarrierEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
setIsLoadingData(true);
|
setIsLoadingData(true);
|
||||||
try {
|
try {
|
||||||
await cityStore.getCities("ru");
|
if (!authStore.me) {
|
||||||
await cityStore.getCities("en");
|
await authStore.getMeAction().catch(() => undefined);
|
||||||
await cityStore.getCities("zh");
|
}
|
||||||
|
if (authStore.canRead("cities")) {
|
||||||
|
await cityStore.getCities("ru");
|
||||||
|
} else {
|
||||||
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
|
}
|
||||||
const carrierData = await getCarrier(Number(id));
|
const carrierData = await getCarrier(Number(id));
|
||||||
|
|
||||||
if (carrierData) {
|
if (carrierData) {
|
||||||
@@ -84,6 +92,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
carrierData.zh?.logo || "",
|
carrierData.zh?.logo || "",
|
||||||
"zh"
|
"zh"
|
||||||
);
|
);
|
||||||
|
setInitialCityName(carrierData.ru?.city || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediaStore.getMedia();
|
await mediaStore.getMedia();
|
||||||
@@ -132,6 +141,31 @@ export const CarrierEditPage = observer(() => {
|
|||||||
? null
|
? null
|
||||||
: (selectedMedia?.id ?? editCarrierData.logo);
|
: (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) {
|
if (isLoadingData) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -181,7 +215,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{cityStore.cities["ru"].data?.map((city) => (
|
{availableCities.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { carrierStore, cityStore, languageStore } from "@shared";
|
import { authStore, carrierStore, cityStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -10,7 +10,6 @@ import { Box, CircularProgress } from "@mui/material";
|
|||||||
|
|
||||||
export const CarrierListPage = observer(() => {
|
export const CarrierListPage = observer(() => {
|
||||||
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||||
const { getCities, cities } = cityStore;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
@@ -22,13 +21,19 @@ export const CarrierListPage = observer(() => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await getCities("ru");
|
if (!authStore.me) {
|
||||||
await getCities("en");
|
await authStore.getMeAction().catch(() => undefined);
|
||||||
await getCities("zh");
|
}
|
||||||
|
if (authStore.canRead("cities")) {
|
||||||
|
await cityStore.getCities(language);
|
||||||
|
} else {
|
||||||
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
|
}
|
||||||
await getCarriers(language);
|
await getCarriers(language);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -73,56 +78,57 @@ export const CarrierListPage = observer(() => {
|
|||||||
headerName: "Город",
|
headerName: "Город",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
const city = cities[language]?.data.find(
|
const lang = language as "ru" | "en" | "zh";
|
||||||
(city) => city.id == params.value
|
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 (
|
return (
|
||||||
<div className="w-full h-full flex items-center">
|
<div className="w-full h-full flex items-center">
|
||||||
{city && city.name ? (
|
{cityName ?? <Minus size={20} className="text-red-500" />}
|
||||||
city.name
|
|
||||||
) : (
|
|
||||||
<Minus size={20} className="text-red-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(authStore.canWrite("carriers") ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
</button>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
setIsDeleteModalOpen(true);
|
||||||
<Eye size={20} className="text-green-500" />
|
setRowId(params.row.id);
|
||||||
</button> */}
|
}}
|
||||||
<button
|
>
|
||||||
onClick={() => {
|
<Trash2 size={20} className="text-red-500" />
|
||||||
setIsDeleteModalOpen(true);
|
</button>
|
||||||
setRowId(params.row.id);
|
</div>
|
||||||
}}
|
),
|
||||||
>
|
}] : []),
|
||||||
<Trash2 size={20} className="text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = carriers[language].data?.map((carrier) => ({
|
const allowedCityIds = canReadCities
|
||||||
id: carrier.id,
|
? null
|
||||||
full_name: carrier.full_name,
|
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||||
short_name: carrier.short_name,
|
|
||||||
city_id: carrier.city_id,
|
const canWriteCarriers = authStore.canWrite("carriers");
|
||||||
}));
|
|
||||||
|
const rows = carriers[language].data
|
||||||
|
?.filter((carrier) =>
|
||||||
|
!allowedCityIds || allowedCityIds.includes(carrier.city_id),
|
||||||
|
)
|
||||||
|
.map((carrier) => ({
|
||||||
|
id: carrier.id,
|
||||||
|
full_name: carrier.full_name,
|
||||||
|
short_name: carrier.short_name,
|
||||||
|
city_id: carrier.city_id,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -130,10 +136,12 @@ export const CarrierListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Перевозчики</h1>
|
<h1 className="text-2xl">Перевозчики</h1>
|
||||||
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
{canWriteCarriers && (
|
||||||
|
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ids.length > 0 && (
|
{canWriteCarriers && ids.length > 0 && (
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
@@ -148,25 +156,33 @@ export const CarrierListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={canWriteCarriers}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
pageSizeOptions={[50]}
|
pageSizeOptions={[50]}
|
||||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
onRowSelectionModelChange={(newSelection: any) => {
|
onRowSelectionModelChange={
|
||||||
if (Array.isArray(newSelection)) {
|
canWriteCarriers
|
||||||
const selectedIds = newSelection.map((id: string | number) => Number(id));
|
? (newSelection: any) => {
|
||||||
setIds(selectedIds);
|
if (Array.isArray(newSelection)) {
|
||||||
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
|
const selectedIds = newSelection.map(Number);
|
||||||
const idsSet = newSelection.ids as Set<string | number>;
|
setIds(selectedIds);
|
||||||
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
|
} else if (
|
||||||
setIds(selectedIds);
|
newSelection &&
|
||||||
} else {
|
typeof newSelection === "object" &&
|
||||||
setIds([]);
|
"ids" in newSelection
|
||||||
}
|
) {
|
||||||
}}
|
const idsSet = newSelection.ids as Set<string | number>;
|
||||||
|
const selectedIds = Array.from(idsSet).map(Number);
|
||||||
|
setIds(selectedIds);
|
||||||
|
} else {
|
||||||
|
setIds([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { languageStore, cityStore, countryStore } from "@shared";
|
import { authStore, languageStore, cityStore, countryStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -23,6 +23,7 @@ export const CityListPage = observer(() => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canWriteCities = authStore.canWrite("cities");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -91,35 +92,30 @@ export const CityListPage = observer(() => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(authStore.canWrite("cities") ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
return (
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<Pencil size={20} className="text-blue-500" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
|
onClick={(e) => {
|
||||||
<Eye size={20} className="text-green-500" />
|
e.stopPropagation();
|
||||||
</button> */}
|
setIsDeleteModalOpen(true);
|
||||||
<button
|
setRowId(params.row.id);
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
>
|
||||||
setIsDeleteModalOpen(true);
|
<Trash2 size={20} className="text-red-500" />
|
||||||
setRowId(params.row.id);
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
),
|
||||||
<Trash2 size={20} className="text-red-500" />
|
}] : []),
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +125,9 @@ export const CityListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Города</h1>
|
<h1 className="text-2xl">Города</h1>
|
||||||
<CreateButton label="Создать город" path="/city/create" />
|
{canWriteCities && (
|
||||||
|
<CreateButton label="Создать город" path="/city/create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ids.length > 0 && (
|
{ids.length > 0 && (
|
||||||
@@ -147,7 +145,7 @@ export const CityListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={authStore.canWrite("cities")}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { countryStore, languageStore } from "@shared";
|
import { authStore, countryStore, languageStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Trash2, Minus } from "lucide-react";
|
import { Trash2, Minus } from "lucide-react";
|
||||||
@@ -21,6 +21,7 @@ export const CountryListPage = observer(() => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canWriteCountries = authStore.canWrite("countries");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCountries = async () => {
|
const fetchCountries = async () => {
|
||||||
@@ -48,37 +49,27 @@ export const CountryListPage = observer(() => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(authStore.canWrite("countries") ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
return (
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<button
|
||||||
{/* <button
|
onClick={(e) => {
|
||||||
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
e.stopPropagation();
|
||||||
>
|
setIsDeleteModalOpen(true);
|
||||||
<Pencil size={20} className="text-blue-500" />
|
setRowId(params.row.code);
|
||||||
</button> */}
|
}}
|
||||||
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
|
>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Trash2 size={20} className="text-red-500" />
|
||||||
</button> */}
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
),
|
||||||
e.stopPropagation();
|
}] : []),
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
setRowId(params.row.code);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={20} className="text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = countries[language]?.data.map((country) => ({
|
const rows = countries[language]?.data.map((country) => ({
|
||||||
@@ -94,7 +85,9 @@ export const CountryListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Страны</h1>
|
<h1 className="text-2xl">Страны</h1>
|
||||||
<CreateButton label="Добавить страну" path="/country/add" />
|
{canWriteCountries && (
|
||||||
|
<CreateButton label="Добавить страну" path="/country/add" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ids.length > 0 && (
|
{ids.length > 0 && (
|
||||||
@@ -112,7 +105,7 @@ export const CountryListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows || []}
|
rows={rows || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={authStore.canWrite("countries")}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Tab, Tabs } from "@mui/material";
|
import { Box, Tab, Tabs } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
articlesStore,
|
articlesStore,
|
||||||
|
authStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
createSightStore,
|
createSightStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
@@ -40,7 +41,14 @@ export const CreateSightPage = observer(() => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await getCities("ru");
|
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);
|
await getArticles(languageStore.language);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import {
|
import {
|
||||||
articlesStore,
|
articlesStore,
|
||||||
|
authStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
@@ -41,7 +42,14 @@ export const EditSightPage = observer(() => {
|
|||||||
if (id) {
|
if (id) {
|
||||||
setIsLoadingData(true);
|
setIsLoadingData(true);
|
||||||
try {
|
try {
|
||||||
await getCities("ru");
|
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, "ru");
|
||||||
await getSightInfo(+id, "en");
|
await getSightInfo(+id, "en");
|
||||||
await getSightInfo(+id, "zh");
|
await getSightInfo(+id, "zh");
|
||||||
|
|||||||
@@ -1,37 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
export const MainPage: React.FC = () => {
|
export const MainPage: React.FC = () => {
|
||||||
return (
|
return null;
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Trash2, Minus } from "lucide-react";
|
import { Eye, Trash2, Minus } from "lucide-react";
|
||||||
@@ -71,16 +71,15 @@ export const MediaListPage = observer(() => {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 200,
|
width: 200,
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<Eye size={20} className="text-green-500" />
|
||||||
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
</button>
|
||||||
<Eye size={20} className="text-green-500" />
|
{authStore.canWrite("sights") && (
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -89,9 +88,9 @@ export const MediaListPage = observer(() => {
|
|||||||
>
|
>
|
||||||
<Trash2 size={20} className="text-red-500" />
|
<Trash2 size={20} className="text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
},
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ export const MediaListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={authStore.canWrite("sights")}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { carrierStore, languageStore, routeStore } from "@shared";
|
import { authStore, carrierStore, languageStore, routeStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -108,27 +108,37 @@ export const RouteListPage = observer(() => {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 250,
|
width: 250,
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
const canWrite = authStore.canWrite("routes");
|
||||||
|
const canShowRoutePreview =
|
||||||
|
authStore.canRead("stations") &&
|
||||||
|
authStore.canRead("sights") &&
|
||||||
|
authStore.canRead("routes");
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
{canWrite && (
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||||
</button>
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
</button>
|
||||||
<Map size={20} className="text-purple-500" />
|
)}
|
||||||
</button>
|
{canShowRoutePreview && (
|
||||||
|
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||||
<button
|
<Map size={20} className="text-purple-500" />
|
||||||
onClick={() => {
|
</button>
|
||||||
setIsDeleteModalOpen(true);
|
)}
|
||||||
setRowId(params.row.id);
|
{canWrite && (
|
||||||
}}
|
<button
|
||||||
>
|
onClick={() => {
|
||||||
<Trash2 size={20} className="text-red-500" />
|
setIsDeleteModalOpen(true);
|
||||||
</button>
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -168,7 +178,7 @@ export const RouteListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={authStore.canWrite("routes")}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import {
|
import {
|
||||||
|
authStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
sightsStore,
|
sightsStore,
|
||||||
@@ -15,7 +16,6 @@ import { Box, CircularProgress } from "@mui/material";
|
|||||||
|
|
||||||
export const SightListPage = observer(() => {
|
export const SightListPage = observer(() => {
|
||||||
const { sights, getSights, deleteListSight } = sightsStore;
|
const { sights, getSights, deleteListSight } = sightsStore;
|
||||||
const { cities, getCities } = cityStore;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
@@ -27,13 +27,20 @@ export const SightListPage = observer(() => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSights = async () => {
|
const fetchSights = async () => {
|
||||||
setIsLoading(true);
|
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();
|
await getSights();
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchSights();
|
fetchSights();
|
||||||
@@ -61,54 +68,59 @@ export const SightListPage = observer(() => {
|
|||||||
headerName: "Город",
|
headerName: "Город",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
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 (
|
return (
|
||||||
<div className="w-full h-full flex items-center">
|
<div className="w-full h-full flex items-center">
|
||||||
{params.value ? (
|
{cityName ?? <Minus size={20} className="text-red-500" />}
|
||||||
cities[language].data.find((el) => el.id == params.value)?.name
|
|
||||||
) : (
|
|
||||||
<Minus size={20} className="text-red-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(authStore.canWrite("sights") ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
</button>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
|
setIsDeleteModalOpen(true);
|
||||||
<Eye size={20} className="text-green-500" />
|
setRowId(params.row.id);
|
||||||
</button> */}
|
}}
|
||||||
<button
|
>
|
||||||
onClick={() => {
|
<Trash2 size={20} className="text-red-500" />
|
||||||
setIsDeleteModalOpen(true);
|
</button>
|
||||||
setRowId(params.row.id);
|
</div>
|
||||||
}}
|
),
|
||||||
>
|
}] : []),
|
||||||
<Trash2 size={20} className="text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredSights = useMemo(() => {
|
const filteredSights = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
if (!selectedCityId) {
|
const allowedCityIds = canReadCities
|
||||||
return sights;
|
? null
|
||||||
}
|
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
|
||||||
}, [sights, selectedCityStore.selectedCityId]);
|
return sights.filter((sight: any) => {
|
||||||
|
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedCityId && sight.city_id !== selectedCityId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
|
||||||
|
|
||||||
|
const canWriteSights = authStore.canWrite("sights");
|
||||||
|
|
||||||
const rows = filteredSights.map((sight) => ({
|
const rows = filteredSights.map((sight) => ({
|
||||||
id: sight.id,
|
id: sight.id,
|
||||||
@@ -123,13 +135,15 @@ export const SightListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Достопримечательности</h1>
|
<h1 className="text-2xl">Достопримечательности</h1>
|
||||||
<CreateButton
|
{canWriteSights && (
|
||||||
label="Создать достопримечательность"
|
<CreateButton
|
||||||
path="/sight/create"
|
label="Создать достопримечательность"
|
||||||
/>
|
path="/sight/create"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ids.length > 0 && (
|
{canWriteSights && ids.length > 0 && (
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||||
@@ -144,25 +158,33 @@ export const SightListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={canWriteSights}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
pageSizeOptions={[50]}
|
pageSizeOptions={[50]}
|
||||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||||
onRowSelectionModelChange={(newSelection: any) => {
|
onRowSelectionModelChange={
|
||||||
if (Array.isArray(newSelection)) {
|
canWriteSights
|
||||||
const selectedIds = newSelection.map((id: string | number) => Number(id));
|
? (newSelection: any) => {
|
||||||
setIds(selectedIds);
|
if (Array.isArray(newSelection)) {
|
||||||
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
|
const selectedIds = newSelection.map(Number);
|
||||||
const idsSet = newSelection.ids as Set<string | number>;
|
setIds(selectedIds);
|
||||||
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
|
} else if (
|
||||||
setIds(selectedIds);
|
newSelection &&
|
||||||
} else {
|
typeof newSelection === "object" &&
|
||||||
setIds([]);
|
"ids" in newSelection
|
||||||
}
|
) {
|
||||||
}}
|
const idsSet = newSelection.ids as Set<string | number>;
|
||||||
|
const selectedIds = Array.from(idsSet).map(Number);
|
||||||
|
setIds(selectedIds);
|
||||||
|
} else {
|
||||||
|
setIds([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { languageStore, snapshotStore } from "@shared";
|
import { authStore, languageStore, snapshotStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||||
@@ -10,6 +10,9 @@ import { Box, CircularProgress } from "@mui/material";
|
|||||||
export const SnapshotListPage = observer(() => {
|
export const SnapshotListPage = observer(() => {
|
||||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||||
snapshotStore;
|
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [rowId, setRowId] = useState<string | null>(null);
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
@@ -57,37 +60,33 @@ export const SnapshotListPage = observer(() => {
|
|||||||
return <div>{params.value ? params.value : "-"}</div>;
|
return <div>{params.value ? params.value : "-"}</div>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(canManageSnapshots ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 300,
|
width: 300,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
onClick={() => {
|
||||||
<button
|
setIsRestoreModalOpen(true);
|
||||||
onClick={() => {
|
setRowId(params.row.id);
|
||||||
setIsRestoreModalOpen(true);
|
}}
|
||||||
setRowId(params.row.id);
|
>
|
||||||
}}
|
<DatabaseBackup size={20} className="text-blue-500" />
|
||||||
>
|
</button>
|
||||||
<DatabaseBackup size={20} className="text-blue-500" />
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
<button
|
setRowId(params.row.id);
|
||||||
onClick={() => {
|
}}
|
||||||
setIsDeleteModalOpen(true);
|
>
|
||||||
setRowId(params.row.id);
|
<Trash2 size={20} className="text-red-500" />
|
||||||
}}
|
</button>
|
||||||
>
|
</div>
|
||||||
<Trash2 size={20} className="text-red-500" />
|
),
|
||||||
</button>
|
}] : []),
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = snapshots.map((snapshot) => ({
|
const rows = snapshots.map((snapshot) => ({
|
||||||
@@ -102,7 +101,9 @@ export const SnapshotListPage = observer(() => {
|
|||||||
<div style={{ width: "100%" }}>
|
<div style={{ width: "100%" }}>
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||||
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
|
{canCreateSnapshot && (
|
||||||
|
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
stationsStore,
|
stationsStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
|
authStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
useSelectedCity,
|
useSelectedCity,
|
||||||
@@ -39,7 +40,8 @@ export const StationCreatePage = observer(() => {
|
|||||||
createStation,
|
createStation,
|
||||||
setLanguageCreateStationData,
|
setLanguageCreateStationData,
|
||||||
} = stationsStore;
|
} = stationsStore;
|
||||||
const { cities, getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
const [coordinates, setCoordinates] = useState<string>("");
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
@@ -104,15 +106,35 @@ export const StationCreatePage = observer(() => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCities = async () => {
|
const fetchCities = async () => {
|
||||||
await getCities("ru");
|
if (!authStore.me) {
|
||||||
await getCities("en");
|
await authStore.getMeAction().catch(() => undefined);
|
||||||
await getCities("zh");
|
}
|
||||||
|
if (authStore.canRead("cities")) {
|
||||||
|
await getCities("ru");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCities();
|
fetchCities();
|
||||||
mediaStore.getMedia();
|
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: {
|
const handleMediaSelect = (media: {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -229,7 +251,7 @@ export const StationCreatePage = observer(() => {
|
|||||||
value={createStationData.common.city_id || ""}
|
value={createStationData.common.city_id || ""}
|
||||||
label="Город"
|
label="Город"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selectedCity = cities["ru"].data.find(
|
const selectedCity = availableCities.find(
|
||||||
(city) => city.id === e.target.value
|
(city) => city.id === e.target.value
|
||||||
);
|
);
|
||||||
setCreateCommonData({
|
setCreateCommonData({
|
||||||
@@ -238,7 +260,7 @@ export const StationCreatePage = observer(() => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cities["ru"].data.map((city) => (
|
{availableCities.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
stationsStore,
|
stationsStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
|
authStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
@@ -44,7 +45,8 @@ export const StationEditPage = observer(() => {
|
|||||||
editStation,
|
editStation,
|
||||||
setLanguageEditStationData,
|
setLanguageEditStationData,
|
||||||
} = stationsStore;
|
} = stationsStore;
|
||||||
const { cities, getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
const [coordinates, setCoordinates] = useState<string>("");
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
@@ -138,9 +140,14 @@ export const StationEditPage = observer(() => {
|
|||||||
try {
|
try {
|
||||||
const stationId = Number(id);
|
const stationId = Number(id);
|
||||||
await getEditStation(stationId);
|
await getEditStation(stationId);
|
||||||
await getCities("ru");
|
if (!authStore.me) {
|
||||||
await getCities("en");
|
await authStore.getMeAction().catch(() => undefined);
|
||||||
await getCities("zh");
|
}
|
||||||
|
if (authStore.canRead("cities")) {
|
||||||
|
await getCities("ru");
|
||||||
|
} else {
|
||||||
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
|
}
|
||||||
await mediaStore.getMedia();
|
await mediaStore.getMedia();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingData(false);
|
setIsLoadingData(false);
|
||||||
@@ -150,6 +157,31 @@ export const StationEditPage = observer(() => {
|
|||||||
fetchAndSetStationData();
|
fetchAndSetStationData();
|
||||||
}, [id]);
|
}, [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) {
|
if (isLoadingData) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -255,7 +287,7 @@ export const StationEditPage = observer(() => {
|
|||||||
value={editStationData.common.city_id || ""}
|
value={editStationData.common.city_id || ""}
|
||||||
label="Город"
|
label="Город"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selectedCity = cities["ru"].data.find(
|
const selectedCity = availableCities.find(
|
||||||
(city) => city.id === e.target.value
|
(city) => city.id === e.target.value
|
||||||
);
|
);
|
||||||
setEditCommonData({
|
setEditCommonData({
|
||||||
@@ -264,7 +296,7 @@ export const StationEditPage = observer(() => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cities["ru"].data.map((city) => (
|
{availableCities.map((city) => (
|
||||||
<MenuItem key={city.id} value={city.id}>
|
<MenuItem key={city.id} value={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import {
|
import {
|
||||||
|
authStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
stationsStore,
|
stationsStore,
|
||||||
selectedCityStore,
|
selectedCityStore,
|
||||||
cityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2, Minus, Route } from "lucide-react";
|
import { Pencil, Trash2, Minus, Route } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
@@ -35,11 +35,11 @@ export const StationListPage = observer(() => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
const canWriteStations = authStore.canWrite("stations");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStations = async () => {
|
const fetchStations = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await cityStore.getCities(language);
|
|
||||||
await getStationList();
|
await getStationList();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@@ -83,36 +83,38 @@ export const StationListPage = observer(() => {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 200,
|
width: 200,
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
{canWriteStations && (
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
||||||
</button>
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
</button>
|
||||||
<Eye size={20} className="text-green-500" />
|
)}
|
||||||
</button>
|
{canWriteStations && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedStationId(params.row.id);
|
setSelectedStationId(params.row.id);
|
||||||
setIsTransfersModalOpen(true);
|
setIsTransfersModalOpen(true);
|
||||||
}}
|
}}
|
||||||
title="Редактировать пересадки"
|
title="Редактировать пересадки"
|
||||||
>
|
>
|
||||||
<Route size={20} className="text-purple-500" />
|
<Route size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={() => {
|
{canWriteStations && (
|
||||||
setIsDeleteModalOpen(true);
|
<button
|
||||||
setRowId(params.row.id);
|
onClick={() => {
|
||||||
}}
|
setIsDeleteModalOpen(true);
|
||||||
>
|
setRowId(params.row.id);
|
||||||
<Trash2 size={20} className="text-red-500" />
|
}}
|
||||||
</button>
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -142,7 +144,9 @@ export const StationListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Станции</h1>
|
<h1 className="text-2xl">Станции</h1>
|
||||||
<CreateButton label="Создать остановки" path="/station/create" />
|
{canWriteStations && (
|
||||||
|
<CreateButton label="Создать остановки" path="/station/create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Button, Paper, TextField } from "@mui/material";
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Checkbox,
|
|
||||||
FormControlLabel,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -133,26 +127,6 @@ export const UserCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col items-start">
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={createUserData.is_admin || false}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCreateUserData(
|
|
||||||
createUserData.name || "",
|
|
||||||
createUserData.email || "",
|
|
||||||
createUserData.password || "",
|
|
||||||
e.target.checked,
|
|
||||||
createUserData.icon
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Администратор"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Аватар"
|
title="Аватар"
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Box,
|
Box,
|
||||||
|
Typography,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
@@ -19,17 +28,61 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
|
authStore,
|
||||||
|
cityStore,
|
||||||
|
MultiSelect,
|
||||||
|
type User,
|
||||||
|
type UserCity,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ImageUploadCard, DeleteModal } from "@widgets";
|
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(() => {
|
export const UserEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
const { id } = useParams();
|
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 [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
@@ -44,13 +97,65 @@ export const UserEditPage = observer(() => {
|
|||||||
languageStore.setLanguage("ru");
|
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 {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
|
||||||
|
const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
|
||||||
|
setEditUserRoles(rolesToSave);
|
||||||
await editUser(Number(id));
|
await editUser(Number(id));
|
||||||
toast.success("Пользователь успешно обновлен");
|
|
||||||
|
await userStore.addUserCityAction({
|
||||||
|
id: Number(id),
|
||||||
|
city_ids: localCityIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Пользователь успешно обновлён");
|
||||||
navigate("/user");
|
navigate("/user");
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Ошибка при обновлении пользователя");
|
toast.error("Ошибка при обновлении пользователя");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -68,43 +173,43 @@ export const UserEditPage = observer(() => {
|
|||||||
editUserData.email || "",
|
editUserData.email || "",
|
||||||
editUserData.password || "",
|
editUserData.password || "",
|
||||||
editUserData.is_admin || false,
|
editUserData.is_admin || false,
|
||||||
media.id
|
media.id,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (id) {
|
|
||||||
setIsLoadingData(true);
|
|
||||||
try {
|
|
||||||
await mediaStore.getMedia();
|
|
||||||
const data = await getUser(Number(id));
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setEditUserData(
|
|
||||||
data.name || "",
|
|
||||||
data.email || "",
|
|
||||||
data.password || "",
|
|
||||||
data.is_admin || false,
|
|
||||||
data.icon || ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoadingData(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsLoadingData(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const selectedMedia =
|
const selectedMedia =
|
||||||
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
|
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
|
||||||
? mediaStore.media.find((m) => m.id === editUserData.icon)
|
? mediaStore.media.find((m) => m.id === editUserData.icon)
|
||||||
: null;
|
: null;
|
||||||
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
|
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
|
||||||
? null
|
? null
|
||||||
: selectedMedia?.id ?? 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) {
|
if (isLoadingData) {
|
||||||
return (
|
return (
|
||||||
@@ -122,18 +227,16 @@ export const UserEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full p-6 flex flex-col gap-8">
|
||||||
<div className="flex items-center gap-4">
|
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
|
||||||
<button
|
<ArrowLeft size={20} />
|
||||||
className="flex items-center gap-2"
|
Назад
|
||||||
onClick={() => navigate(-1)}
|
</button>
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
{/* ── Основные данные ── */}
|
||||||
Назад
|
<section className="flex flex-col gap-6">
|
||||||
</button>
|
<Typography variant="h6">Основные данные</Typography>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-start">
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Имя"
|
label="Имя"
|
||||||
@@ -145,7 +248,7 @@ export const UserEditPage = observer(() => {
|
|||||||
editUserData.email || "",
|
editUserData.email || "",
|
||||||
editUserData.password || "",
|
editUserData.password || "",
|
||||||
editUserData.is_admin || false,
|
editUserData.is_admin || false,
|
||||||
editUserData.icon
|
editUserData.icon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -160,11 +263,10 @@ export const UserEditPage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
editUserData.password || "",
|
editUserData.password || "",
|
||||||
editUserData.is_admin || false,
|
editUserData.is_admin || false,
|
||||||
editUserData.icon
|
editUserData.icon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Пароль"
|
label="Пароль"
|
||||||
@@ -176,27 +278,10 @@ export const UserEditPage = observer(() => {
|
|||||||
editUserData.email || "",
|
editUserData.email || "",
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editUserData.is_admin || false,
|
editUserData.is_admin || false,
|
||||||
editUserData.icon
|
editUserData.icon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={editUserData.is_admin || false}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditUserData(
|
|
||||||
editUserData.name || "",
|
|
||||||
editUserData.email || "",
|
|
||||||
editUserData.password || "",
|
|
||||||
e.target.checked,
|
|
||||||
editUserData.icon
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Администратор"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
@@ -218,21 +303,189 @@ export const UserEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Button
|
<Divider />
|
||||||
variant="contained"
|
|
||||||
className="w-min flex gap-2 items-center self-end"
|
{/* ── Права доступа ── */}
|
||||||
startIcon={<Save size={20} />}
|
<section className="flex flex-col gap-4">
|
||||||
onClick={handleEdit}
|
<Typography variant="h6">Права доступа</Typography>
|
||||||
disabled={isLoading || !editUserData.name || !editUserData.email}
|
|
||||||
>
|
<FormControlLabel
|
||||||
{isLoading ? (
|
control={
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Checkbox
|
||||||
) : (
|
checked={localRoles.includes("admin")}
|
||||||
"Сохранить"
|
onChange={(e) => {
|
||||||
)}
|
if (e.target.checked) {
|
||||||
</Button>
|
setLocalRoles((prev) => {
|
||||||
</div>
|
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
|
<SelectMediaDialog
|
||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
@@ -240,7 +493,6 @@ export const UserEditPage = observer(() => {
|
|||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={1}
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
open={isUploadMediaOpen}
|
open={isUploadMediaOpen}
|
||||||
onClose={() => setIsUploadMediaOpen(false)}
|
onClose={() => setIsUploadMediaOpen(false)}
|
||||||
@@ -249,13 +501,11 @@ export const UserEditPage = observer(() => {
|
|||||||
afterUpload={handleMediaSelect}
|
afterUpload={handleMediaSelect}
|
||||||
hardcodeType={activeMenuType}
|
hardcodeType={activeMenuType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PreviewMediaDialog
|
<PreviewMediaDialog
|
||||||
open={isPreviewMediaOpen}
|
open={isPreviewMediaOpen}
|
||||||
onClose={() => setIsPreviewMediaOpen(false)}
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteModal
|
<DeleteModal
|
||||||
open={isDeleteIconModalOpen}
|
open={isDeleteIconModalOpen}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
@@ -264,7 +514,7 @@ export const UserEditPage = observer(() => {
|
|||||||
editUserData.email || "",
|
editUserData.email || "",
|
||||||
editUserData.password || "",
|
editUserData.password || "",
|
||||||
editUserData.is_admin || false,
|
editUserData.is_admin || false,
|
||||||
""
|
"",
|
||||||
);
|
);
|
||||||
setIsDeleteIconModalOpen(false);
|
setIsDeleteIconModalOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { userStore } from "@shared";
|
import { authStore, userStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -20,6 +20,7 @@ export const UserListPage = observer(() => {
|
|||||||
page: 0,
|
page: 0,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
|
const canWriteUsers = authStore.canWrite("users");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -81,44 +82,35 @@ export const UserListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
...(canWriteUsers ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
return (
|
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<Pencil size={20} className="text-blue-500" />
|
||||||
<button>
|
</button>
|
||||||
<Pencil
|
<button
|
||||||
size={20}
|
onClick={() => {
|
||||||
className="text-blue-500"
|
setIsDeleteModalOpen(true);
|
||||||
onClick={() => {
|
setRowId(params.row.id);
|
||||||
navigate(`/user/${params.row.id}/edit`);
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
<Trash2 size={20} className="text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={() => {
|
),
|
||||||
setIsDeleteModalOpen(true);
|
}] : []),
|
||||||
setRowId(params.row.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={20} className="text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = users.data?.map((user) => ({
|
const rows = users.data?.map((user) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
|
||||||
name: user.name,
|
name: user.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -127,7 +119,9 @@ export const UserListPage = observer(() => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Пользователи</h1>
|
<h1 className="text-2xl">Пользователи</h1>
|
||||||
<CreateButton label="Создать пользователя" path="/user/create" />
|
{canWriteUsers && (
|
||||||
|
<CreateButton label="Создать пользователя" path="/user/create" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ids.length > 0 && (
|
{ids.length > 0 && (
|
||||||
@@ -145,7 +139,7 @@ export const UserListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={canWriteUsers}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
import { authStore, carrierStore, languageStore, vehicleStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -104,27 +104,31 @@ export const VehicleListPage = observer(() => {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
width: 200,
|
width: 200,
|
||||||
align: "center",
|
align: "center" as const,
|
||||||
headerAlign: "center",
|
headerAlign: "center" as const,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
const canWrite = authStore.canWrite("devices");
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
{canWrite && (
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
||||||
</button>
|
<Pencil size={20} className="text-blue-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{canWrite && (
|
||||||
onClick={() => {
|
<button
|
||||||
setIsDeleteModalOpen(true);
|
onClick={() => {
|
||||||
setRowId(params.row.id);
|
setIsDeleteModalOpen(true);
|
||||||
}}
|
setRowId(params.row.id);
|
||||||
>
|
}}
|
||||||
<Trash2 size={20} className="text-red-500" />
|
>
|
||||||
</button>
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -167,7 +171,7 @@ export const VehicleListPage = observer(() => {
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection
|
checkboxSelection={authStore.canWrite("devices")}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ const languageInstance = (language: Language) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { authInstance, languageInstance };
|
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;
|
label: string;
|
||||||
icon?: LucideIcon | React.ReactNode;
|
icon?: LucideIcon | React.ReactNode;
|
||||||
path?: string;
|
path?: string;
|
||||||
for_admin?: boolean;
|
requiredRoles?: string[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
nestedItems?: NavigationItem[];
|
nestedItems?: NavigationItem[];
|
||||||
isActive?: boolean;
|
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: {
|
export const NAVIGATION_ITEMS: {
|
||||||
primary: NavigationItem[];
|
primary: NavigationItem[];
|
||||||
secondary: NavigationItem[];
|
secondary: NavigationItem[];
|
||||||
@@ -39,7 +90,7 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Экспорт",
|
label: "Экспорт",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
path: "/snapshot",
|
path: "/snapshot",
|
||||||
for_admin: true,
|
requiredRoles: ["snapshot_rw", "snapshot_create"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "map",
|
id: "map",
|
||||||
@@ -52,14 +103,14 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Устройства",
|
label: "Устройства",
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
path: "/devices",
|
path: "/devices",
|
||||||
for_admin: true,
|
requiredRoles: ["devices_ro", "devices_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
label: "Пользователи",
|
label: "Пользователи",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/user",
|
path: "/user",
|
||||||
for_admin: true,
|
requiredRoles: ["users_ro", "users_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "all",
|
id: "all",
|
||||||
@@ -71,18 +122,21 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Достопримечательности",
|
label: "Достопримечательности",
|
||||||
icon: Landmark,
|
icon: Landmark,
|
||||||
path: "/sight",
|
path: "/sight",
|
||||||
|
requiredRoles: ["sights_ro", "sights_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stations",
|
id: "stations",
|
||||||
label: "Остановки",
|
label: "Остановки",
|
||||||
icon: PersonStanding,
|
icon: PersonStanding,
|
||||||
path: "/station",
|
path: "/station",
|
||||||
|
requiredRoles: ["stations_ro", "stations_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "routes",
|
id: "routes",
|
||||||
label: "Маршруты",
|
label: "Маршруты",
|
||||||
icon: Split,
|
icon: Split,
|
||||||
path: "/route",
|
path: "/route",
|
||||||
|
requiredRoles: ["routes_ro", "routes_rw"],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -90,14 +144,14 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Страны",
|
label: "Страны",
|
||||||
icon: Earth,
|
icon: Earth,
|
||||||
path: "/country",
|
path: "/country",
|
||||||
for_admin: true,
|
requiredRoles: ["countries_ro", "countries_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cities",
|
id: "cities",
|
||||||
label: "Города",
|
label: "Города",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
path: "/city",
|
path: "/city",
|
||||||
for_admin: true,
|
requiredRoles: ["cities_ro", "cities_rw"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "carriers",
|
id: "carriers",
|
||||||
@@ -105,7 +159,7 @@ export const NAVIGATION_ITEMS: {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||||
path: "/carrier",
|
path: "/carrier",
|
||||||
for_admin: true,
|
requiredRoles: ["carriers_ro", "carriers_rw"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -123,6 +177,20 @@ 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 = [
|
export const VEHICLE_TYPES = [
|
||||||
{ label: "Автобус", value: 3 },
|
{ label: "Автобус", value: 3 },
|
||||||
{ label: "Троллейбус", value: 2 },
|
{ label: "Троллейбус", value: 2 },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./mui/theme";
|
export * from "./mui/theme";
|
||||||
export * from "./DecodeJWT";
|
export * from "./DecodeJWT";
|
||||||
export * from "./gltfCacheManager";
|
export * from "./gltfCacheManager";
|
||||||
|
export * from "./permissions";
|
||||||
|
|
||||||
export const generateDefaultMediaName = (
|
export const generateDefaultMediaName = (
|
||||||
objectName: string,
|
objectName: string,
|
||||||
|
|||||||
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),
|
||||||
|
});
|
||||||
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 { makeAutoObservable, runInAction } from "mobx";
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { User, UserCity } from "../UserStore";
|
||||||
|
import { getMeApi, getMeCitiesApi } from "./api";
|
||||||
|
|
||||||
type LoginResponse = {
|
type LoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
user: {
|
user: Pick<User, "id" | "name" | "email" | "is_admin" | "cities">;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
is_admin: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class AuthStore {
|
class AuthStore {
|
||||||
@@ -48,7 +46,7 @@ class AuthStore {
|
|||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
@@ -89,6 +87,78 @@ class AuthStore {
|
|||||||
get user() {
|
get user() {
|
||||||
return this.payload?.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 looks like a concrete role (e.g. snapshot_create/snapshot_rw),
|
||||||
|
// check it as-is; otherwise treat it as a resource name.
|
||||||
|
if (permission.includes("_")) {
|
||||||
|
return this.hasRole(permission);
|
||||||
|
}
|
||||||
|
return this.canRead(permission);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authStore = new AuthStore();
|
export const authStore = new AuthStore();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
authInstance,
|
authInstance,
|
||||||
|
authStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
languageInstance,
|
languageInstance,
|
||||||
@@ -145,12 +146,51 @@ 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 () => {
|
createCarrier = async () => {
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const cityName =
|
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
|
||||||
cityStore.cities[language].data.find(
|
|
||||||
(city) => city.id === this.createCarrierData.city_id
|
|
||||||
)?.name || "";
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
full_name: this.createCarrierData[language].full_name,
|
full_name: this.createCarrierData[language].full_name,
|
||||||
@@ -172,12 +212,16 @@ class CarrierStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||||
|
const cityNameForLang = this.resolveCityName(
|
||||||
|
this.createCarrierData.city_id,
|
||||||
|
lang as Language
|
||||||
|
);
|
||||||
const patchPayload = {
|
const patchPayload = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
full_name: this.createCarrierData[lang as any].full_name as string,
|
full_name: this.createCarrierData[lang as any].full_name as string,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
short_name: this.createCarrierData[lang as any].short_name as string,
|
short_name: this.createCarrierData[lang as any].short_name as string,
|
||||||
city: cityName,
|
city: cityNameForLang || cityName,
|
||||||
city_id: this.createCarrierData.city_id,
|
city_id: this.createCarrierData.city_id,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
slogan: this.createCarrierData[lang as any].slogan as string,
|
slogan: this.createCarrierData[lang as any].slogan as string,
|
||||||
@@ -273,13 +317,8 @@ class CarrierStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
editCarrier = async (id: number) => {
|
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) {
|
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}`, {
|
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
|
||||||
...this.editCarrierData[lang],
|
...this.editCarrierData[lang],
|
||||||
city: cityName,
|
city: cityName,
|
||||||
|
|||||||
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 { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
import { addUserCityApi } from "./api";
|
||||||
|
|
||||||
|
export type UserCity = {
|
||||||
|
city_id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -8,6 +14,8 @@ export type User = {
|
|||||||
name: string;
|
name: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
roles?: string[];
|
||||||
|
cities?: UserCity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
class UserStore {
|
class UserStore {
|
||||||
@@ -59,6 +67,7 @@ class UserStore {
|
|||||||
password: "",
|
password: "",
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
icon: "",
|
icon: "",
|
||||||
|
roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
|
||||||
};
|
};
|
||||||
|
|
||||||
setCreateUserData = (
|
setCreateUserData = (
|
||||||
@@ -66,9 +75,10 @@ class UserStore {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
is_admin: boolean,
|
is_admin: boolean,
|
||||||
icon?: string
|
icon?: string,
|
||||||
) => {
|
) => {
|
||||||
this.createUserData = {
|
this.createUserData = {
|
||||||
|
...this.createUserData,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -82,7 +92,13 @@ class UserStore {
|
|||||||
if (this.users.data.length > 0) {
|
if (this.users.data.length > 0) {
|
||||||
id = this.users.data[this.users.data.length - 1].id + 1;
|
id = this.users.data[this.users.data.length - 1].id + 1;
|
||||||
}
|
}
|
||||||
const payload = { ...this.createUserData };
|
const payload: Partial<User> = { ...this.createUserData };
|
||||||
|
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;
|
if (!payload.icon) delete payload.icon;
|
||||||
const response = await authInstance.post("/user", payload);
|
const response = await authInstance.post("/user", payload);
|
||||||
|
|
||||||
@@ -100,6 +116,7 @@ class UserStore {
|
|||||||
password: "",
|
password: "",
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
icon: "",
|
icon: "",
|
||||||
|
roles: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditUserData = (
|
setEditUserData = (
|
||||||
@@ -107,9 +124,10 @@ class UserStore {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
is_admin: boolean,
|
is_admin: boolean,
|
||||||
icon?: string
|
icon?: string,
|
||||||
) => {
|
) => {
|
||||||
this.editUserData = {
|
this.editUserData = {
|
||||||
|
...this.editUserData,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -118,19 +136,50 @@ class UserStore {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setEditUserRoles = (roles: string[]) => {
|
||||||
|
this.editUserData = { ...this.editUserData, roles };
|
||||||
|
};
|
||||||
|
|
||||||
editUser = async (id: number) => {
|
editUser = async (id: number) => {
|
||||||
const payload = { ...this.editUserData };
|
const payload = { ...this.editUserData };
|
||||||
if (!payload.icon) delete payload.icon;
|
if (!payload.icon) delete payload.icon;
|
||||||
if (!payload.password?.trim()) delete payload.password;
|
if (!payload.password?.trim()) delete payload.password;
|
||||||
|
|
||||||
const response = await authInstance.patch(`/user/${id}`, payload);
|
const response = await authInstance.patch(`/user/${id}`, payload);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.users.data = this.users.data.map((user) =>
|
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 };
|
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();
|
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,30 +1,9 @@
|
|||||||
import { authInstance, languageInstance } from "@shared";
|
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
import { getVehicleSessionsApi } from "./api";
|
||||||
|
import { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||||
|
|
||||||
export type Vehicle = {
|
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
class VehicleStore {
|
class VehicleStore {
|
||||||
vehicles: {
|
vehicles: {
|
||||||
@@ -35,6 +14,9 @@ class VehicleStore {
|
|||||||
loaded: false,
|
loaded: false,
|
||||||
};
|
};
|
||||||
vehicle: Record<string, Vehicle> = {};
|
vehicle: Record<string, Vehicle> = {};
|
||||||
|
vehicleSessions: VehicleMaintenanceSession[] | null = null;
|
||||||
|
vehicleSessionsLoading = false;
|
||||||
|
vehicleSessionsError: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -89,7 +71,7 @@ class VehicleStore {
|
|||||||
|
|
||||||
if (updatedUuid != null) {
|
if (updatedUuid != null) {
|
||||||
const entry = Object.entries(this.vehicle).find(
|
const entry = Object.entries(this.vehicle).find(
|
||||||
([, item]) => item.vehicle.uuid === updatedUuid
|
([, item]) => item.vehicle.uuid === updatedUuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
@@ -118,7 +100,7 @@ class VehicleStore {
|
|||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.vehicles.data = this.vehicles.data.filter(
|
this.vehicles.data = this.vehicles.data.filter(
|
||||||
(vehicle) => vehicle.vehicle.id !== id
|
(vehicle) => vehicle.vehicle.id !== id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -137,7 +119,7 @@ class VehicleStore {
|
|||||||
type: number,
|
type: number,
|
||||||
carrier: string,
|
carrier: string,
|
||||||
carrierId: number,
|
carrierId: number,
|
||||||
model?: string
|
model?: string,
|
||||||
) => {
|
) => {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
tail_number: tailNumber,
|
tail_number: tailNumber,
|
||||||
@@ -197,7 +179,7 @@ class VehicleStore {
|
|||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
snapshot_update_blocked?: boolean;
|
snapshot_update_blocked?: boolean;
|
||||||
}
|
},
|
||||||
) => {
|
) => {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
tail_number: data.tail_number,
|
tail_number: data.tail_number,
|
||||||
@@ -210,7 +192,7 @@ class VehicleStore {
|
|||||||
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
||||||
const response = await languageInstance("ru").patch(
|
const response = await languageInstance("ru").patch(
|
||||||
`/vehicle/${id}`,
|
`/vehicle/${id}`,
|
||||||
payload
|
payload,
|
||||||
);
|
);
|
||||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
const updatedVehiclePayload = {
|
const updatedVehiclePayload = {
|
||||||
@@ -230,9 +212,12 @@ class VehicleStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||||
const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
|
const response = await authInstance.post(
|
||||||
enabled,
|
`/devices/${uuid}/maintenance-mode`,
|
||||||
});
|
{
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
@@ -255,10 +240,24 @@ class VehicleStore {
|
|||||||
this.mergeVehicleInCaches({
|
this.mergeVehicleInCaches({
|
||||||
...normalizedVehicle.vehicle,
|
...normalizedVehicle.vehicle,
|
||||||
uuid,
|
uuid,
|
||||||
demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
|
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();
|
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;
|
||||||
|
};
|
||||||
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}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export * from "./Modal";
|
|||||||
export * from "./CoordinatesInput";
|
export * from "./CoordinatesInput";
|
||||||
export * from "./AnimatedCircleButton";
|
export * from "./AnimatedCircleButton";
|
||||||
export * from "./LoadingSpinner";
|
export * from "./LoadingSpinner";
|
||||||
|
export * from "./MultiSelect";
|
||||||
|
|||||||
@@ -8,16 +8,40 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { cityStore, selectedCityStore } from "@shared";
|
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
|
|
||||||
export const CitySelector: React.FC = observer(() => {
|
export const CitySelector: React.FC = observer(() => {
|
||||||
const { getCities, cities } = cityStore;
|
|
||||||
const { selectedCity, setSelectedCity } = selectedCityStore;
|
const { selectedCity, setSelectedCity } = selectedCityStore;
|
||||||
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCities("ru");
|
if (canReadCities) {
|
||||||
}, []);
|
cityStore.getCities("ru");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authStore.fetchMeCities().catch(() => undefined);
|
||||||
|
}, [canReadCities]);
|
||||||
|
|
||||||
|
const baseCities: City[] = canReadCities
|
||||||
|
? 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 handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||||
const cityId = event.target.value;
|
const cityId = event.target.value;
|
||||||
@@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
|
const city = currentCities.find((c) => c.id === Number(cityId));
|
||||||
if (city) {
|
if (city) {
|
||||||
setSelectedCity(city);
|
setSelectedCity(city);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentCities = cities["ru"].data;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
<MapPin size={16} className="text-white" />
|
<MapPin size={16} className="text-white" />
|
||||||
@@ -51,16 +73,13 @@ export const CitySelector: React.FC = observer(() => {
|
|||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||||
},
|
},
|
||||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
|
||||||
borderColor: "white",
|
borderColor: "white",
|
||||||
},
|
},
|
||||||
"& .MuiSvgIcon-root": {
|
"& .MuiSvgIcon-root": {
|
||||||
color: "white",
|
color: "white",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
MenuProps={{
|
|
||||||
PaperProps: {},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<Typography variant="body2">Выберите город</Typography>
|
<Typography variant="body2">Выберите город</Typography>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import {
|
import {
|
||||||
|
authStore,
|
||||||
authInstance,
|
authInstance,
|
||||||
devicesStore,
|
devicesStore,
|
||||||
Modal,
|
Modal,
|
||||||
snapshotStore,
|
snapshotStore,
|
||||||
vehicleStore,
|
vehicleStore,
|
||||||
|
routeStore,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
carrierStore,
|
carrierStore,
|
||||||
selectedCityStore,
|
selectedCityStore,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +38,7 @@ import { toast } from "react-toastify";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { DeleteModal } from "@widgets";
|
import { DeleteModal } from "@widgets";
|
||||||
import { DeviceLogsModal } from "./DeviceLogsModal";
|
import { DeviceLogsModal } from "./DeviceLogsModal";
|
||||||
|
import { VehicleSessionsModal } from "./VehicleSessionsModal";
|
||||||
|
|
||||||
export type ConnectedDevice = string;
|
export type ConnectedDevice = string;
|
||||||
|
|
||||||
@@ -77,6 +81,13 @@ type RowData = {
|
|||||||
snapshot_update_blocked: boolean;
|
snapshot_update_blocked: boolean;
|
||||||
maintenance_mode_on: boolean;
|
maintenance_mode_on: boolean;
|
||||||
demo_mode_enabled: boolean;
|
demo_mode_enabled: boolean;
|
||||||
|
current_route_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingModeToggle = {
|
||||||
|
deviceUuid: string;
|
||||||
|
nextEnabled: boolean;
|
||||||
|
tailNumber: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getVehicleTypeLabel(vehicle: Vehicle): string {
|
function getVehicleTypeLabel(vehicle: Vehicle): string {
|
||||||
@@ -109,11 +120,13 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
|
|||||||
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
|
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
|
||||||
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
|
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
|
||||||
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
|
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
|
||||||
|
current_route_id: vehicle.device_status?.current_route_id ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DevicesTable = observer(() => {
|
export const DevicesTable = observer(() => {
|
||||||
|
const canWriteDevices = authStore.canWrite("devices");
|
||||||
const {
|
const {
|
||||||
getDevices,
|
getDevices,
|
||||||
setSelectedDevice,
|
setSelectedDevice,
|
||||||
@@ -123,6 +136,7 @@ export const DevicesTable = observer(() => {
|
|||||||
} = devicesStore;
|
} = devicesStore;
|
||||||
|
|
||||||
const { snapshots, getSnapshots } = snapshotStore;
|
const { snapshots, getSnapshots } = snapshotStore;
|
||||||
|
const { routes, getRoutes } = routeStore;
|
||||||
const {
|
const {
|
||||||
getVehicles,
|
getVehicles,
|
||||||
vehicles,
|
vehicles,
|
||||||
@@ -137,6 +151,12 @@ export const DevicesTable = observer(() => {
|
|||||||
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
|
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [sessionsModalOpen, setSessionsModalOpen] = useState(false);
|
||||||
|
const [sessionsModalVehicleId, setSessionsModalVehicleId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [sessionsModalVehicleTailNumber, setSessionsModalVehicleTailNumber] =
|
||||||
|
useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
|
const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
|
||||||
Set<string>
|
Set<string>
|
||||||
@@ -144,6 +164,14 @@ export const DevicesTable = observer(() => {
|
|||||||
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
|
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
const [maintenanceConfirm, setMaintenanceConfirm] =
|
||||||
|
useState<PendingModeToggle | null>(null);
|
||||||
|
const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [maintenanceConfirmSubmitting, setMaintenanceConfirmSubmitting] =
|
||||||
|
useState(false);
|
||||||
|
const [demoConfirmSubmitting, setDemoConfirmSubmitting] = useState(false);
|
||||||
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
|
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
@@ -223,65 +251,108 @@ export const DevicesTable = observer(() => {
|
|||||||
.map((r) => r.device_uuid as string);
|
.map((r) => r.device_uuid as string);
|
||||||
}, [rows, selectedIds]);
|
}, [rows, selectedIds]);
|
||||||
|
|
||||||
const handleToggleMaintenanceMode = async (row: RowData) => {
|
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
|
||||||
if (!row.device_uuid) return;
|
|
||||||
|
|
||||||
const nextEnabled = !row.maintenance_mode_on;
|
|
||||||
setMaintenanceLoadingUuids((prev) => {
|
setMaintenanceLoadingUuids((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(row.device_uuid!);
|
next.add(toggle.deviceUuid);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setMaintenanceMode(row.device_uuid, nextEnabled);
|
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
|
||||||
await getVehicles();
|
await getVehicles();
|
||||||
await getDevices();
|
await getDevices();
|
||||||
toast.success(
|
toast.success(
|
||||||
nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен",
|
toggle.nextEnabled
|
||||||
|
? "Устройство отправлено на ТО"
|
||||||
|
: "Режим ТО отключен",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error toggling maintenance mode for ${row.device_uuid}:`,
|
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
toast.error("Не удалось изменить режим ТО");
|
toast.error("Не удалось изменить режим ТО");
|
||||||
} finally {
|
} finally {
|
||||||
setMaintenanceLoadingUuids((prev) => {
|
setMaintenanceLoadingUuids((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(row.device_uuid!);
|
next.delete(toggle.deviceUuid);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleDemoMode = async (row: RowData) => {
|
const applyDemoMode = async (toggle: PendingModeToggle) => {
|
||||||
if (!row.device_uuid) return;
|
|
||||||
|
|
||||||
const nextEnabled = !row.demo_mode_enabled;
|
|
||||||
setDemoLoadingUuids((prev) => {
|
setDemoLoadingUuids((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(row.device_uuid!);
|
next.add(toggle.deviceUuid);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDemoMode(row.device_uuid, nextEnabled);
|
await setDemoMode(toggle.deviceUuid, toggle.nextEnabled);
|
||||||
await getVehicles();
|
await getVehicles();
|
||||||
await getDevices();
|
await getDevices();
|
||||||
toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен");
|
toast.success(
|
||||||
|
toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error toggling demo mode for ${row.device_uuid}:`, error);
|
console.error(
|
||||||
|
`Error toggling demo mode for ${toggle.deviceUuid}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
toast.error("Не удалось изменить демо-режим");
|
toast.error("Не удалось изменить демо-режим");
|
||||||
} finally {
|
} finally {
|
||||||
setDemoLoadingUuids((prev) => {
|
setDemoLoadingUuids((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(row.device_uuid!);
|
next.delete(toggle.deviceUuid);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMaintenanceConfirm = (row: RowData) => {
|
||||||
|
if (!row.device_uuid) return;
|
||||||
|
setMaintenanceConfirm({
|
||||||
|
deviceUuid: row.device_uuid,
|
||||||
|
nextEnabled: !row.maintenance_mode_on,
|
||||||
|
tailNumber: row.tail_number,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDemoConfirm = (row: RowData) => {
|
||||||
|
if (!row.device_uuid) return;
|
||||||
|
setDemoConfirm({
|
||||||
|
deviceUuid: row.device_uuid,
|
||||||
|
nextEnabled: !row.demo_mode_enabled,
|
||||||
|
tailNumber: row.tail_number,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmMaintenanceToggle = async () => {
|
||||||
|
if (!maintenanceConfirm) return;
|
||||||
|
|
||||||
|
setMaintenanceConfirmSubmitting(true);
|
||||||
|
try {
|
||||||
|
await applyMaintenanceMode(maintenanceConfirm);
|
||||||
|
setMaintenanceConfirm(null);
|
||||||
|
} finally {
|
||||||
|
setMaintenanceConfirmSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDemoToggle = async () => {
|
||||||
|
if (!demoConfirm) return;
|
||||||
|
|
||||||
|
setDemoConfirmSubmitting(true);
|
||||||
|
try {
|
||||||
|
await applyDemoMode(demoConfirm);
|
||||||
|
setDemoConfirm(null);
|
||||||
|
} finally {
|
||||||
|
setDemoConfirmSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(
|
const columns: GridColDef[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -375,10 +446,14 @@ export const DevicesTable = observer(() => {
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={rowData.maintenance_mode_on}
|
checked={rowData.maintenance_mode_on}
|
||||||
disabled={!rowData.device_uuid || isMaintenanceLoading}
|
disabled={
|
||||||
|
!rowData.device_uuid ||
|
||||||
|
isMaintenanceLoading ||
|
||||||
|
maintenanceConfirmSubmitting
|
||||||
|
}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ p: 0 }}
|
sx={{ p: 0 }}
|
||||||
onChange={() => handleToggleMaintenanceMode(rowData)}
|
onChange={() => openMaintenanceConfirm(rowData)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -404,10 +479,12 @@ export const DevicesTable = observer(() => {
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={rowData.demo_mode_enabled}
|
checked={rowData.demo_mode_enabled}
|
||||||
disabled={!rowData.device_uuid || isDemoLoading}
|
disabled={
|
||||||
|
!rowData.device_uuid || isDemoLoading || demoConfirmSubmitting
|
||||||
|
}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ p: 0 }}
|
sx={{ p: 0 }}
|
||||||
onChange={() => handleToggleDemoMode(rowData)}
|
onChange={() => openDemoConfirm(rowData)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -436,6 +513,20 @@ export const DevicesTable = observer(() => {
|
|||||||
return snapshot?.Name ?? uuid;
|
return snapshot?.Name ?? uuid;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: "current_route",
|
||||||
|
headerName: "Текущий маршрут",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 140,
|
||||||
|
filterable: true,
|
||||||
|
valueGetter: (_value, row) => {
|
||||||
|
const rowData = row as RowData;
|
||||||
|
const routeId = rowData.current_route_id;
|
||||||
|
if (!routeId) return "—";
|
||||||
|
const route = routes.data.find((r) => r.id === routeId);
|
||||||
|
return route?.route_number || "—";
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "gps",
|
field: "gps",
|
||||||
headerName: "GPS",
|
headerName: "GPS",
|
||||||
@@ -496,15 +587,17 @@ export const DevicesTable = observer(() => {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{canWriteDevices && (
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
e.stopPropagation();
|
||||||
}}
|
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
||||||
title="Редактировать транспорт"
|
}}
|
||||||
>
|
title="Редактировать транспорт"
|
||||||
<Pencil size={16} />
|
>
|
||||||
</button>
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -529,6 +622,17 @@ export const DevicesTable = observer(() => {
|
|||||||
>
|
>
|
||||||
<Copy size={16} />
|
<Copy size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSessionsModalVehicleId(row.vehicle_id);
|
||||||
|
setSessionsModalVehicleTailNumber(row.tail_number);
|
||||||
|
setSessionsModalOpen(true);
|
||||||
|
}}
|
||||||
|
title="Сессии ТО"
|
||||||
|
>
|
||||||
|
<Wrench size={16} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -557,35 +661,44 @@ export const DevicesTable = observer(() => {
|
|||||||
setLogsModalOpen,
|
setLogsModalOpen,
|
||||||
maintenanceLoadingUuids,
|
maintenanceLoadingUuids,
|
||||||
demoLoadingUuids,
|
demoLoadingUuids,
|
||||||
setMaintenanceMode,
|
openMaintenanceConfirm,
|
||||||
setDemoMode,
|
openDemoConfirm,
|
||||||
handleToggleMaintenanceMode,
|
maintenanceConfirmSubmitting,
|
||||||
handleToggleDemoMode,
|
demoConfirmSubmitting,
|
||||||
|
routes,
|
||||||
|
canWriteDevices,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await getVehicles();
|
await Promise.all([
|
||||||
await getDevices();
|
getVehicles(),
|
||||||
await getSnapshots();
|
getDevices(),
|
||||||
|
getSnapshots(),
|
||||||
|
getRoutes(),
|
||||||
|
]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [getDevices, getSnapshots, getVehicles]);
|
}, [getDevices, getSnapshots, getVehicles, getRoutes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
carrierStore.getCarriers("ru");
|
carrierStore.getCarriers("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenSendSnapshotModal = () => {
|
const handleOpenSendSnapshotModal = () => {
|
||||||
|
if (!canWriteDevices) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedDeviceUuidsAllowed.length > 0) {
|
if (selectedDeviceUuidsAllowed.length > 0) {
|
||||||
toggleSendSnapshotModal();
|
toggleSendSnapshotModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendSnapshotAction = async (snapshotId: string) => {
|
const handleSendSnapshotAction = async (snapshotId: string) => {
|
||||||
|
if (!canWriteDevices) return;
|
||||||
if (selectedDeviceUuidsAllowed.length === 0) return;
|
if (selectedDeviceUuidsAllowed.length === 0) return;
|
||||||
|
|
||||||
const blockedCount =
|
const blockedCount =
|
||||||
@@ -658,14 +771,16 @@ export const DevicesTable = observer(() => {
|
|||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex justify-end mb-5 gap-2 flex-wrap">
|
<div className="flex justify-end mb-5 gap-2 flex-wrap">
|
||||||
<Button
|
{canWriteDevices && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="primary"
|
variant="contained"
|
||||||
size="small"
|
color="primary"
|
||||||
onClick={() => navigate("/vehicle/create")}
|
size="small"
|
||||||
>
|
onClick={() => navigate("/vehicle/create")}
|
||||||
Добавить устройство
|
>
|
||||||
</Button>
|
Добавить устройство
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -677,18 +792,21 @@ export const DevicesTable = observer(() => {
|
|||||||
Удалить ({selectedIds.length})
|
Удалить ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{canWriteDevices && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="primary"
|
variant="contained"
|
||||||
disabled={selectedDeviceUuidsAllowed.length === 0}
|
color="primary"
|
||||||
onClick={handleOpenSendSnapshotModal}
|
disabled={selectedDeviceUuidsAllowed.length === 0}
|
||||||
size="small"
|
onClick={handleOpenSendSnapshotModal}
|
||||||
>
|
size="small"
|
||||||
Обновление ПО ({selectedDeviceUuidsAllowed.length}
|
>
|
||||||
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length &&
|
Обновление ПО ({selectedDeviceUuidsAllowed.length}
|
||||||
`/${selectedDeviceUuids.length}`}
|
{selectedDeviceUuids.length !==
|
||||||
)
|
selectedDeviceUuidsAllowed.length &&
|
||||||
</Button>
|
`/${selectedDeviceUuids.length}`}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groupsByModel.length === 0 ? (
|
{groupsByModel.length === 0 ? (
|
||||||
@@ -787,52 +905,59 @@ export const DevicesTable = observer(() => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
|
{canWriteDevices && (
|
||||||
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
|
<Modal
|
||||||
Обновление ПО
|
open={sendSnapshotModalOpen}
|
||||||
</Box>
|
onClose={toggleSendSnapshotModal}
|
||||||
<Box sx={{ mb: 2 }}>
|
sx={{ width: "min(760px, 94vw)", p: 3 }}
|
||||||
Выбрано устройств для обновления:{" "}
|
|
||||||
<strong className="text-blue-600">
|
|
||||||
{selectedDeviceUuidsAllowed.length}
|
|
||||||
</strong>
|
|
||||||
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length && (
|
|
||||||
<span className="text-amber-600 ml-1">
|
|
||||||
(пропущено{" "}
|
|
||||||
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length} с
|
|
||||||
блокировкой)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
|
|
||||||
{snapshots && (snapshots as Snapshot[]).length > 0 ? (
|
|
||||||
(snapshots as Snapshot[]).map((snapshot) => (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => handleSendSnapshotAction(snapshot.ID)}
|
|
||||||
key={snapshot.ID}
|
|
||||||
sx={{ justifyContent: "flex-start" }}
|
|
||||||
>
|
|
||||||
{snapshot.Name}
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Box sx={{ typography: "body2", color: "text.secondary" }}>
|
|
||||||
Нет доступных экспортов медиа.
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={toggleSendSnapshotModal}
|
|
||||||
color="inherit"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ mt: 3 }}
|
|
||||||
fullWidth
|
|
||||||
>
|
>
|
||||||
Отмена
|
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
|
||||||
</Button>
|
Обновление ПО
|
||||||
</Modal>
|
</Box>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
Выбрано устройств для обновления:{" "}
|
||||||
|
<strong className="text-blue-600">
|
||||||
|
{selectedDeviceUuidsAllowed.length}
|
||||||
|
</strong>
|
||||||
|
{selectedDeviceUuids.length !==
|
||||||
|
selectedDeviceUuidsAllowed.length && (
|
||||||
|
<span className="text-amber-600 ml-1">
|
||||||
|
(пропущено{" "}
|
||||||
|
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "}
|
||||||
|
с блокировкой)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
|
||||||
|
{snapshots && (snapshots as Snapshot[]).length > 0 ? (
|
||||||
|
(snapshots as Snapshot[]).map((snapshot) => (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => handleSendSnapshotAction(snapshot.ID)}
|
||||||
|
key={snapshot.ID}
|
||||||
|
sx={{ justifyContent: "flex-start" }}
|
||||||
|
>
|
||||||
|
{snapshot.Name}
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Box sx={{ typography: "body2", color: "text.secondary" }}>
|
||||||
|
Нет доступных экспортов медиа.
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={toggleSendSnapshotModal}
|
||||||
|
color="inherit"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
<DeleteModal
|
<DeleteModal
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
@@ -840,6 +965,78 @@ export const DevicesTable = observer(() => {
|
|||||||
onCancel={() => setIsDeleteModalOpen(false)}
|
onCancel={() => setIsDeleteModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={maintenanceConfirm != null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!maintenanceConfirmSubmitting) setMaintenanceConfirm(null);
|
||||||
|
}}
|
||||||
|
sx={{ width: "min(640px, 92vw)", p: 3 }}
|
||||||
|
>
|
||||||
|
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
|
||||||
|
Подтверждение режима ТО
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{maintenanceConfirm?.nextEnabled
|
||||||
|
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
|
||||||
|
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
disabled={maintenanceConfirmSubmitting}
|
||||||
|
onClick={() => setMaintenanceConfirm(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={maintenanceConfirmSubmitting}
|
||||||
|
onClick={handleConfirmMaintenanceToggle}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={demoConfirm != null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!demoConfirmSubmitting) setDemoConfirm(null);
|
||||||
|
}}
|
||||||
|
sx={{ width: "min(640px, 92vw)", p: 3 }}
|
||||||
|
>
|
||||||
|
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
|
||||||
|
Подтверждение демо-режима
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{demoConfirm?.nextEnabled
|
||||||
|
? `Включить демо-режим для устройства ${demoConfirm.tailNumber}?`
|
||||||
|
: `Отключить демо-режим для устройства ${demoConfirm?.tailNumber}?`}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
disabled={demoConfirmSubmitting}
|
||||||
|
onClick={() => setDemoConfirm(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={demoConfirmSubmitting}
|
||||||
|
onClick={handleConfirmDemoToggle}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<DeviceLogsModal
|
<DeviceLogsModal
|
||||||
open={logsModalOpen}
|
open={logsModalOpen}
|
||||||
deviceUuid={logsModalDeviceUuid}
|
deviceUuid={logsModalDeviceUuid}
|
||||||
@@ -848,6 +1045,17 @@ export const DevicesTable = observer(() => {
|
|||||||
setLogsModalDeviceUuid(null);
|
setLogsModalDeviceUuid(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<VehicleSessionsModal
|
||||||
|
open={sessionsModalOpen}
|
||||||
|
vehicleId={sessionsModalVehicleId}
|
||||||
|
tailNumber={sessionsModalVehicleTailNumber}
|
||||||
|
onClose={() => {
|
||||||
|
setSessionsModalOpen(false);
|
||||||
|
setSessionsModalVehicleId(null);
|
||||||
|
setSessionsModalVehicleTailNumber(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
|
|||||||
import { Drawer } from "./ui/Drawer";
|
import { Drawer } from "./ui/Drawer";
|
||||||
import { DrawerHeader } from "./ui/DrawerHeader";
|
import { DrawerHeader } from "./ui/DrawerHeader";
|
||||||
import { NavigationList } from "@features";
|
import { NavigationList } from "@features";
|
||||||
import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
|
import { authStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Typography } from "@mui/material";
|
import { Typography } from "@mui/material";
|
||||||
@@ -27,13 +27,10 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
|||||||
setIsMenuOpen(open);
|
setIsMenuOpen(open);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const { getUsers, users } = userStore;
|
const { getMeAction, me } = authStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
getMeAction();
|
||||||
await getUsers();
|
|
||||||
};
|
|
||||||
fetchUsers();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
const handleDrawerOpen = () => {
|
||||||
@@ -68,17 +65,13 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
|||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{(() => {
|
{(() => {
|
||||||
const currentUser = users?.data?.find(
|
const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon);
|
||||||
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
|
|
||||||
);
|
|
||||||
const hasAvatar =
|
|
||||||
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-white">{currentUser?.name}</p>
|
<p className="text-white">{me?.name}</p>
|
||||||
<div
|
<div
|
||||||
className="text-center text-xs"
|
className="text-center text-xs"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,7 +81,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
|||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(authStore.payload as { is_admin?: boolean })?.is_admin
|
{me?.roles?.includes("admin")
|
||||||
? "Администратор"
|
? "Администратор"
|
||||||
: "Режим пользователя"}
|
: "Режим пользователя"}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +91,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
|||||||
<img
|
<img
|
||||||
src={`${
|
src={`${
|
||||||
import.meta.env.VITE_KRBL_MEDIA
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
}${currentUser!.icon}/download?token=${token}`}
|
}${me?.icon}/download?token=${token}`}
|
||||||
alt="Аватар"
|
alt="Аватар"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
BackButton,
|
BackButton,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
languageStore,
|
languageStore,
|
||||||
|
authStore,
|
||||||
Language,
|
Language,
|
||||||
cityStore,
|
cityStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
@@ -40,7 +41,7 @@ import { SaveWithoutCityAgree } from "@widgets";
|
|||||||
|
|
||||||
export const CreateInformationTab = observer(
|
export const CreateInformationTab = observer(
|
||||||
({ value, index }: { value: number; index: number }) => {
|
({ value, index }: { value: number; index: number }) => {
|
||||||
const { cities } = cityStore;
|
const canReadCities = authStore.canRead("cities");
|
||||||
const [mediaId, setMediaId] = useState<string>("");
|
const [mediaId, setMediaId] = useState<string>("");
|
||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
@@ -64,6 +65,30 @@ export const CreateInformationTab = observer(
|
|||||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
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(() => {}, [hardcodeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,17 +233,16 @@ export const CreateInformationTab = observer(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={cities["ru"]?.data ?? []}
|
options={availableCities}
|
||||||
value={
|
value={
|
||||||
cities["ru"]?.data?.find(
|
availableCities.find((city) => city.id === sight.city_id) ?? null
|
||||||
(city) => city.id === sight.city_id
|
|
||||||
) ?? null
|
|
||||||
}
|
}
|
||||||
getOptionLabel={(option) => option.name}
|
getOptionLabel={(option) => option.name}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
setCity(value?.id ?? 0);
|
setCity(value?.id ?? 0);
|
||||||
handleChange({
|
handleChange({
|
||||||
city_id: value?.id ?? 0,
|
city_id: value?.id ?? 0,
|
||||||
|
city: value?.name ?? "",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
BackButton,
|
BackButton,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
languageStore,
|
languageStore,
|
||||||
|
authStore,
|
||||||
Language,
|
Language,
|
||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
@@ -62,10 +63,35 @@ export const InformationTab = observer(
|
|||||||
const [hardcodeType, setHardcodeType] = useState<
|
const [hardcodeType, setHardcodeType] = useState<
|
||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||||
>(null);
|
>(null);
|
||||||
const { cities } = cityStore;
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
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.common.city_id &&
|
||||||
|
!baseCities.some((city) => city.id === sight.common.city_id)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: sight.common.city_id,
|
||||||
|
name: sight.common.city || `Город ${sight.common.city_id}`,
|
||||||
|
country: "",
|
||||||
|
country_code: "",
|
||||||
|
arms: "",
|
||||||
|
},
|
||||||
|
...baseCities,
|
||||||
|
]
|
||||||
|
: baseCities;
|
||||||
|
|
||||||
useEffect(() => {}, [hardcodeType]);
|
useEffect(() => {}, [hardcodeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,11 +234,9 @@ export const InformationTab = observer(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={cities["ru"]?.data ?? []}
|
options={availableCities}
|
||||||
value={
|
value={
|
||||||
cities["ru"]?.data?.find(
|
availableCities.find((city) => city.id === sight.common.city_id) ?? null
|
||||||
(city) => city.id === sight.common.city_id
|
|
||||||
) ?? null
|
|
||||||
}
|
}
|
||||||
getOptionLabel={(option) => option.name}
|
getOptionLabel={(option) => option.name}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
@@ -221,6 +245,7 @@ export const InformationTab = observer(
|
|||||||
language as Language,
|
language as Language,
|
||||||
{
|
{
|
||||||
city_id: value?.id ?? 0,
|
city_id: value?.id ?? 0,
|
||||||
|
city: value?.name ?? "",
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user