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_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.st.unprism.ru/' VITE_REACT_APP ='https://wn.krbl.ru/'
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/' VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true' VITE_NEED_AUTH='true'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,4 @@
import { import { Button, Paper, TextField } from "@mui/material";
Button,
Paper,
TextField,
Checkbox,
FormControlLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -133,26 +127,6 @@ export const UserCreatePage = observer(() => {
} }
/> />
<div className="w-full flex flex-col items-start">
<FormControlLabel
control={
<Checkbox
checked={createUserData.is_admin || false}
onChange={(e) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
e.target.checked,
createUserData.icon
);
}}
/>
}
label="Администратор"
/>
</div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Аватар" title="Аватар"

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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 "./CoordinatesInput";
export * from "./AnimatedCircleButton"; export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner"; export * from "./LoadingSpinner";
export * from "./MultiSelect";

View File

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

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
BackButton, BackButton,
TabPanel, TabPanel,
languageStore, languageStore,
authStore,
Language, Language,
cityStore, cityStore,
editSightStore, editSightStore,
@@ -62,10 +63,35 @@ export const InformationTab = observer(
const [hardcodeType, setHardcodeType] = useState< const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const { cities } = cityStore; const canReadCities = authStore.canRead("cities");
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
const baseCities = canReadCities
? cityStore.cities["ru"]?.data ?? []
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
sight.common.city_id &&
!baseCities.some((city) => city.id === sight.common.city_id)
? [
{
id: sight.common.city_id,
name: sight.common.city || `Город ${sight.common.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
useEffect(() => {}, [hardcodeType]); useEffect(() => {}, [hardcodeType]);
useEffect(() => { useEffect(() => {
@@ -208,11 +234,9 @@ export const InformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={cities["ru"]?.data ?? []} options={availableCities}
value={ value={
cities["ru"]?.data?.find( availableCities.find((city) => city.id === sight.common.city_id) ?? null
(city) => city.id === sight.common.city_id
) ?? null
} }
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
onChange={(_, value) => { onChange={(_, value) => {
@@ -221,6 +245,7 @@ export const InformationTab = observer(
language as Language, language as Language,
{ {
city_id: value?.id ?? 0, city_id: value?.id ?? 0,
city: value?.name ?? "",
}, },
true true
); );

File diff suppressed because one or more lines are too long