feat: role system

This commit is contained in:
2026-03-18 20:11:07 +03:00
parent 73070fe233
commit c3127b8d47
47 changed files with 2425 additions and 768 deletions

6
.env
View File

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

View File

@@ -37,7 +37,7 @@ import {
ArticlePreviewPage,
CountryAddPage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import React, { useEffect } from "react";
@@ -48,6 +48,7 @@ import {
Navigate,
Outlet,
useLocation,
useMatches,
} from "react-router-dom";
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 location = useLocation();
const matches = useMatches();
if (!isAuthenticated && need_auth) {
return <Navigate to="/login" replace />;
}
if (location.pathname === "/") {
if (location.pathname === "/" && authStore.canRead("map")) {
return <Navigate to="/map" replace />;
}
const lastMatch = matches[matches.length - 1] as
| { handle?: { permissions?: string[] } }
| undefined;
const requiredPermissions = lastMatch?.handle?.permissions ?? [];
if (
requiredPermissions.length > 0 &&
!requiredPermissions.every((permission) => authStore.canAccess(permission))
) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
@@ -102,7 +116,10 @@ const router = createBrowserRouter([
</PublicRoute>
),
},
{ path: "route-preview/:id", element: <RoutePreview /> },
{
path: "route-preview/:id",
element: <RoutePreview />,
},
{
path: "/",
element: (
@@ -115,48 +132,258 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
children: [
{ index: true, element: <MainPage /> },
{
index: true,
element: <MainPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/"],
},
},
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> },
{
path: "sight",
element: <SightListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight"],
},
},
{
path: "sight/create",
element: <CreateSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"],
},
},
{
path: "sight/:id/edit",
element: <EditSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"],
},
},
{ path: "devices", element: <DevicesPage /> },
{
path: "devices",
element: <DevicesPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/devices"],
},
},
{ path: "map", element: <MapPage /> },
{
path: "map",
element: <MapPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/map"],
},
},
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
{
path: "media",
element: <MediaListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media"],
},
},
{
path: "media/:id",
element: <MediaPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"],
},
},
{
path: "media/:id/edit",
element: <MediaEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"],
},
},
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> },
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
{ path: "city/:id/edit", element: <CityEditPage /> },
{ path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> },
{
path: "country",
element: <CountryListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country"],
},
},
{
path: "country/create",
element: <CountryCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/create"],
},
},
{
path: "country/add",
element: <CountryAddPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/add"],
},
},
{
path: "country/:id/edit",
element: <CountryEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"],
},
},
{
path: "city",
element: <CityListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city"],
},
},
{
path: "city/create",
element: <CityCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/create"],
},
},
{
path: "city/:id/edit",
element: <CityEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"],
},
},
{
path: "route",
element: <RouteListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route"],
},
},
{
path: "route/create",
element: <RouteCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/create"],
},
},
{
path: "route/:id/edit",
element: <RouteEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"],
},
},
{ path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> },
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
{
path: "user",
element: <UserListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user"],
},
},
{
path: "user/create",
element: <UserCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/create"],
},
},
{
path: "user/:id/edit",
element: <UserEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"],
},
},
{
path: "snapshot",
element: <SnapshotListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"],
},
},
{
path: "snapshot/create",
element: <SnapshotCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"],
},
},
{ path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
{ path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
{ path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> },
{
path: "carrier",
element: <CarrierListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier"],
},
},
{
path: "carrier/create",
element: <CarrierCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"],
},
},
{
path: "carrier/:id/edit",
element: <CarrierEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"],
},
},
{
path: "station",
element: <StationListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station"],
},
},
{
path: "station/create",
element: <StationCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/create"],
},
},
{
path: "station/:id",
element: <StationPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"],
},
},
{
path: "station/:id/edit",
element: <StationEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"],
},
},
{
path: "vehicle/create",
element: <VehicleCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"],
},
},
{
path: "vehicle/:id/edit",
element: <VehicleEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"],
},
},
{
path: "article",
element: <ArticleListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article"],
},
},
{
path: "article/:id",
element: <ArticlePreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"],
},
},
],
},
]);

View File

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

View File

@@ -10,7 +10,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps {
item: NavigationItem;
@@ -31,22 +30,10 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const navigate = useNavigate();
const location = useLocation();
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 filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const filteredNestedItems = item.nestedItems;
const handleClick = () => {
if (item.id === "all" && !open) {

View File

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

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react";
@@ -51,13 +51,12 @@ export const ArticleListPage = observer(() => {
field: "actions",
headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{authStore.canWrite("sights") && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -66,9 +65,9 @@ export const ArticleListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
);
},
),
},
];

View File

@@ -15,6 +15,7 @@ import { toast } from "react-toastify";
import {
carrierStore,
cityStore,
authStore,
mediaStore,
languageStore,
isMediaIdEmpty,
@@ -30,7 +31,8 @@ export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -42,11 +44,37 @@ export const CarrierCreatePage = observer(() => {
>(null);
useEffect(() => {
cityStore.getCities("ru");
const fetchCities = async () => {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
};
fetchCities();
mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
@@ -134,7 +162,7 @@ export const CarrierCreatePage = observer(() => {
)
}
>
{cityStore.cities["ru"].data.map((city) => (
{availableCities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>

View File

@@ -16,6 +16,7 @@ import { toast } from "react-toastify";
import {
carrierStore,
cityStore,
authStore,
mediaStore,
languageStore,
isMediaIdEmpty,
@@ -34,6 +35,7 @@ export const CarrierEditPage = observer(() => {
const { id } = useParams();
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
@@ -42,6 +44,7 @@ export const CarrierEditPage = observer(() => {
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [initialCityName, setInitialCityName] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
@@ -54,9 +57,14 @@ export const CarrierEditPage = observer(() => {
}
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
const carrierData = await getCarrier(Number(id));
if (carrierData) {
@@ -84,6 +92,7 @@ export const CarrierEditPage = observer(() => {
carrierData.zh?.logo || "",
"zh"
);
setInitialCityName(carrierData.ru?.city || "");
}
await mediaStore.getMedia();
@@ -132,6 +141,31 @@ export const CarrierEditPage = observer(() => {
? null
: (selectedMedia?.id ?? editCarrierData.logo);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editCarrierData.city_id &&
!baseCities.some((city) => city.id === editCarrierData.city_id)
? [
{
id: editCarrierData.city_id,
name: initialCityName || `Город ${editCarrierData.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
@@ -181,7 +215,7 @@ export const CarrierEditPage = observer(() => {
)
}
>
{cityStore.cities["ru"].data?.map((city) => (
{availableCities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
@@ -10,7 +10,6 @@ import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
@@ -22,13 +21,19 @@ export const CarrierListPage = observer(() => {
pageSize: 50,
});
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getCities("ru");
await getCities("en");
await getCities("zh");
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities(language);
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getCarriers(language);
setIsLoading(false);
};
@@ -73,36 +78,28 @@ export const CarrierListPage = observer(() => {
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
const lang = language as "ru" | "en" | "zh";
const cityName = canReadCities
? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name
: authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name;
return (
<div className="w-full h-full flex items-center">
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
{cityName ?? <Minus size={20} className="text-red-500" />}
</div>
);
},
},
{
...(authStore.canWrite("carriers") ? [{
field: "actions",
headerName: "Действия",
headerAlign: "center",
headerAlign: "center" as const,
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -112,12 +109,21 @@ export const CarrierListPage = observer(() => {
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
),
}] : []),
];
const rows = carriers[language].data?.map((carrier) => ({
const allowedCityIds = canReadCities
? null
: authStore.meCities["ru"].map((c) => c.city_id);
const canWriteCarriers = authStore.canWrite("carriers");
const rows = 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,
@@ -130,10 +136,12 @@ export const CarrierListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1>
{canWriteCarriers && (
<CreateButton label="Создать перевозчика" path="/carrier/create" />
)}
</div>
{ids.length > 0 && (
{canWriteCarriers && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300">
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
@@ -148,25 +156,33 @@ export const CarrierListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={canWriteCarriers}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection: any) => {
onRowSelectionModelChange={
canWriteCarriers
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
const selectedIds = newSelection.map(Number);
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
const selectedIds = Array.from(idsSet).map(Number);
setIds(selectedIds);
} else {
setIds([]);
}
}}
}
: undefined
}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
@@ -23,6 +23,7 @@ export const CityListPage = observer(() => {
pageSize: 50,
});
const { language } = languageStore;
const canWriteCities = authStore.canWrite("cities");
useEffect(() => {
const fetchData = async () => {
@@ -91,22 +92,18 @@ export const CityListPage = observer(() => {
);
},
},
{
...(authStore.canWrite("cities") ? [{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -117,9 +114,8 @@ export const CityListPage = observer(() => {
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
),
}] : []),
];
return (
@@ -129,7 +125,9 @@ export const CityListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1>
{canWriteCities && (
<CreateButton label="Создать город" path="/city/create" />
)}
</div>
{ids.length > 0 && (
@@ -147,7 +145,7 @@ export const CityListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={authStore.canWrite("cities")}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react";
@@ -21,6 +21,7 @@ export const CountryListPage = observer(() => {
pageSize: 50,
});
const { language } = languageStore;
const canWriteCountries = authStore.canWrite("countries");
useEffect(() => {
const fetchCountries = async () => {
@@ -48,24 +49,15 @@ export const CountryListPage = observer(() => {
);
},
},
{
...(authStore.canWrite("countries") ? [{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
{/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)}
>
<Pencil size={20} className="text-blue-500" />
</button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -76,9 +68,8 @@ export const CountryListPage = observer(() => {
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
),
}] : []),
];
const rows = countries[language]?.data.map((country) => ({
@@ -94,7 +85,9 @@ export const CountryListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
{canWriteCountries && (
<CreateButton label="Добавить страну" path="/country/add" />
)}
</div>
{ids.length > 0 && (
@@ -112,7 +105,7 @@ export const CountryListPage = observer(() => {
<DataGrid
rows={rows || []}
columns={columns}
checkboxSelection
checkboxSelection={authStore.canWrite("countries")}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

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

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import {
articlesStore,
authStore,
cityStore,
editSightStore,
LoadingSpinner,
@@ -41,7 +42,14 @@ export const EditSightPage = observer(() => {
if (id) {
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");

View File

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

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react";
@@ -71,16 +71,15 @@ export const MediaListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 200,
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{authStore.canWrite("sights") && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -89,9 +88,9 @@ export const MediaListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
);
},
),
},
];
@@ -119,7 +118,7 @@ export const MediaListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={authStore.canWrite("sights")}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
@@ -108,19 +108,28 @@ export const RouteListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 250,
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const canWrite = authStore.canWrite("routes");
const canShowRoutePreview =
authStore.canRead("stations") &&
authStore.canRead("sights") &&
authStore.canRead("routes");
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWrite && (
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
)}
{canShowRoutePreview && (
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
)}
{canWrite && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -129,6 +138,7 @@ export const RouteListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
);
},
@@ -168,7 +178,7 @@ export const RouteListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={authStore.canWrite("routes")}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

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

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
@@ -10,6 +10,9 @@ import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot = authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
@@ -57,15 +60,13 @@ export const SnapshotListPage = observer(() => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
{
...(canManageSnapshots ? [{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center",
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
@@ -75,7 +76,6 @@ export const SnapshotListPage = observer(() => {
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -85,9 +85,8 @@ export const SnapshotListPage = observer(() => {
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
),
}] : []),
];
const rows = snapshots.map((snapshot) => ({
@@ -102,7 +101,9 @@ export const SnapshotListPage = observer(() => {
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && (
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
)}
</div>
<DataGrid
rows={rows}

View File

@@ -15,6 +15,7 @@ import {
stationsStore,
languageStore,
cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
useSelectedCity,
@@ -39,7 +40,8 @@ export const StationCreatePage = observer(() => {
createStation,
setLanguageCreateStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const { getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -104,15 +106,35 @@ export const StationCreatePage = observer(() => {
useEffect(() => {
const fetchCities = async () => {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
await getCities("en");
await getCities("zh");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
};
fetchCities();
mediaStore.getMedia();
}, []);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
const handleMediaSelect = (media: {
id: string;
filename: string;
@@ -229,7 +251,7 @@ export const StationCreatePage = observer(() => {
value={createStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities["ru"].data.find(
const selectedCity = availableCities.find(
(city) => city.id === e.target.value
);
setCreateCommonData({
@@ -238,7 +260,7 @@ export const StationCreatePage = observer(() => {
});
}}
>
{cities["ru"].data.map((city) => (
{availableCities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>

View File

@@ -15,6 +15,7 @@ import {
stationsStore,
languageStore,
cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
LoadingSpinner,
@@ -44,7 +45,8 @@ export const StationEditPage = observer(() => {
editStation,
setLanguageEditStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const { getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -138,9 +140,14 @@ export const StationEditPage = observer(() => {
try {
const stationId = Number(id);
await getEditStation(stationId);
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
await getCities("en");
await getCities("zh");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
@@ -150,6 +157,31 @@ export const StationEditPage = observer(() => {
fetchAndSetStationData();
}, [id]);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editStationData.common.city_id &&
!baseCities.some((city) => city.id === editStationData.common.city_id)
? [
{
id: editStationData.common.city_id,
name: editStationData.common.city || `Город ${editStationData.common.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
@@ -255,7 +287,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities["ru"].data.find(
const selectedCity = availableCities.find(
(city) => city.id === e.target.value
);
setEditCommonData({
@@ -264,7 +296,7 @@ export const StationEditPage = observer(() => {
});
}}
>
{cities["ru"].data.map((city) => (
{availableCities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>

View File

@@ -1,14 +1,14 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import {
authStore,
languageStore,
stationsStore,
selectedCityStore,
cityStore,
} from "@shared";
import { useEffect, useState } from "react";
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 {
CreateButton,
@@ -35,11 +35,11 @@ export const StationListPage = observer(() => {
pageSize: 50,
});
const { language } = languageStore;
const canWriteStations = authStore.canWrite("stations");
useEffect(() => {
const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
@@ -83,19 +83,18 @@ export const StationListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 200,
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && (
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
)}
{canWriteStations && (
<button
onClick={() => {
setSelectedStationId(params.row.id);
@@ -105,6 +104,8 @@ export const StationListPage = observer(() => {
>
<Route size={20} className="text-purple-500" />
</button>
)}
{canWriteStations && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -113,6 +114,7 @@ export const StationListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
);
},
@@ -142,7 +144,9 @@ export const StationListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
{canWriteStations && (
<CreateButton label="Создать остановки" path="/station/create" />
)}
</div>
<div className="flex justify-end mb-5 duration-300">

View File

@@ -1,10 +1,4 @@
import {
Button,
Paper,
TextField,
Checkbox,
FormControlLabel,
} from "@mui/material";
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
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">
<ImageUploadCard
title="Аватар"

View File

@@ -5,6 +5,15 @@ import {
Paper,
TextField,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -19,17 +28,61 @@ import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
authStore,
cityStore,
MultiSelect,
type User,
type UserCity,
} from "@shared";
import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
export const UserEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore;
const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore;
const canReadCities = authStore.canRead("cities");
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localCityIds, setLocalCityIds] = useState<number[]>([]);
const [initialUserCities, setInitialUserCities] = useState<UserCity[]>([]);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -44,13 +97,65 @@ export const UserEditPage = observer(() => {
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
await Promise.all([
mediaStore.getMedia(),
authStore.canRead("cities")
? cityStore.getRuCities()
: authStore.fetchMeCities().catch(() => undefined),
]);
const data = (await getUser(Number(id))) as User | undefined;
if (data) {
setEditUserData(
data.name || "",
data.email || "",
data.password || "",
data.is_admin || false,
data.icon || "",
);
const roles = data.roles ?? [];
setLocalRoles(roles);
setEditUserRoles(roles);
const cityIds = (data.cities ?? []).map((c) => c.city_id);
setLocalCityIds(cityIds);
setInitialUserCities(data.cities ?? []);
}
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
}
})();
}, [id]);
const handleSave = async () => {
try {
setIsLoading(true);
const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
setEditUserRoles(rolesToSave);
await editUser(Number(id));
toast.success("Пользователь успешно обновлен");
await userStore.addUserCityAction({
id: Number(id),
city_ids: localCityIds,
});
toast.success("Пользователь успешно обновлён");
navigate("/user");
} catch (error) {
} catch {
toast.error("Ошибка при обновлении пользователя");
} finally {
setIsLoading(false);
@@ -68,43 +173,43 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
editUserData.password || "",
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 =
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
? mediaStore.media.find((m) => m.id === editUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
? null
: selectedMedia?.id ?? editUserData.icon ?? null;
: (selectedMedia?.id ?? editUserData.icon ?? null);
const cityOptionsMap = new Map<number, string>();
const sourceCities: UserCity[] = canReadCities
? cityStore.ruCities.data
.filter((city) => city.id !== undefined)
.map((city) => ({
city_id: city.id as number,
name: city.name,
}))
: authStore.meCities.ru;
for (const city of sourceCities) {
cityOptionsMap.set(city.city_id, city.name);
}
for (const city of initialUserCities) {
if (!cityOptionsMap.has(city.city_id)) {
cityOptionsMap.set(city.city_id, city.name);
}
}
const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({
value,
label,
}));
if (isLoadingData) {
return (
@@ -122,18 +227,16 @@ export const UserEditPage = observer(() => {
}
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<Paper className="w-full p-6 flex flex-col gap-8">
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-start">
{/* ── Основные данные ── */}
<section className="flex flex-col gap-6">
<Typography variant="h6">Основные данные</Typography>
<TextField
fullWidth
label="Имя"
@@ -145,7 +248,7 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
editUserData.icon
editUserData.icon,
)
}
/>
@@ -160,11 +263,10 @@ export const UserEditPage = observer(() => {
e.target.value,
editUserData.password || "",
editUserData.is_admin || false,
editUserData.icon
editUserData.icon,
)
}
/>
<TextField
fullWidth
label="Пароль"
@@ -176,27 +278,10 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
e.target.value,
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]">
<ImageUploadCard
@@ -218,21 +303,189 @@ export const UserEditPage = observer(() => {
}}
/>
</div>
</section>
<Divider />
{/* ── Права доступа ── */}
<section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography>
<FormControlLabel
control={
<Checkbox
checked={localRoles.includes("admin")}
onChange={(e) => {
if (e.target.checked) {
setLocalRoles((prev) => {
let next = prev.filter((r) => r !== "admin");
for (const { key } of ROLE_RESOURCES) {
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
next.push(`${key}_rw`);
}
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
});
} else {
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
}
}}
/>
}
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="w-min flex gap-2 items-center self-end"
startIcon={<Save size={20} />}
onClick={handleEdit}
className="self-end"
startIcon={isLoading ? <Loader2 size={20} className="animate-spin" /> : <Save size={20} />}
onClick={handleSave}
disabled={isLoading || !editUserData.name || !editUserData.email}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
Сохранить
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
@@ -240,7 +493,6 @@ export const UserEditPage = observer(() => {
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
@@ -249,13 +501,11 @@ export const UserEditPage = observer(() => {
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
@@ -264,7 +514,7 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
""
"",
);
setIsDeleteIconModalOpen(false);
}}

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { userStore } from "@shared";
import { authStore, userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
@@ -20,6 +20,7 @@ export const UserListPage = observer(() => {
page: 0,
pageSize: 50,
});
const canWriteUsers = authStore.canWrite("users");
useEffect(() => {
const fetchUsers = async () => {
@@ -81,25 +82,17 @@ export const UserListPage = observer(() => {
},
},
{
...(canWriteUsers ? [{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button>
<Pencil
size={20}
className="text-blue-500"
onClick={() => {
navigate(`/user/${params.row.id}/edit`);
}}
/>
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
@@ -110,15 +103,14 @@ export const UserListPage = observer(() => {
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
),
}] : []),
];
const rows = users.data?.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
name: user.name,
}));
@@ -127,7 +119,9 @@ export const UserListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Пользователи</h1>
{canWriteUsers && (
<CreateButton label="Создать пользователя" path="/user/create" />
)}
</div>
{ids.length > 0 && (
@@ -145,7 +139,7 @@ export const UserListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
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 { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -104,19 +104,22 @@ export const VehicleListPage = observer(() => {
field: "actions",
headerName: "Действия",
width: 200,
align: "center",
headerAlign: "center",
align: "center" as const,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const canWrite = authStore.canWrite("devices");
return (
<div className="flex h-full gap-7 justify-center items-center">
{canWrite && (
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
)}
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
{canWrite && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -125,6 +128,7 @@ export const VehicleListPage = observer(() => {
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div>
);
},
@@ -167,7 +171,7 @@ export const VehicleListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
checkboxSelection
checkboxSelection={authStore.canWrite("devices")}
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}

View File

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

View 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;
}

View File

@@ -23,12 +23,63 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
for_admin?: boolean;
requiredRoles?: string[];
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
}
export const ROUTE_REQUIRED_RESOURCES: Record<string, string[]> = {
"/": [],
"/sight": ["sights"],
"/sight/create": ["sights"],
"/sight/:id/edit": ["sights"],
"/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"],
"/map": ["map"],
"/media": ["sights"],
"/media/:id": ["sights"],
"/media/:id/edit": ["sights"],
"/country": ["countries"],
"/country/create": ["countries"],
"/country/add": ["countries"],
"/country/:id/edit": ["countries"],
"/city": ["cities", "countries"],
"/city/create": ["cities", "countries"],
"/city/:id/edit": ["cities", "countries"],
"/route": ["routes", "carriers"],
"/route/create": ["routes", "carriers"],
"/route/:id/edit": ["routes", "carriers"],
"/user": ["users"],
"/user/create": ["users"],
"/user/:id/edit": ["users"],
"/snapshot": ["snapshot_rw"],
"/snapshot/create": ["snapshot_create", "devices_rw"],
"/carrier": ["carriers"],
"/carrier/create": ["carriers"],
"/carrier/:id/edit": ["carriers"],
"/station": ["stations"],
"/station/create": ["stations"],
"/station/:id": ["stations"],
"/station/:id/edit": ["stations"],
"/vehicle/create": ["devices"],
"/vehicle/:id/edit": ["devices"],
"/article": ["sights"],
"/article/:id": ["sights"],
};
export const NAVIGATION_ITEMS: {
primary: NavigationItem[];
secondary: NavigationItem[];
@@ -39,7 +90,7 @@ export const NAVIGATION_ITEMS: {
label: "Экспорт",
icon: GitBranch,
path: "/snapshot",
for_admin: true,
requiredRoles: ["snapshot_rw", "snapshot_create"],
},
{
id: "map",
@@ -52,14 +103,14 @@ export const NAVIGATION_ITEMS: {
label: "Устройства",
icon: Cpu,
path: "/devices",
for_admin: true,
requiredRoles: ["devices_ro", "devices_rw"],
},
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
for_admin: true,
requiredRoles: ["users_ro", "users_rw"],
},
{
id: "all",
@@ -71,18 +122,21 @@ export const NAVIGATION_ITEMS: {
label: "Достопримечательности",
icon: Landmark,
path: "/sight",
requiredRoles: ["sights_ro", "sights_rw"],
},
{
id: "stations",
label: "Остановки",
icon: PersonStanding,
path: "/station",
requiredRoles: ["stations_ro", "stations_rw"],
},
{
id: "routes",
label: "Маршруты",
icon: Split,
path: "/route",
requiredRoles: ["routes_ro", "routes_rw"],
},
{
@@ -90,14 +144,14 @@ export const NAVIGATION_ITEMS: {
label: "Страны",
icon: Earth,
path: "/country",
for_admin: true,
requiredRoles: ["countries_ro", "countries_rw"],
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
for_admin: true,
requiredRoles: ["cities_ro", "cities_rw"],
},
{
id: "carriers",
@@ -105,7 +159,7 @@ export const NAVIGATION_ITEMS: {
// @ts-ignore
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
path: "/carrier",
for_admin: true,
requiredRoles: ["carriers_ro", "carriers_rw"],
},
],
},
@@ -123,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 = [
{ label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 },

View File

@@ -1,6 +1,7 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
export * from "./permissions";
export const generateDefaultMediaName = (
objectName: string,

View 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),
});

View 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 ?? []),
};
};

View File

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

View File

@@ -1,5 +1,6 @@
import {
authInstance,
authStore,
cityStore,
languageStore,
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 () => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
const payload = {
full_name: this.createCarrierData[language].full_name,
@@ -172,12 +212,16 @@ class CarrierStore {
});
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const cityNameForLang = this.resolveCityName(
this.createCarrierData.city_id,
lang as Language
);
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
city: cityName,
city: cityNameForLang || cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
@@ -273,13 +317,8 @@ class CarrierStore {
};
editCarrier = async (id: number) => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.editCarrierData.city_id
)?.name || "";
for (const lang of ["ru", "en", "zh"] as const) {
const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
city: cityName,

View 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;
};

View File

@@ -1,5 +1,11 @@
import { authInstance } from "@shared";
import { authInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { addUserCityApi } from "./api";
export type UserCity = {
city_id: number;
name: string;
};
export type User = {
id: number;
@@ -8,6 +14,8 @@ export type User = {
name: string;
password?: string;
icon?: string;
roles?: string[];
cities?: UserCity[];
};
class UserStore {
@@ -59,6 +67,7 @@ class UserStore {
password: "",
is_admin: false,
icon: "",
roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
};
setCreateUserData = (
@@ -66,9 +75,10 @@ class UserStore {
email: string,
password: string,
is_admin: boolean,
icon?: string
icon?: string,
) => {
this.createUserData = {
...this.createUserData,
name,
email,
password,
@@ -82,7 +92,13 @@ class UserStore {
if (this.users.data.length > 0) {
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;
const response = await authInstance.post("/user", payload);
@@ -100,6 +116,7 @@ class UserStore {
password: "",
is_admin: false,
icon: "",
roles: [],
};
setEditUserData = (
@@ -107,9 +124,10 @@ class UserStore {
email: string,
password: string,
is_admin: boolean,
icon?: string
icon?: string,
) => {
this.editUserData = {
...this.editUserData,
name,
email,
password,
@@ -118,19 +136,50 @@ class UserStore {
};
};
setEditUserRoles = (roles: string[]) => {
this.editUserData = { ...this.editUserData, roles };
};
editUser = async (id: number) => {
const payload = { ...this.editUserData };
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;
const response = await authInstance.patch(`/user/${id}`, payload);
runInAction(() => {
this.users.data = this.users.data.map((user) =>
user.id === id ? { ...user, ...response.data } : user
user.id === id ? { ...user, ...response.data } : user,
);
this.user[id] = { ...this.user[id], ...response.data };
});
};
addUserCityResult: User | null = null;
addUserCityLoading = false;
addUserCityError: string | null = null;
addUserCityAction = mobxFetch<
{ id: number; city_ids: number[] },
User,
UserStore
>({
store: this,
value: "addUserCityResult",
loading: "addUserCityLoading",
error: "addUserCityError",
fn: addUserCityApi,
onSuccess: (result) => {
runInAction(() => {
this.users.data = this.users.data.map((user) =>
user.id === result.id ? { ...user, ...result } : user,
);
if (this.user[result.id]) {
this.user[result.id] = { ...this.user[result.id], ...result };
}
});
},
});
}
export const userStore = new UserStore();

View 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 : [];
};

View File

@@ -1,30 +1,9 @@
import { authInstance, languageInstance } from "@shared";
import { authInstance, languageInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession } from "./types";
export type Vehicle = {
vehicle: {
id: number;
tail_number: 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;
};
};
export type { Vehicle, VehicleMaintenanceSession } from "./types";
class VehicleStore {
vehicles: {
@@ -35,6 +14,9 @@ class VehicleStore {
loaded: false,
};
vehicle: Record<string, Vehicle> = {};
vehicleSessions: VehicleMaintenanceSession[] | null = null;
vehicleSessionsLoading = false;
vehicleSessionsError: string | null = null;
constructor() {
makeAutoObservable(this);
@@ -89,7 +71,7 @@ class VehicleStore {
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
([, item]) => item.vehicle.uuid === updatedUuid
([, item]) => item.vehicle.uuid === updatedUuid,
);
if (entry) {
@@ -118,7 +100,7 @@ class VehicleStore {
runInAction(() => {
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,
carrier: string,
carrierId: number,
model?: string
model?: string,
) => {
const payload: Record<string, unknown> = {
tail_number: tailNumber,
@@ -197,7 +179,7 @@ class VehicleStore {
carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
}
},
) => {
const payload: Record<string, unknown> = {
tail_number: data.tail_number,
@@ -210,7 +192,7 @@ class VehicleStore {
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
payload
payload,
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
@@ -230,9 +212,12 @@ class VehicleStore {
};
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
const response = await authInstance.post(
`/devices/${uuid}/maintenance-mode`,
{
enabled,
});
},
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
@@ -255,10 +240,24 @@ class VehicleStore {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
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();

View 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;
};

View 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}
</>
),
},
}}
/>
)}
/>
);
}

View File

@@ -4,3 +4,4 @@ export * from "./Modal";
export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
export * from "./MultiSelect";

View File

@@ -8,16 +8,40 @@ import {
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { cityStore, selectedCityStore } from "@shared";
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
const canReadCities = authStore.canRead("cities");
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 cityId = event.target.value;
@@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => {
return;
}
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
const city = currentCities.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
const currentCities = cities["ru"].data;
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" />
@@ -51,16 +73,13 @@ export const CitySelector: React.FC = observer(() => {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
MenuProps={{
PaperProps: {},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>

View 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>
);
},
);

View File

@@ -1,11 +1,13 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import {
authStore,
authInstance,
devicesStore,
Modal,
snapshotStore,
vehicleStore,
routeStore,
Vehicle,
carrierStore,
selectedCityStore,
@@ -22,6 +24,7 @@ import {
RotateCcw,
ScrollText,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
@@ -35,6 +38,7 @@ import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets";
import { DeviceLogsModal } from "./DeviceLogsModal";
import { VehicleSessionsModal } from "./VehicleSessionsModal";
export type ConnectedDevice = string;
@@ -77,6 +81,13 @@ type RowData = {
snapshot_update_blocked: boolean;
maintenance_mode_on: boolean;
demo_mode_enabled: boolean;
current_route_id: number | null;
};
type PendingModeToggle = {
deviceUuid: string;
nextEnabled: boolean;
tailNumber: string;
};
function getVehicleTypeLabel(vehicle: Vehicle): string {
@@ -109,11 +120,13 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
current_route_id: vehicle.device_status?.current_route_id ?? null,
};
});
};
export const DevicesTable = observer(() => {
const canWriteDevices = authStore.canWrite("devices");
const {
getDevices,
setSelectedDevice,
@@ -123,6 +136,7 @@ export const DevicesTable = observer(() => {
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { routes, getRoutes } = routeStore;
const {
getVehicles,
vehicles,
@@ -137,6 +151,12 @@ export const DevicesTable = observer(() => {
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | 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 [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
Set<string>
@@ -144,6 +164,14 @@ export const DevicesTable = observer(() => {
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
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>>(
new Set(),
);
@@ -223,65 +251,108 @@ export const DevicesTable = observer(() => {
.map((r) => r.device_uuid as string);
}, [rows, selectedIds]);
const handleToggleMaintenanceMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.maintenance_mode_on;
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
next.add(toggle.deviceUuid);
return next;
});
try {
await setMaintenanceMode(row.device_uuid, nextEnabled);
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(
nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен",
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
);
} catch (error) {
console.error(
`Error toggling maintenance mode for ${row.device_uuid}:`,
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить режим ТО");
} finally {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
next.delete(toggle.deviceUuid);
return next;
});
}
};
const handleToggleDemoMode = async (row: RowData) => {
if (!row.device_uuid) return;
const nextEnabled = !row.demo_mode_enabled;
const applyDemoMode = async (toggle: PendingModeToggle) => {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.add(row.device_uuid!);
next.add(toggle.deviceUuid);
return next;
});
try {
await setDemoMode(row.device_uuid, nextEnabled);
await setDemoMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен");
toast.success(
toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен",
);
} 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("Не удалось изменить демо-режим");
} finally {
setDemoLoadingUuids((prev) => {
const next = new Set(prev);
next.delete(row.device_uuid!);
next.delete(toggle.deviceUuid);
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(
() => [
{
@@ -375,10 +446,14 @@ export const DevicesTable = observer(() => {
>
<Checkbox
checked={rowData.maintenance_mode_on}
disabled={!rowData.device_uuid || isMaintenanceLoading}
disabled={
!rowData.device_uuid ||
isMaintenanceLoading ||
maintenanceConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleMaintenanceMode(rowData)}
onChange={() => openMaintenanceConfirm(rowData)}
/>
</Box>
);
@@ -404,10 +479,12 @@ export const DevicesTable = observer(() => {
>
<Checkbox
checked={rowData.demo_mode_enabled}
disabled={!rowData.device_uuid || isDemoLoading}
disabled={
!rowData.device_uuid || isDemoLoading || demoConfirmSubmitting
}
size="small"
sx={{ p: 0 }}
onChange={() => handleToggleDemoMode(rowData)}
onChange={() => openDemoConfirm(rowData)}
/>
</Box>
);
@@ -436,6 +513,20 @@ export const DevicesTable = observer(() => {
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",
headerName: "GPS",
@@ -496,6 +587,7 @@ export const DevicesTable = observer(() => {
justifyContent: "center",
}}
>
{canWriteDevices && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -505,6 +597,7 @@ export const DevicesTable = observer(() => {
>
<Pencil size={16} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -529,6 +622,17 @@ export const DevicesTable = observer(() => {
>
<Copy size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setSessionsModalVehicleId(row.vehicle_id);
setSessionsModalVehicleTailNumber(row.tail_number);
setSessionsModalOpen(true);
}}
title="Сессии ТО"
>
<Wrench size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
@@ -557,35 +661,44 @@ export const DevicesTable = observer(() => {
setLogsModalOpen,
maintenanceLoadingUuids,
demoLoadingUuids,
setMaintenanceMode,
setDemoMode,
handleToggleMaintenanceMode,
handleToggleDemoMode,
openMaintenanceConfirm,
openDemoConfirm,
maintenanceConfirmSubmitting,
demoConfirmSubmitting,
routes,
canWriteDevices,
],
);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getVehicles();
await getDevices();
await getSnapshots();
await Promise.all([
getVehicles(),
getDevices(),
getSnapshots(),
getRoutes(),
]);
setIsLoading(false);
};
fetchData();
}, [getDevices, getSnapshots, getVehicles]);
}, [getDevices, getSnapshots, getVehicles, getRoutes]);
useEffect(() => {
carrierStore.getCarriers("ru");
}, []);
const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) {
return;
}
if (selectedDeviceUuidsAllowed.length > 0) {
toggleSendSnapshotModal();
}
};
const handleSendSnapshotAction = async (snapshotId: string) => {
if (!canWriteDevices) return;
if (selectedDeviceUuidsAllowed.length === 0) return;
const blockedCount =
@@ -658,6 +771,7 @@ export const DevicesTable = observer(() => {
<>
<div className="w-full">
<div className="flex justify-end mb-5 gap-2 flex-wrap">
{canWriteDevices && (
<Button
variant="contained"
color="primary"
@@ -666,6 +780,7 @@ export const DevicesTable = observer(() => {
>
Добавить устройство
</Button>
)}
{selectedIds.length > 0 && (
<Button
variant="contained"
@@ -677,6 +792,7 @@ export const DevicesTable = observer(() => {
Удалить ({selectedIds.length})
</Button>
)}
{canWriteDevices && (
<Button
variant="contained"
color="primary"
@@ -685,10 +801,12 @@ export const DevicesTable = observer(() => {
size="small"
>
Обновление ПО ({selectedDeviceUuidsAllowed.length}
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length &&
{selectedDeviceUuids.length !==
selectedDeviceUuidsAllowed.length &&
`/${selectedDeviceUuids.length}`}
)
</Button>
)}
</div>
{groupsByModel.length === 0 ? (
@@ -787,7 +905,12 @@ export const DevicesTable = observer(() => {
)}
</div>
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
{canWriteDevices && (
<Modal
open={sendSnapshotModalOpen}
onClose={toggleSendSnapshotModal}
sx={{ width: "min(760px, 94vw)", p: 3 }}
>
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Обновление ПО
</Box>
@@ -796,11 +919,12 @@ export const DevicesTable = observer(() => {
<strong className="text-blue-600">
{selectedDeviceUuidsAllowed.length}
</strong>
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length && (
{selectedDeviceUuids.length !==
selectedDeviceUuidsAllowed.length && (
<span className="text-amber-600 ml-1">
(пропущено{" "}
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length} с
блокировкой)
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "}
с блокировкой)
</span>
)}
</Box>
@@ -833,6 +957,7 @@ export const DevicesTable = observer(() => {
Отмена
</Button>
</Modal>
)}
<DeleteModal
open={isDeleteModalOpen}
@@ -840,6 +965,78 @@ export const DevicesTable = observer(() => {
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
open={logsModalOpen}
deviceUuid={logsModalDeviceUuid}
@@ -848,6 +1045,17 @@ export const DevicesTable = observer(() => {
setLogsModalDeviceUuid(null);
}}
/>
<VehicleSessionsModal
open={sessionsModalOpen}
vehicleId={sessionsModalVehicleId}
tailNumber={sessionsModalVehicleTailNumber}
onClose={() => {
setSessionsModalOpen(false);
setSessionsModalVehicleId(null);
setSessionsModalVehicleTailNumber(null);
}}
/>
</>
);
});

View File

@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features";
import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
import { authStore, menuStore, isMediaIdEmpty } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
@@ -27,13 +27,10 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
setIsMenuOpen(open);
}, [open]);
const { getUsers, users } = userStore;
const { getMeAction, me } = authStore;
useEffect(() => {
const fetchUsers = async () => {
await getUsers();
};
fetchUsers();
getMeAction();
}, []);
const handleDrawerOpen = () => {
@@ -68,17 +65,13 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
<div className="flex gap-2 items-center">
{(() => {
const currentUser = users?.data?.find(
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
);
const hasAvatar =
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon);
const token = localStorage.getItem("token");
return (
<>
<div className="flex flex-col gap-1">
<p className="text-white">{currentUser?.name}</p>
<p className="text-white">{me?.name}</p>
<div
className="text-center text-xs"
style={{
@@ -88,7 +81,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
padding: "2px 10px",
}}
>
{(authStore.payload as { is_admin?: boolean })?.is_admin
{me?.roles?.includes("admin")
? "Администратор"
: "Режим пользователя"}
</div>
@@ -98,7 +91,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${currentUser!.icon}/download?token=${token}`}
}${me?.icon}/download?token=${token}`}
alt="Аватар"
className="w-full h-full object-cover"
/>

View File

@@ -14,6 +14,7 @@ import {
BackButton,
TabPanel,
languageStore,
authStore,
Language,
cityStore,
isMediaIdEmpty,
@@ -40,7 +41,7 @@ import { SaveWithoutCityAgree } from "@widgets";
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore;
const canReadCities = authStore.canRead("cities");
const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -64,6 +65,30 @@ export const CreateInformationTab = observer(
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
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(() => {
@@ -208,17 +233,16 @@ export const CreateInformationTab = observer(
/>
<Autocomplete
options={cities["ru"]?.data ?? []}
options={availableCities}
value={
cities["ru"]?.data?.find(
(city) => city.id === sight.city_id
) ?? null
availableCities.find((city) => city.id === sight.city_id) ?? null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
handleChange({
city_id: value?.id ?? 0,
city: value?.name ?? "",
});
}}
renderInput={(params) => (

View File

@@ -14,6 +14,7 @@ import {
BackButton,
TabPanel,
languageStore,
authStore,
Language,
cityStore,
editSightStore,
@@ -62,10 +63,35 @@ export const InformationTab = observer(
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
const canReadCities = authStore.canRead("cities");
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(() => {
@@ -208,11 +234,9 @@ export const InformationTab = observer(
/>
<Autocomplete
options={cities["ru"]?.data ?? []}
options={availableCities}
value={
cities["ru"]?.data?.find(
(city) => city.id === sight.common.city_id
) ?? null
availableCities.find((city) => city.id === sight.common.city_id) ?? null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
@@ -221,6 +245,7 @@ export const InformationTab = observer(
language as Language,
{
city_id: value?.id ?? 0,
city: value?.name ?? "",
},
true
);

File diff suppressed because one or more lines are too long