diff --git a/.env b/.env index f80e47e..899dc39 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -VITE_API_URL='https://wn.st.unprism.ru' -VITE_REACT_APP ='https://wn.st.unprism.ru/' -VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/' +VITE_API_URL='https://wn.krbl.ru' +VITE_REACT_APP ='https://wn.krbl.ru/' +VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' VITE_NEED_AUTH='true' \ No newline at end of file diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 082be27..b7e1dc2 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -37,7 +37,7 @@ import { ArticlePreviewPage, CountryAddPage, } from "@pages"; -import { authStore, createSightStore, editSightStore } from "@shared"; +import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared"; import { Layout } from "@widgets"; import { runInAction } from "mobx"; import React, { useEffect } from "react"; @@ -48,6 +48,7 @@ import { Navigate, Outlet, useLocation, + useMatches, } from "react-router-dom"; const PublicRoute = ({ children }: { children: React.ReactNode }) => { @@ -65,15 +66,28 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const need_auth = import.meta.env.VITE_NEED_AUTH == "true"; const location = useLocation(); + const matches = useMatches(); if (!isAuthenticated && need_auth) { return ; } - if (location.pathname === "/") { + if (location.pathname === "/" && authStore.canRead("map")) { return ; } + 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 ; + } + return <>{children}; }; @@ -102,7 +116,10 @@ const router = createBrowserRouter([ ), }, - { path: "route-preview/:id", element: }, + { + path: "route-preview/:id", + element: , + }, { path: "/", element: ( @@ -115,48 +132,258 @@ const router = createBrowserRouter([ ), children: [ - { index: true, element: }, + { + index: true, + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/"], + }, + }, - { path: "sight", element: }, - { path: "sight/create", element: }, - { path: "sight/:id/edit", element: }, + { + path: "sight", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight"], + }, + }, + { + path: "sight/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"], + }, + }, + { + path: "sight/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"], + }, + }, - { path: "devices", element: }, + { + path: "devices", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/devices"], + }, + }, - { path: "map", element: }, + { + path: "map", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/map"], + }, + }, - { path: "media", element: }, - { path: "media/:id", element: }, - { path: "media/:id/edit", element: }, + { + path: "media", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media"], + }, + }, + { + path: "media/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"], + }, + }, + { + path: "media/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"], + }, + }, - { path: "country", element: }, - { path: "country/create", element: }, - { path: "country/add", element: }, - { path: "country/:id/edit", element: }, - { path: "city", element: }, - { path: "city/create", element: }, - { path: "city/:id/edit", element: }, - { path: "route", element: }, - { path: "route/create", element: }, - { path: "route/:id/edit", element: }, + { + path: "country", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country"], + }, + }, + { + path: "country/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/create"], + }, + }, + { + path: "country/add", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/add"], + }, + }, + { + path: "country/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"], + }, + }, + { + path: "city", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city"], + }, + }, + { + path: "city/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city/create"], + }, + }, + { + path: "city/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"], + }, + }, + { + path: "route", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route"], + }, + }, + { + path: "route/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route/create"], + }, + }, + { + path: "route/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"], + }, + }, - { path: "user", element: }, - { path: "user/create", element: }, - { path: "user/:id/edit", element: }, - { path: "snapshot", element: }, - { path: "snapshot/create", element: }, + { + path: "user", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user"], + }, + }, + { + path: "user/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user/create"], + }, + }, + { + path: "user/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"], + }, + }, + { + path: "snapshot", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"], + }, + }, + { + path: "snapshot/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"], + }, + }, - { path: "carrier", element: }, - { path: "carrier/create", element: }, - { path: "carrier/:id/edit", element: }, - { path: "station", element: }, - { path: "station/create", element: }, - { path: "station/:id", element: }, - { path: "station/:id/edit", element: }, - { path: "vehicle/create", element: }, - { path: "vehicle/:id/edit", element: }, - { path: "article", element: }, - { path: "article/:id", element: }, + { + path: "carrier", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier"], + }, + }, + { + path: "carrier/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"], + }, + }, + { + path: "carrier/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"], + }, + }, + { + path: "station", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station"], + }, + }, + { + path: "station/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/create"], + }, + }, + { + path: "station/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"], + }, + }, + { + path: "station/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"], + }, + }, + { + path: "vehicle/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"], + }, + }, + { + path: "vehicle/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"], + }, + }, + { + path: "article", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/article"], + }, + }, + { + path: "article/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"], + }, + }, ], }, ]); diff --git a/src/entities/navigation/model/index.ts b/src/entities/navigation/model/index.ts index 5048640..5fe2161 100644 --- a/src/entities/navigation/model/index.ts +++ b/src/entities/navigation/model/index.ts @@ -6,6 +6,7 @@ export interface NavigationItem { icon: LucideIcon; path?: string; for_admin?: boolean; + required_resource?: string; onClick?: () => void; nestedItems?: NavigationItem[]; } diff --git a/src/entities/navigation/ui/index.tsx b/src/entities/navigation/ui/index.tsx index 3b67622..c5e8e77 100644 --- a/src/entities/navigation/ui/index.tsx +++ b/src/entities/navigation/ui/index.tsx @@ -10,7 +10,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import type { NavigationItem } from "../model"; import { useNavigate, useLocation } from "react-router-dom"; import { Plus } from "lucide-react"; -import { authStore } from "@shared"; interface NavigationItemProps { item: NavigationItem; @@ -31,22 +30,10 @@ export const NavigationItemComponent: React.FC = ({ const navigate = useNavigate(); const location = useLocation(); const [isExpanded, setIsExpanded] = React.useState(false); - const { payload } = authStore; - - const need_auth = import.meta.env.VITE_NEED_AUTH == "true"; - - // @ts-ignore - const isAdmin = payload?.is_admin || false || !need_auth; const isActive = item.path ? location.pathname.startsWith(item.path) : false; - const filteredNestedItems = item.nestedItems?.filter((nestedItem) => { - if (nestedItem.for_admin) { - return isAdmin; - } - - return true; - }); + const filteredNestedItems = item.nestedItems; const handleClick = () => { if (item.id === "all" && !open) { diff --git a/src/features/navigation/ui/index.tsx b/src/features/navigation/ui/index.tsx index 0cdbac0..de3ee52 100644 --- a/src/features/navigation/ui/index.tsx +++ b/src/features/navigation/ui/index.tsx @@ -1,6 +1,6 @@ import List from "@mui/material/List"; import Divider from "@mui/material/Divider"; -import { authStore, NAVIGATION_ITEMS } from "@shared"; +import { authStore, NAVIGATION_ITEMS, ROUTE_REQUIRED_RESOURCES } from "@shared"; import { NavigationItem, NavigationItemComponent } from "@entities"; import { observer } from "mobx-react-lite"; @@ -9,28 +9,48 @@ interface NavigationListProps { onDrawerOpen?: () => void; } +const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean => { + // Для карты в навигации требуем наличие ВСЕХ трёх rw-ролей: routes/stations/sights + if (item.id === "map") { + return ["routes", "stations", "sights"].every((resource) => + authStore.canWrite(resource), + ); + } + + const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : []; + const canAccessRoute = routePermissions.every((permission) => + authStore.canAccess(permission), + ); + + if (!canAccessRoute) { + return false; + } + + if (!item.requiredRoles || item.requiredRoles.length === 0) { + return true; + } + + return item.requiredRoles.some((role) => { + const match = role.match(/^(.+)_r[ow]$/); + if (match) { + return authStore.canRead(match[1]); + } + return authStore.hasRole(role); + }); +}; + export const NavigationList = observer( ({ open, onDrawerOpen }: NavigationListProps) => { - const { payload } = authStore; - // @ts-ignore - const isAdmin = Boolean(payload?.is_admin) || false; - - const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => { - if (item.for_admin) { - return isAdmin; - } - - if (item.nestedItems && item.nestedItems.length > 0) { - return item.nestedItems.some((nestedItem) => { - if (nestedItem.for_admin) { - return isAdmin; - } - return true; - }); - } - - return true; - }); + const primaryItems = NAVIGATION_ITEMS.primary + .filter(isItemVisible) + .map((item) => { + if (!item.nestedItems) return item; + return { + ...item, + nestedItems: item.nestedItems.filter(isItemVisible), + }; + }) + .filter((item) => !item.nestedItems || item.nestedItems.length > 0); return ( <> @@ -51,7 +71,7 @@ export const NavigationList = observer( key={item.id} item={item as NavigationItem} open={open} - onClick={item.onClick ? item.onClick : undefined} + onClick={item.onClick ?? undefined} onDrawerOpen={onDrawerOpen} /> ))} diff --git a/src/pages/Article/ArticleListPage/index.tsx b/src/pages/Article/ArticleListPage/index.tsx index 0e9169d..179baea 100644 --- a/src/pages/Article/ArticleListPage/index.tsx +++ b/src/pages/Article/ArticleListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { articlesStore, languageStore } from "@shared"; +import { authStore, articlesStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Eye, Minus } from "lucide-react"; @@ -51,13 +51,12 @@ export const ArticleListPage = observer(() => { field: "actions", headerName: "Действия", sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- + renderCell: (params: GridRenderCellParams) => ( +
+ + {authStore.canWrite("sights") && ( -
- ); - }, + )} +
+ ), }, ]; diff --git a/src/pages/Carrier/CarrierCreatePage/index.tsx b/src/pages/Carrier/CarrierCreatePage/index.tsx index 4fc1435..ba87efc 100644 --- a/src/pages/Carrier/CarrierCreatePage/index.tsx +++ b/src/pages/Carrier/CarrierCreatePage/index.tsx @@ -15,6 +15,7 @@ import { toast } from "react-toastify"; import { carrierStore, cityStore, + authStore, mediaStore, languageStore, isMediaIdEmpty, @@ -30,7 +31,8 @@ export const CarrierCreatePage = observer(() => { const navigate = useNavigate(); const { createCarrierData, setCreateCarrierData } = carrierStore; const { language } = languageStore; - const { selectedCityId } = useSelectedCity(); + const canReadCities = authStore.canRead("cities"); + const { selectedCityId, selectedCity } = useSelectedCity(); const [selectedMediaId, setSelectedMediaId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); @@ -42,11 +44,37 @@ export const CarrierCreatePage = observer(() => { >(null); useEffect(() => { - cityStore.getCities("ru"); + const fetchCities = async () => { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities("ru"); + return; + } + await authStore.fetchMeCities().catch(() => undefined); + }; + + fetchCities(); mediaStore.getMedia(); languageStore.setLanguage("ru"); }, []); + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id) + ? [selectedCity, ...baseCities] + : baseCities; + useEffect(() => { if (selectedCityId && !createCarrierData.city_id) { setCreateCarrierData( @@ -134,7 +162,7 @@ export const CarrierCreatePage = observer(() => { ) } > - {cityStore.cities["ru"].data.map((city) => ( + {availableCities.map((city) => ( {city.name} diff --git a/src/pages/Carrier/CarrierEditPage/index.tsx b/src/pages/Carrier/CarrierEditPage/index.tsx index ebe50f7..18c4925 100644 --- a/src/pages/Carrier/CarrierEditPage/index.tsx +++ b/src/pages/Carrier/CarrierEditPage/index.tsx @@ -16,6 +16,7 @@ import { toast } from "react-toastify"; import { carrierStore, cityStore, + authStore, mediaStore, languageStore, isMediaIdEmpty, @@ -34,6 +35,7 @@ export const CarrierEditPage = observer(() => { const { id } = useParams(); const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore; const { language } = languageStore; + const canReadCities = authStore.canRead("cities"); const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(true); @@ -42,6 +44,7 @@ export const CarrierEditPage = observer(() => { const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [mediaId, setMediaId] = useState(""); const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false); + const [initialCityName, setInitialCityName] = useState(""); const [activeMenuType, setActiveMenuType] = useState< "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null >(null); @@ -54,9 +57,14 @@ export const CarrierEditPage = observer(() => { } setIsLoadingData(true); try { - await cityStore.getCities("ru"); - await cityStore.getCities("en"); - await cityStore.getCities("zh"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } const carrierData = await getCarrier(Number(id)); if (carrierData) { @@ -84,6 +92,7 @@ export const CarrierEditPage = observer(() => { carrierData.zh?.logo || "", "zh" ); + setInitialCityName(carrierData.ru?.city || ""); } await mediaStore.getMedia(); @@ -132,6 +141,31 @@ export const CarrierEditPage = observer(() => { ? null : (selectedMedia?.id ?? editCarrierData.logo); + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + editCarrierData.city_id && + !baseCities.some((city) => city.id === editCarrierData.city_id) + ? [ + { + id: editCarrierData.city_id, + name: initialCityName || `Город ${editCarrierData.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + if (isLoadingData) { return ( { ) } > - {cityStore.cities["ru"].data?.map((city) => ( + {availableCities.map((city) => ( {city.name} diff --git a/src/pages/Carrier/CarrierListPage/index.tsx b/src/pages/Carrier/CarrierListPage/index.tsx index 1d09dfe..48dc3f2 100644 --- a/src/pages/Carrier/CarrierListPage/index.tsx +++ b/src/pages/Carrier/CarrierListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { carrierStore, cityStore, languageStore } from "@shared"; +import { authStore, carrierStore, cityStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; @@ -10,7 +10,6 @@ import { Box, CircularProgress } from "@mui/material"; export const CarrierListPage = observer(() => { const { carriers, getCarriers, deleteCarrier } = carrierStore; - const { getCities, cities } = cityStore; const navigate = useNavigate(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); @@ -22,13 +21,19 @@ export const CarrierListPage = observer(() => { pageSize: 50, }); const { language } = languageStore; + const canReadCities = authStore.canRead("cities"); useEffect(() => { const fetchData = async () => { setIsLoading(true); - await getCities("ru"); - await getCities("en"); - await getCities("zh"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities(language); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getCarriers(language); setIsLoading(false); }; @@ -73,56 +78,57 @@ export const CarrierListPage = observer(() => { headerName: "Город", flex: 1, renderCell: (params: GridRenderCellParams) => { - const city = cities[language]?.data.find( - (city) => city.id == params.value - ); + const lang = language as "ru" | "en" | "zh"; + const cityName = canReadCities + ? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name + : authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name; return (
- {city && city.name ? ( - city.name - ) : ( - - )} + {cityName ?? }
); }, }, - { + ...(authStore.canWrite("carriers") ? [{ field: "actions", headerName: "Действия", - headerAlign: "center", + headerAlign: "center" as const, width: 200, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; - const rows = carriers[language].data?.map((carrier) => ({ - id: carrier.id, - full_name: carrier.full_name, - short_name: carrier.short_name, - city_id: carrier.city_id, - })); + const allowedCityIds = canReadCities + ? null + : authStore.meCities["ru"].map((c) => c.city_id); + + const canWriteCarriers = authStore.canWrite("carriers"); + + const rows = carriers[language].data + ?.filter((carrier) => + !allowedCityIds || allowedCityIds.includes(carrier.city_id), + ) + .map((carrier) => ({ + id: carrier.id, + full_name: carrier.full_name, + short_name: carrier.short_name, + city_id: carrier.city_id, + })); return ( <> @@ -130,10 +136,12 @@ export const CarrierListPage = observer(() => {

Перевозчики

- + {canWriteCarriers && ( + + )}
- {ids.length > 0 && ( + {canWriteCarriers && ids.length > 0 && (
- {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; return ( @@ -129,7 +125,9 @@ export const CityListPage = observer(() => {

Города

- + {canWriteCities && ( + + )}
{ids.length > 0 && ( @@ -147,7 +145,7 @@ export const CityListPage = observer(() => { { pageSize: 50, }); const { language } = languageStore; + const canWriteCountries = authStore.canWrite("countries"); useEffect(() => { const fetchCountries = async () => { @@ -48,37 +49,27 @@ export const CountryListPage = observer(() => { ); }, }, - { + ...(authStore.canWrite("countries") ? [{ field: "actions", headerName: "Действия", - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, width: 200, sortable: false, - renderCell: (params: GridRenderCellParams) => { - return ( -
- {/* */} - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ +
+ ), + }] : []), ]; const rows = countries[language]?.data.map((country) => ({ @@ -94,7 +85,9 @@ export const CountryListPage = observer(() => {

Страны

- + {canWriteCountries && ( + + )}
{ids.length > 0 && ( @@ -112,7 +105,7 @@ export const CountryListPage = observer(() => { { useEffect(() => { const fetchData = async () => { - await getCities("ru"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getArticles(languageStore.language); }; fetchData(); diff --git a/src/pages/EditSightPage/index.tsx b/src/pages/EditSightPage/index.tsx index 605fda3..7ca464e 100644 --- a/src/pages/EditSightPage/index.tsx +++ b/src/pages/EditSightPage/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { articlesStore, + authStore, cityStore, editSightStore, LoadingSpinner, @@ -41,7 +42,14 @@ export const EditSightPage = observer(() => { if (id) { setIsLoadingData(true); try { - await getCities("ru"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getSightInfo(+id, "ru"); await getSightInfo(+id, "en"); await getSightInfo(+id, "zh"); diff --git a/src/pages/MainPage/index.tsx b/src/pages/MainPage/index.tsx index 1ca9b32..4aecc6c 100644 --- a/src/pages/MainPage/index.tsx +++ b/src/pages/MainPage/index.tsx @@ -1,37 +1,5 @@ import * as React from "react"; -import Typography from "@mui/material/Typography"; export const MainPage: React.FC = () => { - return ( - <> - - 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. - - - 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. - - - ); + return null; }; diff --git a/src/pages/Media/MediaListPage/index.tsx b/src/pages/Media/MediaListPage/index.tsx index d780358..f296dca 100644 --- a/src/pages/Media/MediaListPage/index.tsx +++ b/src/pages/Media/MediaListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; +import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Eye, Trash2, Minus } from "lucide-react"; @@ -71,16 +71,15 @@ export const MediaListPage = observer(() => { field: "actions", headerName: "Действия", width: 200, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- + renderCell: (params: GridRenderCellParams) => ( +
+ + {authStore.canWrite("sights") && ( -
- ); - }, + )} +
+ ), }, ]; @@ -119,7 +118,7 @@ export const MediaListPage = observer(() => { { field: "actions", headerName: "Действия", width: 250, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, renderCell: (params: GridRenderCellParams) => { + const canWrite = authStore.canWrite("routes"); + const canShowRoutePreview = + authStore.canRead("stations") && + authStore.canRead("sights") && + authStore.canRead("routes"); return (
- - - - + {canWrite && ( + + )} + {canShowRoutePreview && ( + + )} + {canWrite && ( + + )}
); }, @@ -168,7 +178,7 @@ export const RouteListPage = observer(() => { { const { sights, getSights, deleteListSight } = sightsStore; - const { cities, getCities } = cityStore; const navigate = useNavigate(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); @@ -27,13 +27,20 @@ export const SightListPage = observer(() => { pageSize: 50, }); const { language } = languageStore; + const canReadCities = authStore.canRead("cities"); useEffect(() => { const fetchSights = async () => { setIsLoading(true); - await getCities(language); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities(language); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getSights(); - setIsLoading(false); }; fetchSights(); @@ -61,54 +68,59 @@ export const SightListPage = observer(() => { headerName: "Город", flex: 1, renderCell: (params: GridRenderCellParams) => { + const lang = language as "ru" | "en" | "zh"; + const cityName = canReadCities + ? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name + : authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name; return (
- {params.value ? ( - cities[language].data.find((el) => el.id == params.value)?.name - ) : ( - - )} + {cityName ?? }
); }, }, - { + ...(authStore.canWrite("sights") ? [{ field: "actions", headerName: "Действия", - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; const filteredSights = useMemo(() => { const { selectedCityId } = selectedCityStore; - if (!selectedCityId) { - return sights; - } - return sights.filter((sight: any) => sight.city_id === selectedCityId); - }, [sights, selectedCityStore.selectedCityId]); + const allowedCityIds = canReadCities + ? null + : authStore.meCities["ru"].map((c) => c.city_id); + + return sights.filter((sight: any) => { + if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) { + return false; + } + if (selectedCityId && sight.city_id !== selectedCityId) { + return false; + } + return true; + }); + }, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]); + + const canWriteSights = authStore.canWrite("sights"); const rows = filteredSights.map((sight) => ({ id: sight.id, @@ -123,13 +135,15 @@ export const SightListPage = observer(() => {

Достопримечательности

- + {canWriteSights && ( + + )}
- {ids.length > 0 && ( + {canWriteSights && ids.length > 0 && (
- - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; const rows = snapshots.map((snapshot) => ({ @@ -102,7 +101,9 @@ export const SnapshotListPage = observer(() => {

Экспорт Медиа

- + {canCreateSnapshot && ( + + )}
{ createStation, setLanguageCreateStationData, } = stationsStore; - const { cities, getCities } = cityStore; + const { getCities } = cityStore; + const canReadCities = authStore.canRead("cities"); const { selectedCityId, selectedCity } = useSelectedCity(); const [coordinates, setCoordinates] = useState(""); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); @@ -104,15 +106,35 @@ export const StationCreatePage = observer(() => { useEffect(() => { const fetchCities = async () => { - await getCities("ru"); - await getCities("en"); - await getCities("zh"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + return; + } + await authStore.fetchMeCities().catch(() => undefined); }; fetchCities(); mediaStore.getMedia(); }, []); + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id) + ? [selectedCity, ...baseCities] + : baseCities; + const handleMediaSelect = (media: { id: string; filename: string; @@ -229,7 +251,7 @@ export const StationCreatePage = observer(() => { value={createStationData.common.city_id || ""} label="Город" onChange={(e) => { - const selectedCity = cities["ru"].data.find( + const selectedCity = availableCities.find( (city) => city.id === e.target.value ); setCreateCommonData({ @@ -238,7 +260,7 @@ export const StationCreatePage = observer(() => { }); }} > - {cities["ru"].data.map((city) => ( + {availableCities.map((city) => ( {city.name} diff --git a/src/pages/Station/StationEditPage/index.tsx b/src/pages/Station/StationEditPage/index.tsx index 7fefe2f..de97eb6 100644 --- a/src/pages/Station/StationEditPage/index.tsx +++ b/src/pages/Station/StationEditPage/index.tsx @@ -15,6 +15,7 @@ import { stationsStore, languageStore, cityStore, + authStore, mediaStore, isMediaIdEmpty, LoadingSpinner, @@ -44,7 +45,8 @@ export const StationEditPage = observer(() => { editStation, setLanguageEditStationData, } = stationsStore; - const { cities, getCities } = cityStore; + const { getCities } = cityStore; + const canReadCities = authStore.canRead("cities"); const [coordinates, setCoordinates] = useState(""); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -138,9 +140,14 @@ export const StationEditPage = observer(() => { try { const stationId = Number(id); await getEditStation(stationId); - await getCities("ru"); - await getCities("en"); - await getCities("zh"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await mediaStore.getMedia(); } finally { setIsLoadingData(false); @@ -150,6 +157,31 @@ export const StationEditPage = observer(() => { fetchAndSetStationData(); }, [id]); + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + editStationData.common.city_id && + !baseCities.some((city) => city.id === editStationData.common.city_id) + ? [ + { + id: editStationData.common.city_id, + name: editStationData.common.city || `Город ${editStationData.common.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + if (isLoadingData) { return ( { value={editStationData.common.city_id || ""} label="Город" onChange={(e) => { - const selectedCity = cities["ru"].data.find( + const selectedCity = availableCities.find( (city) => city.id === e.target.value ); setEditCommonData({ @@ -264,7 +296,7 @@ export const StationEditPage = observer(() => { }); }} > - {cities["ru"].data.map((city) => ( + {availableCities.map((city) => ( {city.name} diff --git a/src/pages/Station/StationListPage/index.tsx b/src/pages/Station/StationListPage/index.tsx index 622fd08..4b8e004 100644 --- a/src/pages/Station/StationListPage/index.tsx +++ b/src/pages/Station/StationListPage/index.tsx @@ -1,14 +1,14 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; import { + authStore, languageStore, stationsStore, selectedCityStore, - cityStore, } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Eye, Pencil, Trash2, Minus, Route } from "lucide-react"; +import { Pencil, Trash2, Minus, Route } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, @@ -35,11 +35,11 @@ export const StationListPage = observer(() => { pageSize: 50, }); const { language } = languageStore; + const canWriteStations = authStore.canWrite("stations"); useEffect(() => { const fetchStations = async () => { setIsLoading(true); - await cityStore.getCities(language); await getStationList(); setIsLoading(false); }; @@ -83,36 +83,38 @@ export const StationListPage = observer(() => { field: "actions", headerName: "Действия", width: 200, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - renderCell: (params: GridRenderCellParams) => { return (
- - - - + {canWriteStations && ( + + )} + {canWriteStations && ( + + )} + {canWriteStations && ( + + )}
); }, @@ -142,7 +144,9 @@ export const StationListPage = observer(() => {

Станции

- + {canWriteStations && ( + + )}
diff --git a/src/pages/User/UserCreatePage/index.tsx b/src/pages/User/UserCreatePage/index.tsx index 2816b5b..e778016 100644 --- a/src/pages/User/UserCreatePage/index.tsx +++ b/src/pages/User/UserCreatePage/index.tsx @@ -1,10 +1,4 @@ -import { - Button, - Paper, - TextField, - Checkbox, - FormControlLabel, -} from "@mui/material"; +import { Button, Paper, TextField } from "@mui/material"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useNavigate } from "react-router-dom"; @@ -133,26 +127,6 @@ export const UserCreatePage = observer(() => { } /> -
- { - setCreateUserData( - createUserData.name || "", - createUserData.email || "", - createUserData.password || "", - e.target.checked, - createUserData.icon - ); - }} - /> - } - label="Администратор" - /> -
-
r !== `${resource}_ro` && r !== `${resource}_rw`, + ); + if (level === "ro") return [...filtered, `${resource}_ro`]; + if (level === "rw") return [...filtered, `${resource}_rw`]; + return filtered; +} + export const UserEditPage = observer(() => { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(true); const { id } = useParams(); - const { editUserData, editUser, getUser, setEditUserData } = userStore; + const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore; + const canReadCities = authStore.canRead("cities"); + + const [localRoles, setLocalRoles] = useState([]); + const [localCityIds, setLocalCityIds] = useState([]); + const [initialUserCities, setInitialUserCities] = useState([]); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -44,13 +97,65 @@ export const UserEditPage = observer(() => { languageStore.setLanguage("ru"); }, []); - const handleEdit = async () => { + useEffect(() => { + (async () => { + if (id) { + setIsLoadingData(true); + try { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + await Promise.all([ + mediaStore.getMedia(), + authStore.canRead("cities") + ? cityStore.getRuCities() + : authStore.fetchMeCities().catch(() => undefined), + ]); + const data = (await getUser(Number(id))) as User | undefined; + + if (data) { + setEditUserData( + data.name || "", + data.email || "", + data.password || "", + data.is_admin || false, + data.icon || "", + ); + + const roles = data.roles ?? []; + setLocalRoles(roles); + setEditUserRoles(roles); + + const cityIds = (data.cities ?? []).map((c) => c.city_id); + setLocalCityIds(cityIds); + setInitialUserCities(data.cities ?? []); + } + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); + } + })(); + }, [id]); + + const handleSave = async () => { try { setIsLoading(true); + + const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"]; + const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles])); + setEditUserRoles(rolesToSave); await editUser(Number(id)); - toast.success("Пользователь успешно обновлен"); + + await userStore.addUserCityAction({ + id: Number(id), + city_ids: localCityIds, + }); + + toast.success("Пользователь успешно обновлён"); navigate("/user"); - } catch (error) { + } catch { toast.error("Ошибка при обновлении пользователя"); } finally { setIsLoading(false); @@ -68,43 +173,43 @@ export const UserEditPage = observer(() => { editUserData.email || "", editUserData.password || "", editUserData.is_admin || false, - media.id + media.id, ); }; - useEffect(() => { - (async () => { - if (id) { - setIsLoadingData(true); - try { - await mediaStore.getMedia(); - const data = await getUser(Number(id)); - - if (data) { - setEditUserData( - data.name || "", - data.email || "", - data.password || "", - data.is_admin || false, - data.icon || "" - ); - } - } finally { - setIsLoadingData(false); - } - } else { - setIsLoadingData(false); - } - })(); - }, [id]); - const selectedMedia = editUserData.icon && !isMediaIdEmpty(editUserData.icon) ? mediaStore.media.find((m) => m.id === editUserData.icon) : null; const effectiveIconUrl = isMediaIdEmpty(editUserData.icon) ? null - : selectedMedia?.id ?? editUserData.icon ?? null; + : (selectedMedia?.id ?? editUserData.icon ?? null); + + const cityOptionsMap = new Map(); + + const sourceCities: UserCity[] = canReadCities + ? cityStore.ruCities.data + .filter((city) => city.id !== undefined) + .map((city) => ({ + city_id: city.id as number, + name: city.name, + })) + : authStore.meCities.ru; + + for (const city of sourceCities) { + cityOptionsMap.set(city.city_id, city.name); + } + + for (const city of initialUserCities) { + if (!cityOptionsMap.has(city.city_id)) { + cityOptionsMap.set(city.city_id, city.name); + } + } + + const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({ + value, + label, + })); if (isLoadingData) { return ( @@ -122,18 +227,16 @@ export const UserEditPage = observer(() => { } return ( - -
- -
+ + + + {/* ── Основные данные ── */} +
+ Основные данные -
{ editUserData.email || "", editUserData.password || "", editUserData.is_admin || false, - editUserData.icon + editUserData.icon, ) } /> @@ -160,11 +263,10 @@ export const UserEditPage = observer(() => { e.target.value, editUserData.password || "", editUserData.is_admin || false, - editUserData.icon + editUserData.icon, ) } /> - { editUserData.email || "", e.target.value, editUserData.is_admin || false, - editUserData.icon + editUserData.icon, ) } /> - - setEditUserData( - editUserData.name || "", - editUserData.email || "", - editUserData.password || "", - e.target.checked, - editUserData.icon - ) - } - /> - } - label="Администратор" - />
{ }} />
+
- -
+ + + {/* ── Права доступа ── */} +
+ Права доступа + + { + 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)" + /> + + + + + + Ресурс + Нет доступа + Чтение + Чтение/Запись + + Создание (snapshot_create) + + + + + {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 ( + + {label} + + handleChange(e.target.value)} + sx={{ justifyContent: "center", flexWrap: "nowrap" }} + > + + + + + {isSnapshotResource ? ( + + - + + ) : ( + handleChange(e.target.value)} + sx={{ justifyContent: "center", flexWrap: "nowrap" }} + > + + + )} + + + handleChange(e.target.value)} + sx={{ justifyContent: "center", flexWrap: "nowrap" }} + > + + + + + {isSnapshotResource ? ( + + handleSnapshotCreateChange(e.target.checked) + } + size="small" + /> + ) : ( + + - + + )} + + + ); + })} + +
+
+
+ + + + {/* ── Города ── */} +
+ Города + setLocalCityIds(ids as number[])} + label="Города" + placeholder="Выберите города" + loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData} + /> +
+ + { onSelectMedia={handleMediaSelect} mediaType={1} /> - setIsUploadMediaOpen(false)} @@ -249,13 +501,11 @@ export const UserEditPage = observer(() => { afterUpload={handleMediaSelect} hardcodeType={activeMenuType} /> - setIsPreviewMediaOpen(false)} mediaId={mediaId} /> - { @@ -264,7 +514,7 @@ export const UserEditPage = observer(() => { editUserData.email || "", editUserData.password || "", editUserData.is_admin || false, - "" + "", ); setIsDeleteIconModalOpen(false); }} diff --git a/src/pages/User/UserListPage/index.tsx b/src/pages/User/UserListPage/index.tsx index 3ab95fa..76335b6 100644 --- a/src/pages/User/UserListPage/index.tsx +++ b/src/pages/User/UserListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { userStore } from "@shared"; +import { authStore, userStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; @@ -20,6 +20,7 @@ export const UserListPage = observer(() => { page: 0, pageSize: 50, }); + const canWriteUsers = authStore.canWrite("users"); useEffect(() => { const fetchUsers = async () => { @@ -81,44 +82,35 @@ export const UserListPage = observer(() => { }, }, - { + ...(canWriteUsers ? [{ field: "actions", headerName: "Действия", flex: 1, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; const rows = users.data?.map((user) => ({ id: user.id, email: user.email, - is_admin: user.is_admin, + is_admin: user.is_admin || (user.roles ?? []).includes("admin"), name: user.name, })); @@ -127,7 +119,9 @@ export const UserListPage = observer(() => {

Пользователи

- + {canWriteUsers && ( + + )}
{ids.length > 0 && ( @@ -145,7 +139,7 @@ export const UserListPage = observer(() => { { field: "actions", headerName: "Действия", width: 200, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - renderCell: (params: GridRenderCellParams) => { + const canWrite = authStore.canWrite("devices"); return (
- + {canWrite && ( + + )} - + {canWrite && ( + + )}
); }, @@ -167,7 +171,7 @@ export const VehicleListPage = observer(() => { { }; export { authInstance, languageInstance }; +export { mobxFetch } from "./mobxFetch"; diff --git a/src/shared/api/mobxFetch/index.ts b/src/shared/api/mobxFetch/index.ts new file mode 100644 index 0000000..2ce9971 --- /dev/null +++ b/src/shared/api/mobxFetch/index.ts @@ -0,0 +1,183 @@ +import { runInAction } from "mobx"; + +type mobxFetchOptions = { + store: Store; + value?: keyof Store; + values?: Array; + loading?: keyof Store; + error?: keyof Store; + fn: RequestType extends void + ? (signal?: AbortSignal) => Promise + : (request: RequestType, signal?: AbortSignal) => Promise; + + pollingInterval?: number; + resetValue?: boolean; + transform?: (response: ResponseType) => Partial>; + onSuccess?: (response: ResponseType) => void; +}; + +type FetchFunction = RequestType extends void + ? { + (): Promise; + stopPolling?: () => void; + } + : { + (request: RequestType): Promise; + stopPolling?: () => void; + }; + +export function mobxFetch>( + options: mobxFetchOptions +): FetchFunction; + +export function mobxFetch< + RequestType, + ResponseType, + Store extends Record, +>( + options: mobxFetchOptions +): FetchFunction; + +export function mobxFetch< + RequestType, + ResponseType, + Store extends Record, +>( + options: mobxFetchOptions +): FetchFunction { + const { + store, + value, + values, + loading, + error, + fn, + pollingInterval, + resetValue, + transform, + onSuccess, + } = options; + + let abortController: AbortController | undefined; + let pollingTimer: ReturnType | undefined; + let currentRequest: RequestType | undefined; + + const stopPolling = () => { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = undefined; + } + abortController?.abort(); + }; + + const fetch = async (request?: RequestType): Promise => { + 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 + )(request, abortController.signal); + + runInAction(() => { + if (values && transform) { + const transformed = transform(result) as Record; + 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; +} diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index 40eca7b..fb6e2e9 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -23,12 +23,63 @@ interface NavigationItem { label: string; icon?: LucideIcon | React.ReactNode; path?: string; - for_admin?: boolean; + requiredRoles?: string[]; onClick?: () => void; nestedItems?: NavigationItem[]; isActive?: boolean; } +export const ROUTE_REQUIRED_RESOURCES: Record = { + "/": [], + + "/sight": ["sights"], + "/sight/create": ["sights"], + "/sight/:id/edit": ["sights"], + + "/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"], + + "/map": ["map"], + + "/media": ["sights"], + "/media/:id": ["sights"], + "/media/:id/edit": ["sights"], + + "/country": ["countries"], + "/country/create": ["countries"], + "/country/add": ["countries"], + "/country/:id/edit": ["countries"], + + "/city": ["cities", "countries"], + "/city/create": ["cities", "countries"], + "/city/:id/edit": ["cities", "countries"], + + "/route": ["routes", "carriers"], + "/route/create": ["routes", "carriers"], + "/route/:id/edit": ["routes", "carriers"], + + "/user": ["users"], + "/user/create": ["users"], + "/user/:id/edit": ["users"], + + "/snapshot": ["snapshot_rw"], + "/snapshot/create": ["snapshot_create", "devices_rw"], + + "/carrier": ["carriers"], + "/carrier/create": ["carriers"], + "/carrier/:id/edit": ["carriers"], + + "/station": ["stations"], + "/station/create": ["stations"], + "/station/:id": ["stations"], + "/station/:id/edit": ["stations"], + + "/vehicle/create": ["devices"], + "/vehicle/:id/edit": ["devices"], + + "/article": ["sights"], + "/article/:id": ["sights"], +}; + export const NAVIGATION_ITEMS: { primary: NavigationItem[]; secondary: NavigationItem[]; @@ -39,7 +90,7 @@ export const NAVIGATION_ITEMS: { label: "Экспорт", icon: GitBranch, path: "/snapshot", - for_admin: true, + requiredRoles: ["snapshot_rw", "snapshot_create"], }, { id: "map", @@ -52,14 +103,14 @@ export const NAVIGATION_ITEMS: { label: "Устройства", icon: Cpu, path: "/devices", - for_admin: true, + requiredRoles: ["devices_ro", "devices_rw"], }, { id: "users", label: "Пользователи", icon: Users, path: "/user", - for_admin: true, + requiredRoles: ["users_ro", "users_rw"], }, { id: "all", @@ -71,18 +122,21 @@ export const NAVIGATION_ITEMS: { label: "Достопримечательности", icon: Landmark, path: "/sight", + requiredRoles: ["sights_ro", "sights_rw"], }, { id: "stations", label: "Остановки", icon: PersonStanding, path: "/station", + requiredRoles: ["stations_ro", "stations_rw"], }, { id: "routes", label: "Маршруты", icon: Split, path: "/route", + requiredRoles: ["routes_ro", "routes_rw"], }, { @@ -90,14 +144,14 @@ export const NAVIGATION_ITEMS: { label: "Страны", icon: Earth, path: "/country", - for_admin: true, + requiredRoles: ["countries_ro", "countries_rw"], }, { id: "cities", label: "Города", icon: Building2, path: "/city", - for_admin: true, + requiredRoles: ["cities_ro", "cities_rw"], }, { id: "carriers", @@ -105,7 +159,7 @@ export const NAVIGATION_ITEMS: { // @ts-ignore icon: () => Перевозчики, 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(["admin"]); + const walk = (items: NavigationItem[]) => { + for (const item of items) { + item.requiredRoles?.forEach((r) => roles.add(r)); + item.nestedItems && walk(item.nestedItems); + } + }; + walk(list); + return Array.from(roles); +} + +export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary); + export const VEHICLE_TYPES = [ { label: "Автобус", value: 3 }, { label: "Троллейбус", value: 2 }, diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 33db187..471ab14 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,6 +1,7 @@ export * from "./mui/theme"; export * from "./DecodeJWT"; export * from "./gltfCacheManager"; +export * from "./permissions"; export const generateDefaultMediaName = ( objectName: string, diff --git a/src/shared/lib/permissions/index.ts b/src/shared/lib/permissions/index.ts new file mode 100644 index 0000000..2d46414 --- /dev/null +++ b/src/shared/lib/permissions/index.ts @@ -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), +}); diff --git a/src/shared/store/AuthStore/api.ts b/src/shared/store/AuthStore/api.ts new file mode 100644 index 0000000..005df50 --- /dev/null +++ b/src/shared/store/AuthStore/api.ts @@ -0,0 +1,25 @@ +import { languageInstance } from "@shared"; +import { User, UserCity } from "../UserStore"; + +export const getMeApi = async (): Promise => { + 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 ?? []), + }; +}; diff --git a/src/shared/store/AuthStore/index.tsx b/src/shared/store/AuthStore/index.tsx index 21cbd0f..51bdcf3 100644 --- a/src/shared/store/AuthStore/index.tsx +++ b/src/shared/store/AuthStore/index.tsx @@ -1,15 +1,13 @@ -import { API_URL, decodeJWT } from "@shared"; +import { API_URL, decodeJWT, mobxFetch } from "@shared"; +import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions"; import { makeAutoObservable, runInAction } from "mobx"; import axios, { AxiosError } from "axios"; +import { User, UserCity } from "../UserStore"; +import { getMeApi, getMeCitiesApi } from "./api"; type LoginResponse = { token: string; - user: { - id: number; - name: string; - email: string; - is_admin: boolean; - }; + user: Pick; }; class AuthStore { @@ -48,7 +46,7 @@ class AuthStore { { email, password, - } + }, ); const data = response.data; @@ -89,6 +87,78 @@ class AuthStore { get user() { return this.payload?.user; } + + get isAdmin(): boolean { + return ( + this.me?.is_admin === true || + (this.me?.roles ?? []).includes("admin") + ); + } + + me: User | null = null; + meLoading = false; + meError: string | null = null; + + meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = { + ru: [], + en: [], + zh: [], + }; + + getMeAction = mobxFetch({ + 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(); diff --git a/src/shared/store/CarrierStore/index.tsx b/src/shared/store/CarrierStore/index.tsx index f0886ba..e435685 100644 --- a/src/shared/store/CarrierStore/index.tsx +++ b/src/shared/store/CarrierStore/index.tsx @@ -1,5 +1,6 @@ import { authInstance, + authStore, cityStore, languageStore, languageInstance, @@ -145,12 +146,51 @@ class CarrierStore { }; }; + private resolveCityName = (cityId: number, preferredLanguage: Language) => { + if (!cityId) { + return ""; + } + + const languages: Language[] = ["ru", "en", "zh"]; + + const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find( + (city) => city.id === cityId + )?.name; + if (fromCityStorePreferred) { + return fromCityStorePreferred; + } + + for (const language of languages) { + const cityName = cityStore.cities[language].data.find( + (city) => city.id === cityId + )?.name; + if (cityName) { + return cityName; + } + } + + const fromMePreferred = authStore.meCities[preferredLanguage].find( + (city) => city.city_id === cityId + )?.name; + if (fromMePreferred) { + return fromMePreferred; + } + + for (const language of languages) { + const cityName = authStore.meCities[language].find( + (city) => city.city_id === cityId + )?.name; + if (cityName) { + return cityName; + } + } + + return ""; + }; + createCarrier = async () => { const { language } = languageStore; - const cityName = - cityStore.cities[language].data.find( - (city) => city.id === this.createCarrierData.city_id - )?.name || ""; + const cityName = this.resolveCityName(this.createCarrierData.city_id, language); const payload = { full_name: this.createCarrierData[language].full_name, @@ -172,12 +212,16 @@ class CarrierStore { }); for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { + const cityNameForLang = this.resolveCityName( + this.createCarrierData.city_id, + lang as Language + ); const patchPayload = { // @ts-ignore full_name: this.createCarrierData[lang as any].full_name as string, // @ts-ignore short_name: this.createCarrierData[lang as any].short_name as string, - city: cityName, + city: cityNameForLang || cityName, city_id: this.createCarrierData.city_id, // @ts-ignore slogan: this.createCarrierData[lang as any].slogan as string, @@ -273,13 +317,8 @@ class CarrierStore { }; editCarrier = async (id: number) => { - const { language } = languageStore; - const cityName = - cityStore.cities[language].data.find( - (city) => city.id === this.editCarrierData.city_id - )?.name || ""; - for (const lang of ["ru", "en", "zh"] as const) { + const cityName = this.resolveCityName(this.editCarrierData.city_id, lang); const response = await languageInstance(lang).patch(`/carrier/${id}`, { ...this.editCarrierData[lang], city: cityName, diff --git a/src/shared/store/UserStore/api.ts b/src/shared/store/UserStore/api.ts new file mode 100644 index 0000000..d2fb412 --- /dev/null +++ b/src/shared/store/UserStore/api.ts @@ -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 => { + const response = await authInstance.patch( + `/user/${id}/city`, + { city_ids }, + { signal }, + ); + return response.data as User; +}; diff --git a/src/shared/store/UserStore/index.ts b/src/shared/store/UserStore/index.ts index c748337..7e43248 100644 --- a/src/shared/store/UserStore/index.ts +++ b/src/shared/store/UserStore/index.ts @@ -1,5 +1,11 @@ -import { authInstance } from "@shared"; +import { authInstance, mobxFetch } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; +import { addUserCityApi } from "./api"; + +export type UserCity = { + city_id: number; + name: string; +}; export type User = { id: number; @@ -8,6 +14,8 @@ export type User = { name: string; password?: string; icon?: string; + roles?: string[]; + cities?: UserCity[]; }; class UserStore { @@ -59,6 +67,7 @@ class UserStore { password: "", is_admin: false, icon: "", + roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"], }; setCreateUserData = ( @@ -66,9 +75,10 @@ class UserStore { email: string, password: string, is_admin: boolean, - icon?: string + icon?: string, ) => { this.createUserData = { + ...this.createUserData, name, email, password, @@ -82,7 +92,13 @@ class UserStore { if (this.users.data.length > 0) { id = this.users.data[this.users.data.length - 1].id + 1; } - const payload = { ...this.createUserData }; + const payload: Partial = { ...this.createUserData }; + const baseRoles = new Set(payload.roles ?? []); + baseRoles.add("articles_ro"); + baseRoles.add("articles_rw"); + baseRoles.add("media_ro"); + baseRoles.add("media_rw"); + payload.roles = Array.from(baseRoles); if (!payload.icon) delete payload.icon; const response = await authInstance.post("/user", payload); @@ -100,6 +116,7 @@ class UserStore { password: "", is_admin: false, icon: "", + roles: [], }; setEditUserData = ( @@ -107,9 +124,10 @@ class UserStore { email: string, password: string, is_admin: boolean, - icon?: string + icon?: string, ) => { this.editUserData = { + ...this.editUserData, name, email, password, @@ -118,19 +136,50 @@ class UserStore { }; }; + setEditUserRoles = (roles: string[]) => { + this.editUserData = { ...this.editUserData, roles }; + }; + editUser = async (id: number) => { const payload = { ...this.editUserData }; if (!payload.icon) delete payload.icon; if (!payload.password?.trim()) delete payload.password; + const response = await authInstance.patch(`/user/${id}`, payload); runInAction(() => { this.users.data = this.users.data.map((user) => - user.id === id ? { ...user, ...response.data } : user + user.id === id ? { ...user, ...response.data } : user, ); this.user[id] = { ...this.user[id], ...response.data }; }); }; + + addUserCityResult: User | null = null; + addUserCityLoading = false; + addUserCityError: string | null = null; + + addUserCityAction = mobxFetch< + { id: number; city_ids: number[] }, + User, + UserStore + >({ + store: this, + value: "addUserCityResult", + loading: "addUserCityLoading", + error: "addUserCityError", + fn: addUserCityApi, + onSuccess: (result) => { + runInAction(() => { + this.users.data = this.users.data.map((user) => + user.id === result.id ? { ...user, ...result } : user, + ); + if (this.user[result.id]) { + this.user[result.id] = { ...this.user[result.id], ...result }; + } + }); + }, + }); } export const userStore = new UserStore(); diff --git a/src/shared/store/VehicleStore/api.ts b/src/shared/store/VehicleStore/api.ts new file mode 100644 index 0000000..25aa22f --- /dev/null +++ b/src/shared/store/VehicleStore/api.ts @@ -0,0 +1,13 @@ +import { languageInstance } from "@shared"; +import { VehicleMaintenanceSession } from "./types"; + +export const getVehicleSessionsApi = async ( + id: number, + signal?: AbortSignal, +): Promise => { + const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, { + signal, + }); + + return Array.isArray(response.data) ? response.data : []; +}; diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts index 0c9b7cb..525829e 100644 --- a/src/shared/store/VehicleStore/index.ts +++ b/src/shared/store/VehicleStore/index.ts @@ -1,30 +1,9 @@ -import { authInstance, languageInstance } from "@shared"; +import { authInstance, languageInstance, mobxFetch } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; +import { getVehicleSessionsApi } from "./api"; +import { Vehicle, VehicleMaintenanceSession } from "./types"; -export type Vehicle = { - vehicle: { - id: number; - tail_number: string; - type: number; - carrier_id: number; - carrier: string; - uuid?: string; - model?: string; - current_snapshot_uuid?: string; - snapshot_update_blocked?: boolean; - demo_mode_enabled?: boolean; - maintenance_mode_on?: boolean; - city_id?: number; - }; - device_status?: { - device_uuid: string; - online: boolean; - gps_ok: boolean; - media_service_ok: boolean; - last_update: string; - is_connected: boolean; - }; -}; +export type { Vehicle, VehicleMaintenanceSession } from "./types"; class VehicleStore { vehicles: { @@ -35,6 +14,9 @@ class VehicleStore { loaded: false, }; vehicle: Record = {}; + vehicleSessions: VehicleMaintenanceSession[] | null = null; + vehicleSessionsLoading = false; + vehicleSessionsError: string | null = null; constructor() { makeAutoObservable(this); @@ -89,7 +71,7 @@ class VehicleStore { if (updatedUuid != null) { const entry = Object.entries(this.vehicle).find( - ([, item]) => item.vehicle.uuid === updatedUuid + ([, item]) => item.vehicle.uuid === updatedUuid, ); if (entry) { @@ -118,7 +100,7 @@ class VehicleStore { runInAction(() => { this.vehicles.data = this.vehicles.data.filter( - (vehicle) => vehicle.vehicle.id !== id + (vehicle) => vehicle.vehicle.id !== id, ); }); }; @@ -137,7 +119,7 @@ class VehicleStore { type: number, carrier: string, carrierId: number, - model?: string + model?: string, ) => { const payload: Record = { tail_number: tailNumber, @@ -197,7 +179,7 @@ class VehicleStore { carrier_id: number; model?: string; snapshot_update_blocked?: boolean; - } + }, ) => { const payload: Record = { tail_number: data.tail_number, @@ -210,7 +192,7 @@ class VehicleStore { payload.snapshot_update_blocked = data.snapshot_update_blocked; const response = await languageInstance("ru").patch( `/vehicle/${id}`, - payload + payload, ); const normalizedVehicle = this.normalizeVehicleItem(response.data); const updatedVehiclePayload = { @@ -230,9 +212,12 @@ class VehicleStore { }; setMaintenanceMode = async (uuid: string, enabled: boolean) => { - const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, { - enabled, - }); + const response = await authInstance.post( + `/devices/${uuid}/maintenance-mode`, + { + enabled, + }, + ); const normalizedVehicle = this.normalizeVehicleItem(response.data); runInAction(() => { @@ -255,10 +240,24 @@ class VehicleStore { this.mergeVehicleInCaches({ ...normalizedVehicle.vehicle, uuid, - demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled, + demo_mode_enabled: + normalizedVehicle.vehicle.demo_mode_enabled ?? enabled, }); }); }; + + getVehicleSessions = mobxFetch< + number, + VehicleMaintenanceSession[], + VehicleStore + >({ + store: this, + value: "vehicleSessions", + loading: "vehicleSessionsLoading", + error: "vehicleSessionsError", + resetValue: true, + fn: getVehicleSessionsApi, + }); } export const vehicleStore = new VehicleStore(); diff --git a/src/shared/store/VehicleStore/types.ts b/src/shared/store/VehicleStore/types.ts new file mode 100644 index 0000000..11bd39e --- /dev/null +++ b/src/shared/store/VehicleStore/types.ts @@ -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; +}; diff --git a/src/shared/ui/MultiSelect/index.tsx b/src/shared/ui/MultiSelect/index.tsx new file mode 100644 index 0000000..d8016e3 --- /dev/null +++ b/src/shared/ui/MultiSelect/index.tsx @@ -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 { + readonly value: TValue; + readonly label: string; +} + +interface MultiSelectProps { + readonly options: MultiSelectOption[]; + 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({ + options, + value, + onChange, + label, + placeholder, + loading = false, + disabled = false, + error = false, + helperText, + size = "small", + fullWidth = true, +}: MultiSelectProps) { + const selectedOptions = options.filter((opt) => value.includes(opt.value)); + + return ( + 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 & { key: React.Key }; + return ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {option.label} +
  • + ); + }} + renderInput={(params) => ( + + {loading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 1adb123..e0e28d8 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -4,3 +4,4 @@ export * from "./Modal"; export * from "./CoordinatesInput"; export * from "./AnimatedCircleButton"; export * from "./LoadingSpinner"; +export * from "./MultiSelect"; diff --git a/src/widgets/CitySelector/index.tsx b/src/widgets/CitySelector/index.tsx index 6ac2771..7f08aab 100644 --- a/src/widgets/CitySelector/index.tsx +++ b/src/widgets/CitySelector/index.tsx @@ -8,16 +8,40 @@ import { Box, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { cityStore, selectedCityStore } from "@shared"; +import { authStore, cityStore, selectedCityStore, type City } from "@shared"; import { MapPin } from "lucide-react"; export const CitySelector: React.FC = observer(() => { - const { getCities, cities } = cityStore; const { selectedCity, setSelectedCity } = selectedCityStore; + const canReadCities = authStore.canRead("cities"); useEffect(() => { - getCities("ru"); - }, []); + if (canReadCities) { + cityStore.getCities("ru"); + return; + } + authStore.fetchMeCities().catch(() => undefined); + }, [canReadCities]); + + const baseCities: City[] = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((uc) => ({ + id: uc.city_id, + name: uc.name, + country: "", + country_code: "", + arms: "", + })); + + const currentCities: City[] = selectedCity?.id + ? (() => { + const exists = baseCities.some((city) => city.id === selectedCity.id); + if (exists) { + return baseCities; + } + return [selectedCity, ...baseCities]; + })() + : baseCities; const handleCityChange = (event: SelectChangeEvent) => { const cityId = event.target.value; @@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => { return; } - const city = cities["ru"].data.find((c) => c.id === Number(cityId)); + const city = currentCities.find((c) => c.id === Number(cityId)); if (city) { setSelectedCity(city); } }; - const currentCities = cities["ru"].data; - return ( @@ -51,16 +73,13 @@ export const CitySelector: React.FC = observer(() => { "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255, 255, 255, 0.5)", }, - "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + "&.Mui.focused .MuiOutlinedInput-notchedOutline": { borderColor: "white", }, "& .MuiSvgIcon-root": { color: "white", }, }} - MenuProps={{ - PaperProps: {}, - }} > Выберите город diff --git a/src/widgets/DevicesTable/VehicleSessionsModal.tsx b/src/widgets/DevicesTable/VehicleSessionsModal.tsx new file mode 100644 index 0000000..901a075 --- /dev/null +++ b/src/widgets/DevicesTable/VehicleSessionsModal.tsx @@ -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 ( + +
    + + {title} + + + + {vehicleSessionsLoading && ( + + + + )} + + {!vehicleSessionsLoading && vehicleSessionsError && ( + + {vehicleSessionsError} + + )} + + {!vehicleSessionsLoading && + !vehicleSessionsError && + vehicleSessions && + vehicleSessions.length === 0 && ( + + По этому транспорту нет сессий ТО. + + )} + + {!vehicleSessionsLoading && + !vehicleSessionsError && + vehicleSessions && + vehicleSessions.length > 0 && ( + + + + + ID + Начало + Окончание + Длительность + + + + {vehicleSessions.map((session) => ( + + {session.id} + + {formatDateTime(session.started_at)} + + + {formatDateTime(session.ended_at)} + + + {formatDuration(session.duration_seconds)} + + + ))} + +
    +
    + )} +
    + + +
    +
    + ); + }, +); diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index 3bf116d..7a58baa 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -1,11 +1,13 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; import { + authStore, authInstance, devicesStore, Modal, snapshotStore, vehicleStore, + routeStore, Vehicle, carrierStore, selectedCityStore, @@ -22,6 +24,7 @@ import { RotateCcw, ScrollText, Trash2, + Wrench, X, } from "lucide-react"; import { @@ -35,6 +38,7 @@ import { toast } from "react-toastify"; import { useNavigate } from "react-router-dom"; import { DeleteModal } from "@widgets"; import { DeviceLogsModal } from "./DeviceLogsModal"; +import { VehicleSessionsModal } from "./VehicleSessionsModal"; export type ConnectedDevice = string; @@ -77,6 +81,13 @@ type RowData = { snapshot_update_blocked: boolean; maintenance_mode_on: boolean; demo_mode_enabled: boolean; + current_route_id: number | null; +}; + +type PendingModeToggle = { + deviceUuid: string; + nextEnabled: boolean; + tailNumber: string; }; function getVehicleTypeLabel(vehicle: Vehicle): string { @@ -109,11 +120,13 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => { snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false, demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false, + current_route_id: vehicle.device_status?.current_route_id ?? null, }; }); }; export const DevicesTable = observer(() => { + const canWriteDevices = authStore.canWrite("devices"); const { getDevices, setSelectedDevice, @@ -123,6 +136,7 @@ export const DevicesTable = observer(() => { } = devicesStore; const { snapshots, getSnapshots } = snapshotStore; + const { routes, getRoutes } = routeStore; const { getVehicles, vehicles, @@ -137,6 +151,12 @@ export const DevicesTable = observer(() => { const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState( null, ); + const [sessionsModalOpen, setSessionsModalOpen] = useState(false); + const [sessionsModalVehicleId, setSessionsModalVehicleId] = useState< + number | null + >(null); + const [sessionsModalVehicleTailNumber, setSessionsModalVehicleTailNumber] = + useState(null); const [isLoading, setIsLoading] = useState(false); const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState< Set @@ -144,6 +164,14 @@ export const DevicesTable = observer(() => { const [demoLoadingUuids, setDemoLoadingUuids] = useState>( new Set(), ); + const [maintenanceConfirm, setMaintenanceConfirm] = + useState(null); + const [demoConfirm, setDemoConfirm] = useState( + null, + ); + const [maintenanceConfirmSubmitting, setMaintenanceConfirmSubmitting] = + useState(false); + const [demoConfirmSubmitting, setDemoConfirmSubmitting] = useState(false); const [collapsedModels, setCollapsedModels] = useState>( new Set(), ); @@ -223,65 +251,108 @@ export const DevicesTable = observer(() => { .map((r) => r.device_uuid as string); }, [rows, selectedIds]); - const handleToggleMaintenanceMode = async (row: RowData) => { - if (!row.device_uuid) return; - - const nextEnabled = !row.maintenance_mode_on; + const applyMaintenanceMode = async (toggle: PendingModeToggle) => { setMaintenanceLoadingUuids((prev) => { const next = new Set(prev); - next.add(row.device_uuid!); + next.add(toggle.deviceUuid); return next; }); try { - await setMaintenanceMode(row.device_uuid, nextEnabled); + await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled); await getVehicles(); await getDevices(); toast.success( - nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен", + toggle.nextEnabled + ? "Устройство отправлено на ТО" + : "Режим ТО отключен", ); } catch (error) { console.error( - `Error toggling maintenance mode for ${row.device_uuid}:`, + `Error toggling maintenance mode for ${toggle.deviceUuid}:`, error, ); toast.error("Не удалось изменить режим ТО"); } finally { setMaintenanceLoadingUuids((prev) => { const next = new Set(prev); - next.delete(row.device_uuid!); + next.delete(toggle.deviceUuid); return next; }); } }; - const handleToggleDemoMode = async (row: RowData) => { - if (!row.device_uuid) return; - - const nextEnabled = !row.demo_mode_enabled; + const applyDemoMode = async (toggle: PendingModeToggle) => { setDemoLoadingUuids((prev) => { const next = new Set(prev); - next.add(row.device_uuid!); + next.add(toggle.deviceUuid); return next; }); try { - await setDemoMode(row.device_uuid, nextEnabled); + await setDemoMode(toggle.deviceUuid, toggle.nextEnabled); await getVehicles(); await getDevices(); - toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен"); + toast.success( + toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен", + ); } catch (error) { - console.error(`Error toggling demo mode for ${row.device_uuid}:`, error); + console.error( + `Error toggling demo mode for ${toggle.deviceUuid}:`, + error, + ); toast.error("Не удалось изменить демо-режим"); } finally { setDemoLoadingUuids((prev) => { const next = new Set(prev); - next.delete(row.device_uuid!); + next.delete(toggle.deviceUuid); return next; }); } }; + const openMaintenanceConfirm = (row: RowData) => { + if (!row.device_uuid) return; + setMaintenanceConfirm({ + deviceUuid: row.device_uuid, + nextEnabled: !row.maintenance_mode_on, + tailNumber: row.tail_number, + }); + }; + + const openDemoConfirm = (row: RowData) => { + if (!row.device_uuid) return; + setDemoConfirm({ + deviceUuid: row.device_uuid, + nextEnabled: !row.demo_mode_enabled, + tailNumber: row.tail_number, + }); + }; + + const handleConfirmMaintenanceToggle = async () => { + if (!maintenanceConfirm) return; + + setMaintenanceConfirmSubmitting(true); + try { + await applyMaintenanceMode(maintenanceConfirm); + setMaintenanceConfirm(null); + } finally { + setMaintenanceConfirmSubmitting(false); + } + }; + + const handleConfirmDemoToggle = async () => { + if (!demoConfirm) return; + + setDemoConfirmSubmitting(true); + try { + await applyDemoMode(demoConfirm); + setDemoConfirm(null); + } finally { + setDemoConfirmSubmitting(false); + } + }; + const columns: GridColDef[] = useMemo( () => [ { @@ -375,10 +446,14 @@ export const DevicesTable = observer(() => { > handleToggleMaintenanceMode(rowData)} + onChange={() => openMaintenanceConfirm(rowData)} />
    ); @@ -404,10 +479,12 @@ export const DevicesTable = observer(() => { > handleToggleDemoMode(rowData)} + onChange={() => openDemoConfirm(rowData)} /> ); @@ -436,6 +513,20 @@ export const DevicesTable = observer(() => { return snapshot?.Name ?? uuid; }, }, + { + field: "current_route", + headerName: "Текущий маршрут", + flex: 1, + minWidth: 140, + filterable: true, + valueGetter: (_value, row) => { + const rowData = row as RowData; + const routeId = rowData.current_route_id; + if (!routeId) return "—"; + const route = routes.data.find((r) => r.id === routeId); + return route?.route_number || "—"; + }, + }, { field: "gps", headerName: "GPS", @@ -496,15 +587,17 @@ export const DevicesTable = observer(() => { justifyContent: "center", }} > - + {canWriteDevices && ( + + )} + + {canWriteDevices && ( + + )} {selectedIds.length > 0 && ( )} - + {canWriteDevices && ( + + )}
    {groupsByModel.length === 0 ? ( @@ -787,52 +905,59 @@ export const DevicesTable = observer(() => { )}
    - - - Обновление ПО - - - Выбрано устройств для обновления:{" "} - - {selectedDeviceUuidsAllowed.length} - - {selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length && ( - - (пропущено{" "} - {selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length} с - блокировкой) - - )} - -
    - {snapshots && (snapshots as Snapshot[]).length > 0 ? ( - (snapshots as Snapshot[]).map((snapshot) => ( - - )) - ) : ( - - Нет доступных экспортов медиа. - - )} -
    - -
    + + Обновление ПО + + + Выбрано устройств для обновления:{" "} + + {selectedDeviceUuidsAllowed.length} + + {selectedDeviceUuids.length !== + selectedDeviceUuidsAllowed.length && ( + + (пропущено{" "} + {selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "} + с блокировкой) + + )} + +
    + {snapshots && (snapshots as Snapshot[]).length > 0 ? ( + (snapshots as Snapshot[]).map((snapshot) => ( + + )) + ) : ( + + Нет доступных экспортов медиа. + + )} +
    + + + )} { onCancel={() => setIsDeleteModalOpen(false)} /> + { + if (!maintenanceConfirmSubmitting) setMaintenanceConfirm(null); + }} + sx={{ width: "min(640px, 92vw)", p: 3 }} + > + + Подтверждение режима ТО + + + {maintenanceConfirm?.nextEnabled + ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` + : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} + + + + + + + + { + if (!demoConfirmSubmitting) setDemoConfirm(null); + }} + sx={{ width: "min(640px, 92vw)", p: 3 }} + > + + Подтверждение демо-режима + + + {demoConfirm?.nextEnabled + ? `Включить демо-режим для устройства ${demoConfirm.tailNumber}?` + : `Отключить демо-режим для устройства ${demoConfirm?.tailNumber}?`} + + + + + + + { setLogsModalDeviceUuid(null); }} /> + + { + setSessionsModalOpen(false); + setSessionsModalVehicleId(null); + setSessionsModalVehicleTailNumber(null); + }} + /> ); }); diff --git a/src/widgets/Layout/index.tsx b/src/widgets/Layout/index.tsx index 40f6f5b..10e2723 100644 --- a/src/widgets/Layout/index.tsx +++ b/src/widgets/Layout/index.tsx @@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar"; import { Drawer } from "./ui/Drawer"; import { DrawerHeader } from "./ui/DrawerHeader"; import { NavigationList } from "@features"; -import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared"; +import { authStore, menuStore, isMediaIdEmpty } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect } from "react"; import { Typography } from "@mui/material"; @@ -27,13 +27,10 @@ export const Layout: React.FC = observer(({ children }) => { setIsMenuOpen(open); }, [open]); - const { getUsers, users } = userStore; + const { getMeAction, me } = authStore; useEffect(() => { - const fetchUsers = async () => { - await getUsers(); - }; - fetchUsers(); + getMeAction(); }, []); const handleDrawerOpen = () => { @@ -68,17 +65,13 @@ export const Layout: React.FC = observer(({ children }) => {
    {(() => { - const currentUser = users?.data?.find( - (user) => user.id === (authStore.payload as { user_id?: number })?.user_id, - ); - const hasAvatar = - currentUser?.icon && !isMediaIdEmpty(currentUser.icon); + const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon); const token = localStorage.getItem("token"); return ( <>
    -

    {currentUser?.name}

    +

    {me?.name}

    = observer(({ children }) => { padding: "2px 10px", }} > - {(authStore.payload as { is_admin?: boolean })?.is_admin + {me?.roles?.includes("admin") ? "Администратор" : "Режим пользователя"}
    @@ -98,7 +91,7 @@ export const Layout: React.FC = observer(({ children }) => { Аватар diff --git a/src/widgets/SightTabs/CreateInformationTab/index.tsx b/src/widgets/SightTabs/CreateInformationTab/index.tsx index c5acf86..6c8499a 100644 --- a/src/widgets/SightTabs/CreateInformationTab/index.tsx +++ b/src/widgets/SightTabs/CreateInformationTab/index.tsx @@ -14,6 +14,7 @@ import { BackButton, TabPanel, languageStore, + authStore, Language, cityStore, isMediaIdEmpty, @@ -40,7 +41,7 @@ import { SaveWithoutCityAgree } from "@widgets"; export const CreateInformationTab = observer( ({ value, index }: { value: number; index: number }) => { - const { cities } = cityStore; + const canReadCities = authStore.canRead("cities"); const [mediaId, setMediaId] = useState(""); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -64,6 +65,30 @@ export const CreateInformationTab = observer( // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); + const baseCities = canReadCities + ? cityStore.cities["ru"]?.data ?? [] + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + sight.city_id && !baseCities.some((city) => city.id === sight.city_id) + ? [ + { + id: sight.city_id, + name: sight.city || `Город ${sight.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + useEffect(() => {}, [hardcodeType]); useEffect(() => { @@ -208,17 +233,16 @@ export const CreateInformationTab = observer( /> city.id === sight.city_id - ) ?? null + availableCities.find((city) => city.id === sight.city_id) ?? null } getOptionLabel={(option) => option.name} onChange={(_, value) => { setCity(value?.id ?? 0); handleChange({ city_id: value?.id ?? 0, + city: value?.name ?? "", }); }} renderInput={(params) => ( diff --git a/src/widgets/SightTabs/InformationTab/index.tsx b/src/widgets/SightTabs/InformationTab/index.tsx index 19f8951..d8e956b 100644 --- a/src/widgets/SightTabs/InformationTab/index.tsx +++ b/src/widgets/SightTabs/InformationTab/index.tsx @@ -14,6 +14,7 @@ import { BackButton, TabPanel, languageStore, + authStore, Language, cityStore, editSightStore, @@ -62,10 +63,35 @@ export const InformationTab = observer( const [hardcodeType, setHardcodeType] = useState< "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null >(null); - const { cities } = cityStore; + const canReadCities = authStore.canRead("cities"); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); + const baseCities = canReadCities + ? cityStore.cities["ru"]?.data ?? [] + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + sight.common.city_id && + !baseCities.some((city) => city.id === sight.common.city_id) + ? [ + { + id: sight.common.city_id, + name: sight.common.city || `Город ${sight.common.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + useEffect(() => {}, [hardcodeType]); useEffect(() => { @@ -208,11 +234,9 @@ export const InformationTab = observer( /> city.id === sight.common.city_id - ) ?? null + availableCities.find((city) => city.id === sight.common.city_id) ?? null } getOptionLabel={(option) => option.name} onChange={(_, value) => { @@ -221,6 +245,7 @@ export const InformationTab = observer( language as Language, { city_id: value?.id ?? 0, + city: value?.name ?? "", }, true ); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index be3d550..46dfa28 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/route/route-preview/web-gl/languageselector.tsx","./src/pages/route/route-preview/webgl-prototype/webglroutemapprototype.tsx","./src/pages/sight/linkedstations.tsx","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/articleselectorcreatedialog/index.tsx","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/animatedcirclebutton.tsx","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/devicelogsmodal.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/editstationtransfersmodal/index.tsx","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/route/route-preview/web-gl/languageselector.tsx","./src/pages/route/route-preview/webgl-prototype/webglroutemapprototype.tsx","./src/pages/sight/linkedstations.tsx","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/api/mobxfetch/index.ts","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/lib/permissions/index.ts","./src/shared/modals/index.ts","./src/shared/modals/articleselectorcreatedialog/index.tsx","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/api.ts","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/userstore/api.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/api.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/store/vehiclestore/types.ts","./src/shared/ui/animatedcirclebutton.tsx","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/multiselect/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/devicelogsmodal.tsx","./src/widgets/devicestable/vehiclesessionsmodal.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/editstationtransfersmodal/index.tsx","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file