51 Commits

Author SHA1 Message Date
b88d83cd74 Fix build errors 2024-09-26 12:09:38 +09:00
108dc5082c Disabled signup; Map test 2024-09-26 12:02:01 +09:00
33f41aaab0 Tile generation 2024-09-10 17:26:05 +09:00
8c8c619143 Tile generation 2024-09-09 17:49:32 +09:00
ddacbcd837 Map experiments 2024-09-06 17:56:42 +09:00
3994989994 Map update 2024-09-05 17:14:48 +09:00
ab88fd5ea5 Map caching, clickhouse test service 2024-08-26 16:11:37 +09:00
579bbf7764 Map caching in Redis 2024-08-23 17:50:53 +09:00
97b44a4db7 Rename; Added EMS server; redis compose 2024-08-20 17:34:21 +09:00
61339f4c26 Revert 2024-08-01 14:04:59 +09:00
8d68119ded Build script 2024-08-01 14:01:58 +09:00
00af65ecdb Copy after build 2024-08-01 13:58:31 +09:00
3a090bf1ad Copy dist 2024-08-01 13:56:39 +09:00
878f206189 Remove dist from volumes 2024-08-01 13:53:13 +09:00
748aa81a99 Settings & FormFields 2024-08-01 11:06:25 +09:00
1e802b4550 Refactored forms 2024-07-30 17:39:57 +09:00
a1a5c2b3a6 Remove spread fill to avoid duplicate values on autocomplete 2024-07-22 17:00:47 +09:00
a3b0b1b222 Cleanup 2024-07-22 16:43:52 +09:00
424217a895 Cleanup 2024-07-22 16:43:13 +09:00
0ac0534486 Cleanup, shared create modal 2024-07-22 16:42:17 +09:00
e1f9dc762c Make dist as volume at compose 2024-07-22 16:16:02 +09:00
153806f392 Remove unused imports 2024-07-22 15:58:25 +09:00
ae2213b188 Reports: city autocomplete, mutation, refresh 2024-07-22 15:51:37 +09:00
748cf89b35 Build ahead of Docker image pull 2024-07-19 16:57:29 +09:00
492fbd7d89 Update README 2024-07-19 16:32:34 +09:00
e3af090119 Build & serve 2024-07-19 16:13:29 +09:00
53e9a8cadf Cleanup 2024-07-19 14:43:58 +09:00
a3043afa7b axiosInstance config 2024-07-19 10:46:34 +09:00
ca2d97f975 define Docker 2024-07-18 17:36:52 +09:00
cf3fda43e4 Update 2024-07-18 11:48:56 +09:00
4283bd20bb DataGrid cell autocomplete 2024-07-15 17:48:48 +09:00
e566e23f6d Servers: servers, ips, hw, storages 2024-07-15 12:39:53 +09:00
416e2e39b5 Tables, cards, (servers) 2024-07-12 17:44:44 +09:00
f9de1124c3 Cleanup 2024-07-10 14:37:00 +09:00
a65a431b09 Testing: fetch servers by region 2024-07-09 16:44:41 +09:00
6f4aa1903d Servers API 2024-07-09 11:58:45 +09:00
c74c911eea Minor fixes 2024-07-05 18:00:31 +09:00
d298de0a72 Multiple files upload 2024-07-05 17:35:13 +09:00
3727fcabb3 docx, xlsx, pdf viewers, dropzone for uploading, cleanup 2024-07-05 17:11:29 +09:00
261196afef Reports test 2024-07-04 15:18:06 +09:00
2c71e4f6af File uploading 2024-07-02 10:51:02 +09:00
704276037c upstream to API changes 2024-07-01 17:51:50 +09:00
e70d94afec Documents page, WIP 2024-06-28 17:56:34 +09:00
7ba886e966 Add Document API calls 2024-06-28 16:03:12 +09:00
af1d497715 Rename interfaces, AppBar changes 2024-06-28 12:33:07 +09:00
c41e59cd86 DashboardLayout changes, refactoring, useSWR 2024-06-27 17:32:12 +09:00
18fb120777 Refactored store 2024-06-25 15:56:00 +09:00
85f97e9e0e Roles: useDataFetching + DataGrid 2024-06-24 17:48:52 +09:00
c2688855c3 Auth: react-hook-form 2024-06-24 17:48:01 +09:00
62695acf74 Auth: SignIn, SignUp (TODO: rewrite into react-hook-form) 2024-06-24 17:06:41 +09:00
d6906503d1 Layout, Pages, Dashboard, MUI 2024-06-20 16:59:59 +09:00
125 changed files with 27315 additions and 6000 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
REDIS_HOST=redis_db
REDIS_PORT=6379
REDIS_PASSWORD=
POSTGRES_HOST=localhost
POSTGRES_DB=ems
POSTGRES_USER=ems
POSTGRES_PASSWORD=
POSTGRES_PORT=5432
EMS_PORT=5000
MONITOR_PORT=1234
CLICKHOUSE_DB=test_db
CLICKHOUSE_USER=test_user
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
CLICKHOUSE_PASSWORD=

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
.vscode
__pycache__
.env
redis_data
psql_data

14
client/.env.example Normal file
View File

@ -0,0 +1,14 @@
# API авторизации
VITE_API_AUTH_URL=
# API info
VITE_API_INFO_URL=
# API fuel
VITE_API_FUEL_URL=
# API servers
VITE_API_SERVERS_URL=
# API EMS
VITE_API_EMS_URL=

15
client/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 5173
CMD ["npm", "run", "serve"]

18
client/README.md Normal file
View File

@ -0,0 +1,18 @@
# Experimental Frontend
## Структура проекта
- `src/assets/` - Статические ассеты
- `src/components/` - Компоненты
- `src/constants/` - Константы
- `src/layouts/` - Макеты для разных частей, пока есть MainLayout, используемый всеми роутами
- `src/pages/` - Страницы
- `src/services/` - сервисы / API
## UI
В основном, используется Material UI https://mui.com/material-ui
Для кастомных компонентов следует создать директорию в `src/components/НазваниеКомпонента` со стилями, если необходимо
## Env vars
`.env.example` должен описывать используемые переменные, в работе же используется `.env.local` или `.env`

BIN
client/bun.lockb Normal file

Binary file not shown.

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Dashboard</title>
</head>
<body>
<div id="root"></div>

11055
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
client/package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "frontend_reactjs",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"serve": "serve -s dist -l 5173"
},
"dependencies": {
"-": "^0.0.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/inter": "^5.0.19",
"@fontsource/open-sans": "^5.0.28",
"@js-preview/docx": "^1.6.2",
"@js-preview/excel": "^1.7.8",
"@js-preview/pdf": "^2.0.2",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@mui/x-charts": "^7.8.0",
"@mui/x-data-grid": "^7.7.1",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.5.0",
"@uidotdev/usehooks": "^2.4.1",
"autoprefixer": "^10.4.19",
"axios": "^1.7.2",
"buffer": "^6.0.3",
"file-type": "^19.0.0",
"ol": "^10.0.0",
"ol-ext": "^4.0.23",
"postcss": "^8.4.38",
"proj4": "^2.12.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.23.1",
"swr": "^2.2.5",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/proj4": "^2.5.5",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"serve": "^14.2.3",
"tailwindcss": "^3.4.4",
"typescript": "^5.2.2",
"vite": "^5.3.5",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.20.0"
}
}

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

210
client/src/App.tsx Normal file
View File

@ -0,0 +1,210 @@
import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router-dom"
import Main from "./pages/Main"
import Users from "./pages/Users"
import Roles from "./pages/Roles"
import NotFound from "./pages/NotFound"
import DashboardLayout from "./layouts/DashboardLayout"
import MainLayout from "./layouts/MainLayout"
import SignIn from "./pages/auth/SignIn"
import ApiTest from "./pages/ApiTest"
import SignUp from "./pages/auth/SignUp"
import { initAuth, useAuthStore } from "./store/auth"
import { useEffect, useState } from "react"
import { Box, CircularProgress } from "@mui/material"
import Documents from "./pages/Documents"
import Reports from "./pages/Reports"
import Boilers from "./pages/Boilers"
import Servers from "./pages/Servers"
import { Api, Assignment, Cloud, Factory, Home, Login, Map, MonitorHeart, Password, People, Settings as SettingsIcon, Shield, Storage, Warning } from "@mui/icons-material"
import Settings from "./pages/Settings"
import PasswordReset from "./pages/auth/PasswordReset"
import MapTest from "./pages/MapTest"
import MonitorPage from "./pages/MonitorPage"
import ChunkedUpload from "./components/map/ChunkedUpload"
// Определение страниц с путями и компонентом для рендера
export const pages = [
{
label: "",
path: "/auth/signin",
icon: <Login />,
component: <SignIn />,
drawer: false,
dashboard: false,
enabled: true,
},
{
label: "",
path: "/auth/signup",
icon: <Login />,
component: <SignUp />,
drawer: false,
dashboard: false,
enabled: false,
},
{
label: "",
path: "/auth/password-reset",
icon: <Password />,
component: <PasswordReset />,
drawer: false,
dashboard: false,
enabled: true,
},
{
label: "Настройки",
path: "/settings",
icon: <SettingsIcon />,
component: <Settings />,
drawer: false,
dashboard: true,
enabled: true,
},
{
label: "Главная",
path: "/",
icon: <Home />,
component: <Main />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Пользователи",
path: "/user",
icon: <People />,
component: <Users />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Роли",
path: "/role",
icon: <Shield />,
component: <Roles />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Документы",
path: "/documents",
icon: <Storage />,
component: <Documents />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Отчеты",
path: "/reports",
icon: <Assignment />,
component: <Reports />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Серверы",
path: "/servers",
icon: <Cloud />,
component: <Servers />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "Котельные",
path: "/boilers",
icon: <Factory />,
component: <Boilers />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "API Test",
path: "/api-test",
icon: <Api />,
component: <ApiTest />,
drawer: true,
dashboard: true,
enabled: false,
},
{
label: "ИКС",
path: "/map-test",
icon: <Map />,
component: <MapTest />,
drawer: true,
dashboard: true,
enabled: false,
},
{
label: "Chunk test",
path: "/chunk-test",
icon: <Warning />,
component: <ChunkedUpload />,
drawer: true,
dashboard: true,
enabled: false,
},
{
label: "Монитор",
path: "/monitor",
icon: <MonitorHeart />,
component: <MonitorPage />,
drawer: true,
dashboard: true,
enabled: false,
},
]
function App() {
const auth = useAuthStore()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
initAuth()
}, [])
// Once auth is there, set loading to false and render the app
useEffect(() => {
if (auth) {
setIsLoading(false)
}
}, [auth])
if (isLoading) {
return (
<CircularProgress />
)
} else {
return (
<Box sx={{
width: "100%",
height: "100vh"
}}>
<Router>
<Routes>
<Route element={<MainLayout />}>
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`ml-${index}`} path={page.path} element={page.component} />
))}
</Route>
<Route element={auth.isAuthenticated ? <DashboardLayout /> : <Navigate to={"/auth/signin"} />}>
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`dl-${index}`} path={page.path} element={page.component} />
))}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</Box>
)
}
}
export default App

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,156 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Avatar from '@mui/material/Avatar';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Settings from '@mui/icons-material/Settings';
import Logout from '@mui/icons-material/Logout';
import { useNavigate } from 'react-router-dom';
import { logout } from '../store/auth';
import { ListItemText, Switch, styled } from '@mui/material';
import { setDarkMode, usePrefStore } from '../store/preferences';
const Android12Switch = styled(Switch)(({ theme }) => ({
padding: 8,
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
'&::before, &::after': {
content: '""',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: 16,
height: 16,
},
'&::before': {
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 24 24"><path fill="${encodeURIComponent(
theme.palette.getContrastText(theme.palette.primary.main),
)}" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/></svg>')`,
left: 12,
},
'&::after': {
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 24 24"><path fill="${encodeURIComponent(
theme.palette.getContrastText(theme.palette.primary.main),
)}" d="M19,13H5V11H19V13Z" /></svg>')`,
right: 12,
},
},
'& .MuiSwitch-thumb': {
boxShadow: 'none',
width: 16,
height: 16,
margin: 2,
},
}));
export default function AccountMenu() {
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const prefStore = usePrefStore()
return (
<React.Fragment>
<Box sx={{ display: 'flex', alignItems: 'center', textAlign: 'center' }}>
<Tooltip title="Account settings">
<IconButton
onClick={handleClick}
size="small"
sx={{ ml: 2 }}
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
>
<Avatar sx={{ width: 32, height: 32 }}></Avatar>
</IconButton>
</Tooltip>
</Box>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
slotProps={{
paper: {
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},
},
}
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={() => {
}}>
<ListItemIcon>
<Android12Switch
checked={prefStore.darkMode}
onChange={(e) => {
setDarkMode(e.target.checked)
}} />
</ListItemIcon>
<ListItemText>
Тема: {prefStore.darkMode ? "темная" : "светлая"}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => {
navigate('/settings')
}}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
Настройки
</MenuItem>
<MenuItem
onClick={() => {
logout()
navigate("/auth/signin")
}}
>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
Выход
</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@ -0,0 +1,23 @@
import { Divider, Paper, Typography } from '@mui/material'
import { PropsWithChildren } from 'react'
interface CardInfoProps extends PropsWithChildren {
label: string;
}
export default function CardInfo({
children,
label
}: CardInfoProps) {
return (
<Paper sx={{ display: 'flex', flexDirection: 'column', gap: '16px', p: '16px' }}>
<Typography fontWeight={600}>
{label}
</Typography>
<Divider />
{children}
</Paper>
)
}

View File

@ -0,0 +1,25 @@
import { Chip } from '@mui/material'
import { ReactElement } from 'react'
interface CardInfoChipProps {
status: boolean;
label: string;
iconOn: ReactElement
iconOff: ReactElement
}
export default function CardInfoChip({
status,
label,
iconOn,
iconOff
}: CardInfoChipProps) {
return (
<Chip
icon={status ? iconOn : iconOff}
variant="outlined"
label={label}
color={status ? "success" : "error"}
/>
)
}

View File

@ -0,0 +1,22 @@
import { Box, Typography } from '@mui/material'
interface CardInfoLabelProps {
label: string;
value: string | number;
}
export default function CardInfoLabel({
label,
value
}: CardInfoLabelProps) {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography>
{label}
</Typography>
<Typography variant="h6" fontWeight={600}>
{value}
</Typography>
</Box>
)
}

View File

@ -0,0 +1,21 @@
import { useState, useEffect, useMemo } from 'react'
import axiosInstance from '../http/axiosInstance'
export function useDataFetching<T>(url: string, initData: T): T {
const [data, setData] = useState<T>(initData)
useEffect(() => {
const fetchData = async () => {
const response = await axiosInstance.get(url)
const result = await response.data
setData(result)
}
fetchData()
}, [url])
// Memoize the data value
const memoizedData = useMemo<T>(() => data, [data])
return memoizedData
}
export default useDataFetching;

View File

@ -0,0 +1,344 @@
import { useDocuments, useDownload, useFolders } from '../hooks/swrHooks'
import { IDocument, IDocumentFolder } from '../interfaces/documents'
import { Box, Breadcrumbs, Button, CircularProgress, Divider, IconButton, Link, List, ListItemButton, SxProps } from '@mui/material'
import { Cancel, Close, Download, Folder, InsertDriveFile, Upload, UploadFile } from '@mui/icons-material'
import React, { useEffect, useRef, useState } from 'react'
import DocumentService from '../services/DocumentService'
import { mutate } from 'swr'
import FileViewer from './modals/FileViewer'
interface FolderProps {
folder: IDocumentFolder;
index: number;
handleFolderClick: (folder: IDocumentFolder) => void;
}
interface DocumentProps {
doc: IDocument;
index: number;
handleDocumentClick: (index: number) => void;
}
const FileItemStyle: SxProps = {
cursor: 'pointer',
display: 'flex',
width: '100%',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
padding: '8px'
}
function ItemFolder({ folder, handleFolderClick, ...props }: FolderProps) {
return (
<ListItemButton
onClick={() => handleFolderClick(folder)}
>
<Box
sx={FileItemStyle}
{...props}
>
<Folder />
{folder.name}
</Box>
</ListItemButton>
)
}
const handleSave = async (file: Blob, filename: string) => {
const link = document.createElement('a')
link.href = window.URL.createObjectURL(file)
link.download = filename
link.click()
link.remove()
window.URL.revokeObjectURL(link.href)
}
function ItemDocument({ doc, index, handleDocumentClick, ...props }: DocumentProps) {
const [shouldFetch, setShouldFetch] = useState(false)
const { file, isLoading } = useDownload(shouldFetch ? doc?.document_folder_id : null, shouldFetch ? doc?.id : null)
useEffect(() => {
if (shouldFetch) {
if (file) {
handleSave(file, doc.name)
setShouldFetch(false)
}
}
}, [shouldFetch, file])
return (
<ListItemButton>
<Box
sx={FileItemStyle}
onClick={() => handleDocumentClick(index)}
{...props}
>
<InsertDriveFile />
{doc.name}
</Box>
<Box>
<IconButton
onClick={() => {
if (!isLoading) {
setShouldFetch(true)
}
}}
sx={{ ml: 'auto' }}
>
{isLoading ?
<CircularProgress size={24} variant='indeterminate' />
:
<Download />
}
</IconButton>
</Box>
</ListItemButton>
)
}
export default function FolderViewer() {
const [currentFolder, setCurrentFolder] = useState<IDocumentFolder | null>(null)
const [breadcrumbs, setBreadcrumbs] = useState<IDocumentFolder[]>([])
const { folders, isLoading: foldersLoading } = useFolders()
const { documents, isLoading: documentsLoading } = useDocuments(currentFolder?.id)
const [uploadProgress, setUploadProgress] = useState(0)
const [isUploading, setIsUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileViewerModal, setFileViewerModal] = useState(false)
const [currentFileNo, setCurrentFileNo] = useState<number>(-1)
const [dragOver, setDragOver] = useState(false)
const [filesToUpload, setFilesToUpload] = useState<File[]>([])
const handleFolderClick = (folder: IDocumentFolder) => {
setCurrentFolder(folder)
setBreadcrumbs((prev) => [...prev, folder])
}
const handleDocumentClick = async (index: number) => {
setCurrentFileNo(index)
setFileViewerModal(true)
}
const handleBreadcrumbClick = (index: number) => {
const newBreadcrumbs = breadcrumbs.slice(0, index + 1);
setBreadcrumbs(newBreadcrumbs)
setCurrentFolder(newBreadcrumbs[newBreadcrumbs.length - 1])
}
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(true)
}
const handleDragLeave = () => {
setDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const files = Array.from(e.dataTransfer.files)
setFilesToUpload((prevFiles) => [...prevFiles, ...files])
}
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
setFilesToUpload((prevFiles) => [...prevFiles, ...files])
}
const uploadFiles = async () => {
setIsUploading(true)
if (filesToUpload.length > 0 && currentFolder && currentFolder.id) {
const formData = new FormData()
for (const file of filesToUpload) {
formData.append('files', file)
}
try {
await DocumentService.uploadFiles(currentFolder.id, formData, setUploadProgress);
setIsUploading(false);
setFilesToUpload([]);
mutate(`/info/documents/${currentFolder.id}`);
} catch (error) {
console.error(error);
setIsUploading(false);
}
}
}
if (foldersLoading || documentsLoading) {
return (
<CircularProgress />
)
}
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
p: '16px'
}}>
<FileViewer
open={fileViewerModal}
setOpen={setFileViewerModal}
currentFileNo={currentFileNo}
setCurrentFileNo={setCurrentFileNo}
docs={documents}
/>
<Breadcrumbs>
<Link
underline='hover'
color='inherit'
onClick={() => {
setCurrentFolder(null)
setBreadcrumbs([])
}}
sx={{ cursor: 'pointer' }}
>
Главная
</Link>
{breadcrumbs.map((breadcrumb, index) => (
<Link
key={breadcrumb.id}
underline="hover"
color="inherit"
onClick={() => handleBreadcrumbClick(index)}
sx={{ cursor: 'pointer' }}
>
{breadcrumb.name}
</Link>
))}
</Breadcrumbs>
{currentFolder &&
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
border: filesToUpload.length > 0 ? '1px dashed gray' : 'none',
borderRadius: '8px',
p: '16px'
}}>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Button
LinkComponent="label"
role={undefined}
variant="outlined"
tabIndex={-1}
startIcon={
isUploading ? <CircularProgress sx={{ maxHeight: "20px", maxWidth: "20px" }} variant="determinate" value={uploadProgress} /> : <UploadFile />
}
onClick={handleUploadClick}
>
<input
type='file'
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInput}
onClick={(e) => {
if (e.currentTarget) {
e.currentTarget.value = ''
}
}}
/>
Добавить
</Button>
{filesToUpload.length > 0 &&
<>
<Button
variant="contained"
color="primary"
startIcon={<Upload />}
onClick={uploadFiles}
>
Загрузить все
</Button>
<Button
variant='outlined'
startIcon={<Cancel />}
onClick={() => {
setFilesToUpload([])
}}
>
Отмена
</Button>
</>
}
</Box>
<Divider />
{filesToUpload.length > 0 &&
<Box>
{filesToUpload.map((file, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
<Box>
<InsertDriveFile />
<span>{file.name}</span>
</Box>
<IconButton sx={{ ml: 'auto' }} onClick={() => {
setFilesToUpload(prev => {
return prev.filter((_, i) => i != index)
})
}}>
<Close />
</IconButton>
</Box>
))}
</Box>
}
</Box>
</Box>
}
<List
dense
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
backgroundColor: dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'
}}
>
{currentFolder ? (
documents?.map((doc: IDocument, index: number) => (
<div key={`${doc.id}-${doc.name}`}>
<ItemDocument
doc={doc}
index={index}
handleDocumentClick={handleDocumentClick}
/>
{index < documents.length - 1 && <Divider />}
</div>
))
) : (
folders?.map((folder: IDocumentFolder, index: number) => (
<div key={`${folder.id}-${folder.name}`}>
<ItemFolder
folder={folder}
index={index}
handleFolderClick={handleFolderClick}
/>
{index < folders.length - 1 && <Divider />}
</div>
))
)}
</List>
</Box>
)
}

View File

@ -0,0 +1,100 @@
import { SubmitHandler, useForm } from 'react-hook-form'
import { CreateField } from '../interfaces/create'
import { Box, Button, CircularProgress, Stack, SxProps, TextField, Typography } from '@mui/material';
import { AxiosResponse } from 'axios';
interface Props {
title?: string;
submitHandler?: (data: any) => Promise<AxiosResponse<any, any>>;
fields: CreateField[];
submitButtonText?: string;
mutateHandler?: any;
defaultValues?: {};
watchValues?: string[];
sx?: SxProps | null;
}
function FormFields({
title = '',
submitHandler,
fields,
submitButtonText = 'Сохранить',
mutateHandler,
defaultValues,
sx
}: Props) {
const getDefaultValues = (fields: CreateField[]) => {
let result: { [key: string]: string | boolean } = {}
fields.forEach((field: CreateField) => {
result[field.key] = field.defaultValue || defaultValues?.[field.key as keyof {}]
})
return result
}
const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting, dirtyFields, isValid } } = useForm({
mode: 'onChange',
defaultValues: defaultValues ? getDefaultValues(fields) : {}
})
const onSubmit: SubmitHandler<any> = async (data) => {
fields.forEach((field: CreateField) => {
if (field.include === false) {
delete data[field.key]
}
})
try {
const submitResponse = await submitHandler?.(data)
mutateHandler?.(JSON.stringify(submitResponse?.data))
reset(submitResponse?.data)
} catch (error) {
console.error(error)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack sx={sx} spacing={2} width='100%'>
<Typography variant="h6" component="h6" gutterBottom>
{title}
</Typography>
{fields.map((field: CreateField) => {
return (
<TextField
fullWidth
margin='normal'
key={field.key}
type={field.inputType ? field.inputType : 'text'}
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
required={field.required || false}
{...register(field.key, {
required: field.required ? `${field.headerName} обязателен` : false,
validate: (val: string | boolean) => {
if (field.watch) {
if (watch(field.watch) != val) {
return field.watchMessage || ''
}
}
},
})}
error={!!errors[field.key]}
helperText={errors[field.key]?.message}
/>
)
})}
<Box sx={{
display: "flex",
justifyContent: "space-between",
gap: "8px"
}}>
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type="submit" variant="contained" color="primary">
{isSubmitting ? <CircularProgress size={16} /> : submitButtonText}
</Button>
</Box>
</Stack>
</form>
)
}
export default FormFields

View File

@ -0,0 +1,39 @@
import { Box } from '@mui/material'
import { IServer } from '../interfaces/servers'
import { useServerIps } from '../hooks/swrHooks'
import FullFeaturedCrudGrid from './TableEditable'
import { GridColDef } from '@mui/x-data-grid'
function ServerData({ id }: IServer) {
const { serverIps } = useServerIps(id, 0, 10)
const serverIpsColumns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
{ field: 'ip', headerName: 'IP', type: 'string' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', p: '16px' }}>
{serverIps &&
<FullFeaturedCrudGrid
initialRows={serverIps}
columns={serverIpsColumns}
actions
onRowClick={() => {
//setCurrentServerData(params.row)
//setServerDataOpen(true)
}}
onSave={undefined}
onDelete={undefined}
loading={false}
/>
}
</Box>
)
}
export default ServerData

View File

@ -0,0 +1,125 @@
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
import { Fragment, useState } from 'react'
import { IRegion } from '../interfaces/fuel'
import { useHardwares, useServers } from '../hooks/swrHooks'
import FullFeaturedCrudGrid from './TableEditable'
import ServerService from '../services/ServersService'
import { GridColDef } from '@mui/x-data-grid'
import { Close } from '@mui/icons-material'
import ServerData from './ServerData'
export default function ServerHardware() {
const [open, setOpen] = useState(false)
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
const { servers, isLoading } = useServers()
const [serverDataOpen, setServerDataOpen] = useState(false)
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
const handleInputChange = (value: string) => {
return value
}
const handleOptionChange = (value: IRegion | null) => {
setSelectedOption(value)
}
const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption?.id, 0, 10)
const hardwareColumns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
{ field: 'os_info', headerName: 'ОС', type: 'string' },
{ field: 'ram', headerName: 'ОЗУ', type: 'string' },
{ field: 'processor', headerName: 'Проц.', type: 'string' },
{ field: 'storages_count', headerName: 'Кол-во хранилищ', type: 'number' },
]
return (
<>
<Dialog
fullScreen
open={serverDataOpen}
onClose={() => {
setServerDataOpen(false)
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description">
<AppBar sx={{ position: 'sticky' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
setServerDataOpen(false)
}}
aria-label="close"
>
<Close />
</IconButton>
</Toolbar>
</AppBar>
{currentServerData &&
<ServerData
id={currentServerData?.id}
region_id={currentServerData?.region_id}
name={currentServerData?.name}
/>
}
</Dialog>
{serversLoading ?
<CircularProgress />
:
<FullFeaturedCrudGrid
autoComplete={
<Autocomplete
open={open}
onOpen={() => {
setOpen(true)
}}
onClose={() => {
setOpen(false)
}}
onInputChange={(_, value) => handleInputChange(value)}
onChange={(_, value) => handleOptionChange(value)}
filterOptions={(x) => x}
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
options={servers || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
label="Сервер"
size='small'
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}} />
)} />}
onSave={() => {
}}
onDelete={ServerService.removeServer}
initialRows={hardwares || []}
columns={hardwareColumns}
actions
onRowClick={(params) => {
setCurrentServerData(params.row)
setServerDataOpen(true)
}}
loading={false}
/>
}
</>
)
}

View File

@ -0,0 +1,121 @@
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
import { Fragment, useState } from 'react'
import { IRegion } from '../interfaces/fuel'
import { useServerIps, useServers } from '../hooks/swrHooks'
import FullFeaturedCrudGrid from './TableEditable'
import ServerService from '../services/ServersService'
import { GridColDef } from '@mui/x-data-grid'
import { Close } from '@mui/icons-material'
import ServerData from './ServerData'
export default function ServerIpsView() {
const [open, setOpen] = useState(false)
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
const { servers, isLoading } = useServers()
const [serverDataOpen, setServerDataOpen] = useState(false)
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
const handleInputChange = (value: string) => {
return value
}
const handleOptionChange = (value: IRegion | null) => {
setSelectedOption(value)
}
const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption?.id, 0, 10)
const serverIpsColumns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
{ field: 'ip', headerName: 'IP', type: 'string' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
]
return (
<>
<Dialog
fullScreen
open={serverDataOpen}
onClose={() => {
setServerDataOpen(false)
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description">
<AppBar sx={{ position: 'sticky' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
setServerDataOpen(false)
}}
aria-label="close"
>
<Close />
</IconButton>
</Toolbar>
</AppBar>
{currentServerData &&
<ServerData
id={currentServerData?.id}
region_id={currentServerData?.region_id}
name={currentServerData?.name}
/>
}
</Dialog>
{serversLoading ?
<CircularProgress />
:
<FullFeaturedCrudGrid
autoComplete={
<Autocomplete
open={open}
onOpen={() => {
setOpen(true)
}}
onClose={() => {
setOpen(false)
}}
onInputChange={(_, value) => handleInputChange(value)}
onChange={(_, value) => handleOptionChange(value)}
filterOptions={(x) => x}
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
options={servers || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
size='small'
label="Сервер"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}} />
)} />}
onSave={() => {
}}
onDelete={ServerService.removeServer}
initialRows={serverIps || []}
columns={serverIpsColumns}
actions
onRowClick={(params) => {
setCurrentServerData(params.row)
setServerDataOpen(true)
}} loading={false} />
}
</>
)
}

View File

@ -0,0 +1,122 @@
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
import { Fragment, useState } from 'react'
import { IRegion } from '../interfaces/fuel'
import { useHardwares, useStorages } from '../hooks/swrHooks'
import FullFeaturedCrudGrid from './TableEditable'
import ServerService from '../services/ServersService'
import { GridColDef } from '@mui/x-data-grid'
import { Close } from '@mui/icons-material'
import ServerData from './ServerData'
export default function ServerStorage() {
const [open, setOpen] = useState(false)
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
const { hardwares, isLoading } = useHardwares()
const [serverDataOpen, setServerDataOpen] = useState(false)
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
const handleInputChange = (value: string) => {
return value
}
const handleOptionChange = (value: IRegion | null) => {
setSelectedOption(value)
}
const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10)
const storageColumns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'hardware_id', headerName: 'Hardware ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'size', headerName: 'Размер', type: 'string' },
{ field: 'storage_type', headerName: 'Тип хранилища', type: 'string' },
]
return (
<>
<Dialog
fullScreen
open={serverDataOpen}
onClose={() => {
setServerDataOpen(false)
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description">
<AppBar sx={{ position: 'sticky' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
setServerDataOpen(false)
}}
aria-label="close"
>
<Close />
</IconButton>
</Toolbar>
</AppBar>
{currentServerData &&
<ServerData
id={currentServerData?.id}
region_id={currentServerData?.region_id}
name={currentServerData?.name}
/>
}
</Dialog>
{serversLoading ?
<CircularProgress />
:
<FullFeaturedCrudGrid
autoComplete={
<Autocomplete
open={open}
onOpen={() => {
setOpen(true)
}}
onClose={() => {
setOpen(false)
}}
onInputChange={(_, value) => handleInputChange(value)}
onChange={(_, value) => handleOptionChange(value)}
filterOptions={(x) => x}
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
options={hardwares || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
size='small'
label="Hardware"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}} />
)} />}
onSave={() => {
}}
onDelete={ServerService.removeServer}
initialRows={storages || []}
columns={storageColumns}
actions
onRowClick={(params) => {
setCurrentServerData(params.row)
setServerDataOpen(true)
}}
loading={false}
/>
}
</>
)
}

View File

@ -0,0 +1,177 @@
import { AppBar, Autocomplete, Box, CircularProgress, Dialog, Grid, IconButton, TextField, Toolbar } from '@mui/material'
import { Fragment, useState } from 'react'
import { IRegion } from '../interfaces/fuel'
import { useRegions, useServers, useServersInfo } from '../hooks/swrHooks'
import FullFeaturedCrudGrid from './TableEditable'
import ServerService from '../services/ServersService'
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
import { Close, Cloud, CloudOff } from '@mui/icons-material'
import ServerData from './ServerData'
import { IServersInfo } from '../interfaces/servers'
import CardInfo from './CardInfo/CardInfo'
import CardInfoLabel from './CardInfo/CardInfoLabel'
import CardInfoChip from './CardInfo/CardInfoChip'
import { useDebounce } from '@uidotdev/usehooks'
export default function ServersView() {
const [search, setSearch] = useState<string | null>("")
const debouncedSearch = useDebounce(search, 500)
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
const { regions, isLoading } = useRegions(10, 1, debouncedSearch)
const { serversInfo } = useServersInfo(selectedOption?.id)
const [serverDataOpen, setServerDataOpen] = useState(false)
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
const { servers, isLoading: serversLoading } = useServers(selectedOption?.id, 0, 10)
const serversColumns: GridColDef[] = [
//{ field: 'id', headerName: 'ID', type: "number" },
{
field: 'name', headerName: 'Название', type: "string", editable: true,
},
{
field: 'region_id',
editable: true,
renderCell: (params) => (
<div>
{params.value}
</div>
),
renderEditCell: (params: GridRenderCellParams) => (
<Autocomplete
sx={{ display: 'flex', flexGrow: '1' }}
onInputChange={(_, value) => setSearch(value)}
onChange={(_, value) => {
params.value = value
}}
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
options={regions || []}
loading={isLoading}
value={params.value}
renderInput={(params) => (
<TextField
{...params}
size='small'
variant='standard'
label="Район"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
/>
)}
/>
),
flex: 1
}
]
return (
<>
<Dialog
fullScreen
open={serverDataOpen}
onClose={() => {
setServerDataOpen(false)
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description">
<AppBar sx={{ position: 'sticky' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
setServerDataOpen(false)
}}
aria-label="close"
>
<Close />
</IconButton>
</Toolbar>
</AppBar>
{currentServerData &&
<ServerData
id={currentServerData?.id}
region_id={currentServerData?.region_id}
name={currentServerData?.name}
/>
}
</Dialog>
{serversInfo &&
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }}>
<Grid container spacing={{ xs: 2, md: 3 }} columns={{ xs: 1, sm: 1, md: 2, lg: 3, xl: 4 }}>
{serversInfo.map((serverInfo: IServersInfo) => (
<Grid key={`si-${serverInfo.id}`} item xs={1} sm={1} md={1}>
<CardInfo label={serverInfo.name}>
<CardInfoLabel label='Количество IP' value={serverInfo.IPs_count} />
<CardInfoLabel label='Количество серверов' value={serverInfo.servers_count} />
<CardInfoChip
status={serverInfo.status === "Online"}
label={serverInfo.status}
iconOn={<Cloud />}
iconOff={<CloudOff />}
/>
</CardInfo>
</Grid>
))}
</Grid>
</Box>
}
<FullFeaturedCrudGrid
loading={serversLoading}
autoComplete={
<Autocomplete
onInputChange={(_, value) => setSearch(value)}
onChange={(_, value) => setSelectedOption(value)}
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.id === value.id}
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
options={regions || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
size='small'
label="Район"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
/>
)}
/>
}
onSave={() => {
}}
onDelete={ServerService.removeServer}
initialRows={servers}
columns={serversColumns}
actions
onRowClick={(params) => {
setCurrentServerData(params.row)
setServerDataOpen(true)
}}
/>
</>
)
}

View File

@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Close';
import {
GridRowsProp,
GridRowModesModel,
GridRowModes,
DataGrid,
GridColDef,
GridToolbarContainer,
GridActionsCellItem,
GridEventListener,
GridRowId,
GridRowModel,
GridRowEditStopReasons,
GridSlots,
} from '@mui/x-data-grid';
interface EditToolbarProps {
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void
columns: GridColDef[]
autoComplete?: React.ReactElement | null
}
function EditToolbar(props: EditToolbarProps) {
const { setRows, setRowModesModel, columns, autoComplete } = props
const handleClick = () => {
const id = Date.now().toString(36)
const newValues: any = {}
columns.forEach(column => {
if (column.type === 'number') {
newValues[column.field] = 0
} else if (column.type === 'string') {
newValues[column.field] = ''
} else if (column.type === 'boolean') {
newValues[column.field] = false
} else {
newValues[column.field] = undefined
}
if (column.field === 'region_id') {
// column.valueGetter = (value: any) => {
// console.log(value)
// }
}
})
setRows((oldRows) => [...oldRows, { id, ...newValues, isNew: true }]);
setRowModesModel((oldModel) => ({
...oldModel,
[id]: { mode: GridRowModes.Edit, fieldToFocus: columns[0].field },
}))
};
return (
<GridToolbarContainer sx={{ px: '16px', py: '16px' }}>
{autoComplete &&
<Box sx={{ flexGrow: '1' }}>
{autoComplete}
</Box>
}
<Button color="primary" startIcon={<AddIcon />} onClick={handleClick}>
Добавить
</Button>
</GridToolbarContainer>
);
}
interface DataGridProps {
initialRows: GridRowsProp;
columns: GridColDef[];
actions: boolean;
onRowClick: GridEventListener<"rowClick">;
onSave: any;
onDelete: any;
autoComplete?: React.ReactElement | null;
loading: boolean;
}
export default function FullFeaturedCrudGrid({
initialRows,
columns,
actions = false,
//onRowClick,
onSave,
onDelete,
autoComplete,
loading
}: DataGridProps) {
const [rows, setRows] = useState(initialRows);
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => {
if (params.reason === GridRowEditStopReasons.rowFocusOut) {
event.defaultMuiPrevented = true;
}
};
const handleEditClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
};
const handleSaveClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
onSave?.(id)
};
const handleDeleteClick = (id: GridRowId) => () => {
setRows(rows.filter((row) => row.id !== id));
onDelete?.(id)
};
const handleCancelClick = (id: GridRowId) => () => {
setRowModesModel({
...rowModesModel,
[id]: { mode: GridRowModes.View, ignoreModifications: true },
});
const editedRow = rows.find((row) => row.id === id);
if (editedRow!.isNew) {
setRows(rows.filter((row) => row.id !== id));
}
};
const processRowUpdate = (newRow: GridRowModel) => {
const updatedRow = { ...newRow, isNew: false };
setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
return updatedRow;
};
const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
setRowModesModel(newRowModesModel);
};
useEffect(() => {
if (initialRows) {
setRows(initialRows)
}
}, [initialRows])
const actionColumns: GridColDef[] = [
{
field: 'actions',
type: 'actions',
headerName: 'Действия',
width: 100,
cellClassName: 'actions',
getActions: ({ id }) => {
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
if (isInEditMode) {
return [
<GridActionsCellItem
icon={<SaveIcon />}
label="Save"
sx={{
color: 'primary.main',
}}
onClick={handleSaveClick(id)}
/>,
<GridActionsCellItem
icon={<CancelIcon />}
label="Cancel"
className="textPrimary"
onClick={handleCancelClick(id)}
color="inherit"
/>,
];
}
return [
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
className="textPrimary"
onClick={handleEditClick(id)}
color="inherit"
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={handleDeleteClick(id)}
color="inherit"
/>,
];
},
}
]
return (
<Box
sx={{
height: 500,
width: '100%',
'& .actions': {
color: 'text.secondary',
},
'& .textPrimary': {
color: 'text.primary',
},
}}
>
<DataGrid
loading={loading}
rows={rows || []}
columns={actions ? [...columns, ...actionColumns] : columns}
editMode="row"
rowModesModel={rowModesModel}
//onRowClick={onRowClick}
onRowModesModelChange={handleRowModesModelChange}
onRowEditStop={handleRowEditStop}
processRowUpdate={processRowUpdate}
slots={{
toolbar: EditToolbar as GridSlots['toolbar'],
}}
slotProps={{
toolbar: { setRows, setRowModesModel, columns, autoComplete },
}}
/>
</Box>
);
}

View File

@ -0,0 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import UserService from "../services/UserService";
export default function useUserData<T>(token: string, initData: T): T {
const [userData, setUserData] = useState<T>(initData)
useEffect(()=> {
const fetchUserData = async (token: string) => {
const response = await UserService.getCurrentUser(token)
setUserData(response.data)
}
fetchUserData(token)
}, [token])
const memoizedData = useMemo<T>(() => userData, [userData])
return memoizedData
}

View File

@ -0,0 +1,58 @@
import React, { useState } from 'react';
import axios from 'axios';
const ChunkedUpload = () => {
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number>(0);
// Handle file selection
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setFile(event.target.files[0]);
}
};
// Upload the file in chunks
const uploadFile = async () => {
if (!file) return;
const chunkSize = 1024 * 1024; // 1MB per chunk
const totalChunks = Math.ceil(file.size / chunkSize);
const fileId = `${file.name}-${Date.now()}`; // Unique file identifier
let uploadedChunks = 0;
for (let start = 0; start < file.size; start += chunkSize) {
const chunk = file.slice(start, start + chunkSize);
const chunkNumber = Math.ceil(start / chunkSize) + 1;
try {
await axios.post(`${import.meta.env.VITE_API_EMS_URL}/upload`, chunk, {
headers: {
'Content-Type': 'application/octet-stream',
'X-Chunk-Number': chunkNumber.toString(),
'X-Total-Chunks': totalChunks.toString(),
'X-File-Id': fileId,
},
});
uploadedChunks++;
setUploadProgress((uploadedChunks / totalChunks) * 100);
} catch (error) {
console.error('Chunk upload failed', error);
// Implement retry logic if needed
break;
}
}
};
return (
<div>
<input type="file" onChange={handleFileChange} />
<button onClick={uploadFile} disabled={!file}>
Upload File
</button>
<div>Upload Progress: {uploadProgress.toFixed(2)}%</div>
</div>
);
};
export default ChunkedUpload;

View File

@ -0,0 +1,984 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import GeoJSON from 'ol/format/GeoJSON'
import 'ol/ol.css'
import Map from 'ol/Map'
import View from 'ol/View'
import { Draw, Modify, Select, Snap, Translate } from 'ol/interaction'
import { ImageStatic, OSM, Vector as VectorSource, XYZ } from 'ol/source'
import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'
import { Divider, IconButton, Slider, Stack, Select as MUISelect, MenuItem, Box, Typography, Accordion, AccordionSummary, AccordionDetails, SxProps, Theme } from '@mui/material'
import { Add, Adjust, Api, CircleOutlined, ExpandMore, OpenWith, RectangleOutlined, Straighten, Timeline, Undo, Upload, Warning } from '@mui/icons-material'
import { Type } from 'ol/geom/Geometry'
import { click, never, noModifierKeys, platformModifierKeyOnly, primaryAction, shiftKeyOnly } from 'ol/events/condition'
import Feature from 'ol/Feature'
import { SatelliteMapsProvider } from '../../interfaces/map'
import { containsExtent, Extent, getCenter, getHeight, getWidth } from 'ol/extent'
import { drawingLayerStyle, regionsLayerStyle, selectStyle } from './MapStyles'
import { googleMapsSatelliteSource, regionsLayerSource, yandexMapsSatelliteSource } from './MapSources'
import { mapCenter } from './MapConstants'
import ImageLayer from 'ol/layer/Image'
import VectorImageLayer from 'ol/layer/VectorImage'
import { LineString, MultiPoint, Point, Polygon, SimpleGeometry } from 'ol/geom'
import { fromExtent } from 'ol/geom/Polygon'
import Collection from 'ol/Collection'
import { Coordinate } from 'ol/coordinate'
import { Stroke, Fill, Circle as CircleStyle, Style } from 'ol/style'
import { calculateExtent, calculateRotationAngle, rotateProjection } from './mapUtils'
import MapBrowserEvent from 'ol/MapBrowserEvent'
import { fromLonLat, get } from 'ol/proj'
import { useCities } from '../../hooks/swrHooks'
import useSWR from 'swr'
import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants'
const MapComponent = () => {
const { cities } = useCities(100, 1)
useEffect(() => {
if (cities) {
cities.map((city: any) => {
citiesLayer.current?.getSource()?.addFeature(new Feature(new Point(fromLonLat([city.longitude, city.width]))))
})
}
}, [cities])
const [currentCoordinate, setCurrentCoordinate] = useState<Coordinate | null>(null)
const [currentZ, setCurrentZ] = useState<number | undefined>(undefined)
const [currentX, setCurrentX] = useState<number | undefined>(undefined)
const [currentY, setCurrentY] = useState<number | undefined>(undefined)
const [file, setFile] = useState(null)
const [polygonExtent, setPolygonExtent] = useState<Extent | undefined>(undefined)
const [bottomLeft, setBottomLeft] = useState<Coordinate | undefined>(undefined)
const [topLeft, setTopLeft] = useState<Coordinate | undefined>(undefined)
const [topRight, setTopRight] = useState<Coordinate | undefined>(undefined)
const [bottomRight, setBottomRight] = useState<Coordinate | undefined>(undefined)
const mapElement = useRef<HTMLDivElement | null>(null)
const [currentTool, setCurrentTool] = useState<Type | null>(null)
const map = useRef<Map | null>(null)
const [satMapsProvider, setSatMapsProvider] = useState<SatelliteMapsProvider>('custom')
const gMapsSatSource = useRef<XYZ>(googleMapsSatelliteSource)
const customMapSource = useRef<XYZ>(new XYZ({
url: `${import.meta.env.VITE_API_EMS_URL}/tile/custom/{z}/{x}/{y}`,
attributions: 'Custom map data'
}))
const yMapsSatSource = useRef<XYZ>(yandexMapsSatelliteSource)
const satLayer = useRef<TileLayer>(new TileLayer({
source: gMapsSatSource.current,
}))
const draw = useRef<Draw | null>(null)
const snap = useRef<Snap | null>(null)
const selectFeature = useRef<Select>(new Select({
condition: function (mapBrowserEvent) {
return click(mapBrowserEvent) && shiftKeyOnly(mapBrowserEvent);
},
}))
const nodeLayer = useRef<VectorLayer | null>(null)
const nodeLayerSource = useRef<VectorSource>(new VectorSource())
const overlayLayer = useRef<VectorLayer | null>(null)
const overlayLayerSource = useRef<VectorSource>(new VectorSource())
const drawingLayer = useRef<VectorLayer | null>(null)
const drawingLayerSource = useRef<VectorSource>(new VectorSource())
const citiesLayer = useRef<VectorLayer>(new VectorLayer({
source: new VectorSource()
}))
const regionsLayer = useRef<VectorImageLayer>(new VectorImageLayer({
source: regionsLayerSource,
style: regionsLayerStyle
}))
const selectedRegion = useRef<Feature | null>(null)
const baseLayer = useRef<TileLayer>(new TileLayer({
source: new OSM(),
}))
const imageLayer = useRef<ImageLayer<ImageStatic>>(new ImageLayer())
const addInteractions = () => {
if (currentTool) {
draw.current = new Draw({
source: drawingLayerSource.current,
type: currentTool,
condition: noModifierKeys
})
draw.current.on('drawend', function (s) {
console.log(s.feature.getGeometry()?.getType())
let type = 'POLYGON'
switch (s.feature.getGeometry()?.getType()) {
case 'LineString':
type = 'LINE'
break
case 'Polygon':
type = 'POLYGON'
break
default:
type = 'POLYGON'
break
}
const coordinates = (s.feature.getGeometry() as SimpleGeometry).getCoordinates()
uploadCoordinates(coordinates, type)
})
map?.current?.addInteraction(draw.current)
snap.current = new Snap({ source: drawingLayerSource.current })
map?.current?.addInteraction(snap.current)
}
}
// Function to save features to localStorage
const saveFeatures = () => {
const features = drawingLayer.current?.getSource()?.getFeatures()
if (features && features.length > 0) {
const geoJSON = new GeoJSON()
const featuresJSON = geoJSON.writeFeatures(features)
localStorage.setItem('savedFeatures', featuresJSON)
}
console.log(drawingLayer.current?.getSource()?.getFeatures())
}
// Function to load features from localStorage
const loadFeatures = () => {
const savedFeatures = localStorage.getItem('savedFeatures')
if (savedFeatures) {
const geoJSON = new GeoJSON()
const features = geoJSON.readFeatures(savedFeatures, {
featureProjection: 'EPSG:4326', // Ensure the projection is correct
})
drawingLayerSource.current?.addFeatures(features) // Add features to the vector source
//drawingLayer.current?.getSource()?.changed()
}
}
const handleToolSelect = (tool: Type) => {
if (currentTool == tool) {
setCurrentTool(null)
} else {
setCurrentTool(tool)
}
}
const zoomToFeature = (feature: Feature) => {
const geometry = feature.getGeometry()
const extent = geometry?.getExtent()
if (map.current && extent) {
map.current.getView().fit(extent, {
duration: 300,
maxZoom: 19,
})
}
}
const style = new Style({
geometry: function (feature) {
const modifyGeometry = feature.get('modifyGeometry');
return modifyGeometry ? modifyGeometry.geometry : feature.getGeometry();
},
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)',
}),
stroke: new Stroke({
color: '#ffcc33',
width: 2,
}),
image: new CircleStyle({
radius: 7,
fill: new Fill({
color: '#ffcc33',
}),
}),
});
function calculateCenter(geometry: SimpleGeometry) {
let center, coordinates, minRadius;
const type = geometry.getType();
if (type === 'Polygon') {
let x = 0;
let y = 0;
let i = 0;
coordinates = (geometry as Polygon).getCoordinates()[0].slice(1);
coordinates.forEach(function (coordinate) {
x += coordinate[0];
y += coordinate[1];
i++;
});
center = [x / i, y / i];
} else if (type === 'LineString') {
center = (geometry as LineString).getCoordinateAt(0.5);
coordinates = geometry.getCoordinates();
} else {
center = getCenter(geometry.getExtent());
}
let sqDistances;
if (coordinates) {
sqDistances = coordinates.map(function (coordinate: Coordinate) {
const dx = coordinate[0] - center[0];
const dy = coordinate[1] - center[1];
return dx * dx + dy * dy;
});
minRadius = Math.sqrt(Math.max.apply(Math, sqDistances)) / 3;
} else {
minRadius =
Math.max(
getWidth(geometry.getExtent()),
getHeight(geometry.getExtent()),
) / 3;
}
return {
center: center,
coordinates: coordinates,
minRadius: minRadius,
sqDistances: sqDistances,
};
}
const handleImageDrop = useCallback((event: any) => {
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
setFile(file)
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => {
const imageUrl = reader.result as string;
const img = new Image();
img.src = imageUrl;
img.onload = () => {
if (map.current) {
const view = map.current.getView();
const center = view.getCenter() || [0, 0];
const width = img.naturalWidth;
const height = img.naturalHeight;
const resolution = view.getResolution() || 0;
const extent = [
center[0] - (width * resolution) / 20,
center[1] - (height * resolution) / 20,
center[0] + (width * resolution) / 20,
center[1] + (height * resolution) / 20,
];
// Create a polygon feature with the same extent as the image
const polygonFeature = new Feature({
geometry: fromExtent(extent),
});
// Add the polygon feature to the drawing layer source
overlayLayerSource.current?.addFeature(polygonFeature);
// Set up the initial image layer with the extent
const imageSource = new ImageStatic({
url: imageUrl,
imageExtent: extent,
});
imageLayer.current.setSource(imageSource);
//map.current.addLayer(imageLayer.current);
// Add interactions for translation and scaling
const translate = new Translate({
layers: [imageLayer.current],
features: new Collection([polygonFeature]),
});
const defaultStyle = new Modify({ source: overlayLayerSource.current })
.getOverlay()
.getStyleFunction();
const modify = new Modify({
insertVertexCondition: never,
source: overlayLayerSource.current,
condition: function (event) {
return primaryAction(event) && !platformModifierKeyOnly(event);
},
deleteCondition: never,
features: new Collection([polygonFeature]),
style: function (feature) {
feature.get('features').forEach(function (modifyFeature: Feature) {
const modifyGeometry = modifyFeature.get('modifyGeometry')
if (modifyGeometry) {
const point = (feature.getGeometry() as Point).getCoordinates()
let modifyPoint = modifyGeometry.point
if (!modifyPoint) {
// save the initial geometry and vertex position
modifyPoint = point;
modifyGeometry.point = modifyPoint;
modifyGeometry.geometry0 = modifyGeometry.geometry;
// get anchor and minimum radius of vertices to be used
const result = calculateCenter(modifyGeometry.geometry0);
modifyGeometry.center = result.center;
modifyGeometry.minRadius = result.minRadius;
}
const center = modifyGeometry.center;
const minRadius = modifyGeometry.minRadius;
let dx, dy;
dx = modifyPoint[0] - center[0];
dy = modifyPoint[1] - center[1];
const initialRadius = Math.sqrt(dx * dx + dy * dy);
if (initialRadius > minRadius) {
const initialAngle = Math.atan2(dy, dx);
dx = point[0] - center[0];
dy = point[1] - center[1];
const currentRadius = Math.sqrt(dx * dx + dy * dy);
if (currentRadius > 0) {
const currentAngle = Math.atan2(dy, dx);
const geometry = modifyGeometry.geometry0.clone();
geometry.scale(currentRadius / initialRadius, undefined, center);
geometry.rotate(currentAngle - initialAngle, center);
modifyGeometry.geometry = geometry;
}
}
}
})
const res = map?.current?.getView()?.getResolution()
if (typeof res === 'number' && feature && defaultStyle) {
return defaultStyle(feature, res)
}
}
});
// Function to update the image layer with a new source when extent changes
const updateImageSource = () => {
const newExtent = polygonFeature.getGeometry()?.getExtent();
const bottomLeft = polygonFeature.getGeometry()?.getCoordinates()[0][0]
const topLeft = polygonFeature.getGeometry()?.getCoordinates()[0][1]
const topRight = polygonFeature.getGeometry()?.getCoordinates()[0][2]
const bottomRight = polygonFeature.getGeometry()?.getCoordinates()[0][3]
setPolygonExtent(newExtent)
setBottomLeft(bottomLeft)
setTopLeft(topLeft)
setTopRight(topRight)
setBottomRight(bottomRight)
if (newExtent && bottomLeft && bottomRight && topRight && topLeft) {
const originalExtent = calculateExtent(bottomLeft, topLeft, topRight, bottomRight)
const worldExtent = get('EPSG:3857')?.getExtent() as Extent
const zoomLevel = Number(map.current?.getView().getZoom()?.toFixed(0))
const { tileX: blX, tileY: blY } = getGridCellPosition(bottomLeft[0], bottomLeft[1], worldExtent, zoomLevel)
const { tileX: tlX, tileY: tlY } = getGridCellPosition(topLeft[0], topLeft[1], worldExtent, zoomLevel)
const { tileX: trX, tileY: trY } = getGridCellPosition(topRight[0], topRight[1], worldExtent, zoomLevel)
const { tileX: brX, tileY: brY } = getGridCellPosition(bottomRight[0], topRight[1], worldExtent, zoomLevel)
const minX = Math.min(blX, tlX, trX, brX)
const maxX = Math.max(blX, tlX, trX, brX)
const minY = Math.min(blY, tlY, trY, brY)
const maxY = Math.max(blY, tlY, trY, brY)
const mapWidth = Math.abs(worldExtent[0] - worldExtent[2])
const mapHeight = Math.abs(worldExtent[1] - worldExtent[3])
const tilesH = Math.sqrt(Math.pow(4, zoomLevel))
const tileWidth = mapWidth / (Math.sqrt(Math.pow(4, zoomLevel)))
const tileHeight = mapHeight / (Math.sqrt(Math.pow(4, zoomLevel)))
let minPosX = minX - (tilesH / 2)
let maxPosX = maxX - (tilesH / 2) + 1
let minPosY = -(minY - (tilesH / 2))
let maxPosY = -(maxY - (tilesH / 2) + 1)
console.log(`tileWidth: ${tileWidth} minPosX: ${minPosX} maxPosX: ${maxPosX} minPosY: ${minPosY} maxPosY: ${maxPosY}`)
const newMinX = tileWidth * minPosX
const newMaxX = tileWidth * maxPosX
const newMinY = tileHeight * maxPosY
const newMaxY = tileHeight * minPosY
console.log('Tile slippy bounds: ', minX, maxX, minY, maxY)
console.log('Tile bounds: ', newMinX, newMaxX, newMinY, newMaxY)
const angleDegrees = calculateRotationAngle(bottomLeft, bottomRight) * 180 / Math.PI
const paddingLeft = Math.abs(newExtent[0] - newMinX)
const paddingRight = Math.abs(newExtent[2] - newMaxX)
const paddingTop = Math.abs(newExtent[3] - newMaxY)
const paddingBottom = Math.abs(newExtent[1] - newMinY)
const pixelWidth = Math.abs(minX - (maxX + 1)) * 256
//const pixelHeight = Math.abs(minY - (maxY + 1)) * 256
const width = Math.abs(newMinX - newMaxX)
const perPixel = width / pixelWidth
const paddingLeftPixel = paddingLeft / perPixel
const paddingRightPixel = paddingRight / perPixel
const paddingTopPixel = paddingTop / perPixel
const paddingBottomPixel = paddingBottom / perPixel
console.log('Rotation angle degrees: ', angleDegrees)
console.log('Padding top pixel: ', paddingTopPixel)
console.log('Padding left pixel: ', paddingLeftPixel)
console.log('Padding right pixel: ', paddingRightPixel)
console.log('Padding bottom pixel: ', paddingBottomPixel)
console.log('Per pixel: ', width / pixelWidth)
const boundsWidthPixel = Math.abs(newExtent[0] - newExtent[2]) / perPixel
const boundsHeightPixel = Math.abs(newExtent[1] - newExtent[3]) / perPixel
console.log('Bounds width pixel', boundsWidthPixel)
console.log('Bounds height pixel', boundsHeightPixel)
// Result will be sharp rotate(angleDegrees), resize(boundsWidthPixel), extend()
const newImageSource = new ImageStatic({
url: imageUrl,
imageExtent: originalExtent,
projection: rotateProjection('EPSG:3857', calculateRotationAngle(bottomLeft, bottomRight), originalExtent)
});
imageLayer.current.setSource(newImageSource);
}
};
translate.on('translateend', updateImageSource);
//modify.on('modifyend', updateImageSource);
modify.on('modifystart', function (event) {
event.features.forEach(function (feature) {
feature.set(
'modifyGeometry',
{ geometry: feature.getGeometry()?.clone() },
true,
);
});
});
modify.on('modifyend', function (event) {
event.features.forEach(function (feature) {
const modifyGeometry = feature.get('modifyGeometry');
if (modifyGeometry) {
feature.setGeometry(modifyGeometry.geometry);
feature.unset('modifyGeometry', true);
}
})
updateImageSource()
})
map.current.addInteraction(translate);
map.current.addInteraction(modify);
}
};
};
reader.readAsDataURL(file);
}
}
}, [])
function regionsInit() {
map.current?.on('click', function (e) {
if (selectedRegion.current !== null) {
selectedRegion.current = null
}
if (map.current) {
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
if (layer === regionsLayer.current) {
selectedRegion.current = feature as Feature
// Zoom to the selected feature
zoomToFeature(selectedRegion.current)
return true
} else return false
});
}
})
// Show current selected region
map.current?.on('pointermove', function (e) {
if (selectedRegion.current !== null) {
selectedRegion.current.setStyle(undefined)
selectedRegion.current = null
}
if (map.current) {
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
if (layer === regionsLayer.current) {
selectedRegion.current = feature as Feature
selectedRegion.current.setStyle(selectStyle)
if (feature.get('district')) {
setStatusText(feature.get('district'))
}
return true
} else return false
})
}
})
// Hide regions layer when fully visible
map.current?.on('moveend', function () {
const viewExtent = map.current?.getView().calculateExtent(map.current.getSize())
const features = regionsLayer.current.getSource()?.getFeatures()
let isViewCovered = false
features?.forEach((feature: Feature) => {
const featureExtent = feature?.getGeometry()?.getExtent()
if (viewExtent && featureExtent) {
if (containsExtent(featureExtent, viewExtent)) {
isViewCovered = true
}
}
})
regionsLayer.current.setVisible(!isViewCovered)
})
}
function getTilesPerSide(zoom: number) {
return Math.pow(2, zoom)
}
function normalize(value: number, min: number, max: number) {
return (value - min) / (max - min)
}
function getTileIndex(normalized: number, tilesPerSide: number) {
return Math.floor(normalized * tilesPerSide)
}
function getGridCellPosition(x: number, y: number, extent: Extent, zoom: number) {
const tilesPerSide = getTilesPerSide(zoom);
const minX = extent[0]
const minY = extent[1]
const maxX = extent[2]
const maxY = extent[3]
// Normalize the coordinates
const xNormalized = normalize(x, minX, maxX);
const yNormalized = normalize(y, minY, maxY);
// Get tile indices
const tileX = getTileIndex(xNormalized, tilesPerSide);
const tileY = getTileIndex(1 - yNormalized, tilesPerSide);
return { tileX, tileY };
}
useEffect(() => {
drawingLayer.current = new VectorLayer({
source: drawingLayerSource.current,
style: drawingLayerStyle
})
overlayLayer.current = new VectorLayer({
source: overlayLayerSource.current,
style: function (feature) {
const styles = [style]
const modifyGeometry = feature.get('modifyGeometry')
const geometry = modifyGeometry ? modifyGeometry.geometry : feature.getGeometry()
const result = calculateCenter(geometry)
const center = result.center
if (center) {
styles.push(
new Style({
geometry: new Point(center),
image: new CircleStyle({
radius: 4,
fill: new Fill({
color: '#ff3333'
})
})
})
)
const coordinates = result.coordinates
if (coordinates) {
const minRadius = result.minRadius
const sqDistances = result.sqDistances
const rsq = minRadius * minRadius
if (Array.isArray(sqDistances)) {
const points = coordinates.filter(function (_coordinate, index) {
return sqDistances[index] > rsq
})
styles.push(
new Style({
geometry: new MultiPoint(points),
image: new CircleStyle({
radius: 4,
fill: new Fill({
color: '#33cc33'
})
})
})
)
}
}
}
return styles
},
})
nodeLayer.current = new VectorLayer({
source: nodeLayerSource.current,
style: drawingLayerStyle
})
map.current = new Map({
controls: [],
layers: [baseLayer.current, satLayer.current, regionsLayer.current, citiesLayer.current, drawingLayer.current, imageLayer.current, overlayLayer.current, nodeLayer.current],
target: mapElement.current as HTMLDivElement,
view: new View({
center: mapCenter,//center: fromLonLat([130.401113, 67.797368]),
zoom: 16,
maxZoom: 21,
//extent: mapExtent,
}),
})
map.current.on('pointermove', function (e: MapBrowserEvent<any>) {
setCurrentCoordinate(e.coordinate)
const currentExtent = get('EPSG:3857')?.getExtent() as Extent
const { tileX, tileY } = getGridCellPosition(e.coordinate[0], e.coordinate[1], currentExtent, Number(map.current?.getView().getZoom()?.toFixed(0)))
setCurrentZ(Number(map.current?.getView().getZoom()?.toFixed(0)))
setCurrentX(tileX)
setCurrentY(tileY)
})
const modify = new Modify({ source: drawingLayerSource.current })
map.current.addInteraction(modify)
map.current.addInteraction(selectFeature.current)
selectFeature.current.on('select', (e) => {
const selectedFeatures = e.selected
if (selectedFeatures.length > 0) {
selectedFeatures.forEach((feature) => {
drawingLayerSource.current?.removeFeature(feature)
})
}
})
loadFeatures()
regionsInit()
if (mapElement.current) {
mapElement.current.addEventListener('dragover', (e) => {
e.preventDefault()
})
mapElement.current.addEventListener('drop', handleImageDrop)
}
return () => {
map?.current?.setTarget(undefined)
if (mapElement.current) {
mapElement.current.removeEventListener('drop', handleImageDrop)
}
}
}, [])
useEffect(() => {
if (currentTool) {
if (draw.current) map?.current?.removeInteraction(draw.current)
if (snap.current) map?.current?.removeInteraction(snap.current)
addInteractions()
} else {
if (draw.current) map?.current?.removeInteraction(draw.current)
if (snap.current) map?.current?.removeInteraction(snap.current)
}
}, [currentTool])
const uploadCoordinates = async (coordinates: any, type: any) => {
try {
const response = await fetch(`${import.meta.env.VITE_API_EMS_URL}/nodes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ coordinates, object_id: 1, type: type }) // Replace with actual object_id
});
if (response.ok) {
const data = await response.json();
console.log('Node created:', data);
} else {
console.error('Failed to upload coordinates');
}
} catch (error) {
console.error('Error:', error);
}
};
const [satelliteOpacity, setSatelliteOpacity] = useState<number>(0)
const [statusText, setStatusText] = useState('')
// Visibility setting
useEffect(() => {
satLayer.current?.setOpacity(satelliteOpacity)
if (satelliteOpacity == 0) {
baseLayer.current?.setVisible(true)
satLayer.current?.setVisible(false)
} if (satelliteOpacity == 1) {
baseLayer.current?.setVisible(false)
satLayer.current?.setVisible(true)
} else if (satelliteOpacity > 0 && satelliteOpacity < 1) {
baseLayer.current?.setVisible(true)
satLayer.current?.setVisible(true)
}
}, [satelliteOpacity])
// Satellite tiles setting
useEffect(() => {
satLayer.current?.setSource(satMapsProvider == 'google' ? gMapsSatSource.current : satMapsProvider == 'yandex' ? yMapsSatSource.current : satMapsProvider == 'custom' ? customMapSource.current : gMapsSatSource.current)
satLayer.current?.getSource()?.refresh()
}, [satMapsProvider])
const submitOverlay = async () => {
if (file && polygonExtent && bottomLeft && topLeft && topRight && bottomRight) {
const formData = new FormData()
formData.append('file', file)
formData.append('extentMinX', polygonExtent[0].toString())
formData.append('extentMinY', polygonExtent[1].toString())
formData.append('extentMaxX', polygonExtent[2].toString())
formData.append('extentMaxY', polygonExtent[3].toString())
formData.append('blX', bottomLeft[0].toString())
formData.append('blY', bottomLeft[1].toString())
formData.append('tlX', topLeft[0].toString())
formData.append('tlY', topLeft[1].toString())
formData.append('trX', topRight[0].toString())
formData.append('trY', topRight[1].toString())
formData.append('brX', bottomRight[0].toString())
formData.append('brY', bottomRight[1].toString())
await fetch(`${import.meta.env.VITE_API_EMS_URL}/upload`, { method: 'POST', body: formData })
}
}
const mapControlsStyle: SxProps<Theme> = {
borderRadius: '4px',
position: 'absolute',
zIndex: '1',
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? '#FFFFFFAA'
: '#000000AA',
backdropFilter: 'blur(8px)'
}
const { data: nodes } = useSWR('/nodes/all', () => fetcher('/nodes/all', BASE_URL.ems), { revalidateOnFocus: false })
useEffect(() => {
// Draw features based on database data
if (Array.isArray(nodes)) {
nodes.map(node => {
if (node.shape_type === 'LINE') {
let coordinates: Coordinate[] = []
if (Array.isArray(node.shape)) {
node.shape.map((point: any) => {
const coordinate = [point.x as number, point.y as number] as Coordinate
coordinates.push(coordinate)
})
}
//console.log(coordinates)
nodeLayerSource.current.addFeature(new Feature({ geometry: new LineString(coordinates) }))
}
})
}
}, [nodes])
return (
<Box height={'calc(100% - 64px)'} maxHeight={'100%'} flex={'1'} flexGrow={'1'} position={'relative'}>
<Stack
direction={'column'}
sx={{
...mapControlsStyle,
top: '8px',
right: '8px',
}}
divider={<Divider orientation='horizontal' flexItem />}>
<IconButton onClick={() => {
fetch(`${import.meta.env.VITE_API_EMS_URL}/hello`, { method: 'GET' }).then(res => console.log(res))
}}>
<Api />
</IconButton>
<IconButton onClick={() => {
saveFeatures()
}}>
<Warning />
</IconButton>
<IconButton
onClick={() => {
draw.current?.removeLastPoint()
}}>
<Undo />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Point' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Point')}>
<Adjust />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'LineString' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('LineString')}>
<Timeline />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Polygon' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Polygon')}>
<RectangleOutlined />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Circle' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Circle')}>
<CircleOutlined />
</IconButton>
<IconButton
onClick={() => map?.current?.addInteraction(new Translate())}
>
<OpenWith />
</IconButton>
<IconButton>
<Straighten />
</IconButton>
</Stack>
<Stack
direction={'column'}
sx={{
...mapControlsStyle,
maxWidth: '300px',
width: '100%',
top: '8px',
left: '8px',
}} divider={<Divider orientation='horizontal' flexItem />}
>
<Stack direction={'row'}>
<IconButton onClick={() => submitOverlay()}>
<Upload />
</IconButton>
<IconButton title='Добавить подложку'>
<Add />
</IconButton>
</Stack>
<Stack direction={'row'} padding={'8px'} spacing={4}>
<Slider size='small' aria-label="Opacity" min={0} max={1} step={0.001} defaultValue={satelliteOpacity} value={satelliteOpacity} onChange={(_, value) => setSatelliteOpacity(Array.isArray(value) ? value[0] : value)} />
<MUISelect
variant='standard'
labelId="demo-simple-select-label"
id="demo-simple-select"
value={satMapsProvider}
label="Satellite Provider"
onChange={(e) => setSatMapsProvider(e.target.value as SatelliteMapsProvider)}
>
<MenuItem value={'google'}>Google</MenuItem>
<MenuItem value={'yandex'}>Яндекс</MenuItem>
<MenuItem value={'custom'}>Custom</MenuItem>
</MUISelect>
</Stack>
<Accordion disableGutters sx={{ backgroundColor: 'transparent' }} defaultExpanded>
<AccordionSummary
expandIcon={<ExpandMore />}
aria-controls="panel1-content"
id="panel1-header"
>
<Typography>Объекты</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse
malesuada lacus ex, sit amet blandit leo lobortis eget.
</Typography>
</AccordionDetails>
</Accordion>
</Stack>
<Stack direction={'row'}
sx={{
...mapControlsStyle,
bottom: '8px',
left: '8px',
}}
divider={<Divider orientation='vertical' flexItem />}
>
<Stack>
<Typography>
x: {currentCoordinate?.[0]}
</Typography>
<Typography>
y: {currentCoordinate?.[1]}
</Typography>
</Stack>
<Typography>
Z={currentZ}
X={currentX}
Y={currentY}
</Typography>
</Stack>
<Stack direction={'row'}
sx={{
...mapControlsStyle,
bottom: '8px',
right: '8px',
}}
divider={<Divider orientation='vertical' flexItem />}>
<Stack>
{statusText}
</Stack>
</Stack>
<div
id="map-container"
ref={mapElement}
style={{
width: '100%',
height: '100%',
maxHeight: '100%',
position: 'fixed',
flexGrow: 1
}}
>
</div>
</Box>
);
};
export default MapComponent

View File

@ -0,0 +1,9 @@
import { transform } from "ol/proj"
const mapExtent = [11388546.533293726, 7061866.113051185, 18924313.434856508, 13932243.11199202]
const mapCenter = transform([129.7578941, 62.030804], 'EPSG:4326', 'EPSG:3857')
export {
mapExtent,
mapCenter
}

View File

@ -0,0 +1,32 @@
import GeoJSON from "ol/format/GeoJSON";
import { get } from "ol/proj";
import { register } from "ol/proj/proj4";
import { XYZ } from "ol/source";
import VectorSource from "ol/source/Vector";
import proj4 from "proj4";
proj4.defs('EPSG:3395', '+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs')
register(proj4);
const yandexProjection = get('EPSG:3395')?.setExtent([-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244]) || 'EPSG:3395'
const googleMapsSatelliteSource = new XYZ({
url: `${import.meta.env.VITE_API_EMS_URL}/tile/google/{z}/{x}/{y}`,
attributions: 'Map data © Google'
})
const yandexMapsSatelliteSource = new XYZ({
url: `${import.meta.env.VITE_API_EMS_URL}/tile/yandex/{z}/{x}/{y}`,
attributions: 'Map data © Yandex',
projection: yandexProjection,
})
const regionsLayerSource = new VectorSource({
url: 'sakha_republic.geojson',
format: new GeoJSON(),
})
export {
googleMapsSatelliteSource,
yandexMapsSatelliteSource,
regionsLayerSource
}

View File

@ -0,0 +1,39 @@
import Fill from "ol/style/Fill";
import { FlatStyleLike } from "ol/style/flat";
import Stroke from "ol/style/Stroke";
import Style from "ol/style/Style";
const drawingLayerStyle: FlatStyleLike = {
'fill-color': 'rgba(255, 255, 255, 0.2)',
//'stroke-color': '#ffcc33',
'stroke-color': '#000000',
'stroke-width': 2,
'circle-radius': 7,
'circle-fill-color': '#ffcc33',
}
const selectStyle = new Style({
fill: new Fill({
color: 'rgba(0, 0, 255, 0.3)',
}),
stroke: new Stroke({
color: 'rgba(255, 255, 255, 0.7)',
width: 2,
}),
})
const regionsLayerStyle = new Style({
stroke: new Stroke({
color: 'blue',
width: 1,
}),
fill: new Fill({
color: 'rgba(0, 0, 255, 0.1)',
}),
})
export {
drawingLayerStyle,
selectStyle,
regionsLayerStyle
}

View File

@ -0,0 +1,127 @@
import { Coordinate, distance, rotate } from "ol/coordinate";
import { Extent, getCenter } from "ol/extent";
import { addCoordinateTransforms, addProjection, get, getTransform, Projection, ProjectionLike, transform } from "ol/proj";
import proj4 from "proj4";
function rotateProjection(projection: ProjectionLike, angle: number, extent: Extent) {
function rotateCoordinate(coordinate: Coordinate, angle: number, anchor: Coordinate) {
var coord = rotate(
[coordinate[0] - anchor[0], coordinate[1] - anchor[1]],
angle
);
return [coord[0] + anchor[0], coord[1] + anchor[1]];
}
function rotateTransform(coordinate: Coordinate) {
return rotateCoordinate(coordinate, angle, getCenter(extent));
}
function normalTransform(coordinate: Coordinate) {
return rotateCoordinate(coordinate, -angle, getCenter(extent));
}
var normalProjection = get(projection);
if (normalProjection) {
var rotatedProjection = new Projection({
code: normalProjection.getCode() + ":" + angle.toString() + ":" + extent.toString(),
units: normalProjection.getUnits(),
extent: extent
});
addProjection(rotatedProjection);
addCoordinateTransforms(
"EPSG:4326",
rotatedProjection,
function (coordinate) {
return rotateTransform(transform(coordinate, "EPSG:4326", projection));
},
function (coordinate) {
return transform(normalTransform(coordinate), projection, "EPSG:4326");
}
);
addCoordinateTransforms(
"EPSG:3857",
rotatedProjection,
function (coordinate) {
return rotateTransform(transform(coordinate, "EPSG:3857", projection));
},
function (coordinate) {
return transform(normalTransform(coordinate), projection, "EPSG:3857");
}
);
// also set up transforms with any projections defined using proj4
if (typeof proj4 !== "undefined") {
var projCodes = Object.keys(proj4.defs);
projCodes.forEach(function (code) {
var proj4Projection = get(code) as Projection;
if (proj4Projection) {
if (!getTransform(proj4Projection, rotatedProjection)) {
addCoordinateTransforms(
proj4Projection,
rotatedProjection,
function (coordinate) {
return rotateTransform(
transform(coordinate, proj4Projection, projection)
);
},
function (coordinate) {
return transform(
normalTransform(coordinate),
projection,
proj4Projection
);
}
);
}
}
});
}
return rotatedProjection;
}
}
const calculateCentroid = (bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) => {
const x = (bottomLeft[0] + topLeft[0] + topRight[0] + bottomRight[0]) / 4;
const y = (bottomLeft[1] + topLeft[1] + topRight[1] + bottomRight[1]) / 4;
return [x, y];
}
function calculateRotationAngle(bottomLeft: Coordinate, bottomRight: Coordinate) {
// Calculate the difference in x and y coordinates between bottom right and bottom left
const deltaX = bottomRight[0] - bottomLeft[0];
const deltaY = bottomRight[1] - bottomLeft[1];
// Calculate the angle using atan2
const angle = -Math.atan2(deltaY, deltaX);
return angle;
}
function calculateExtent(bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) {
const width = distance(bottomLeft, bottomRight);
const height = distance(bottomLeft, topLeft);
// Calculate the centroid of the polygon
const [centerX, centerY] = calculateCentroid(bottomLeft, topLeft, topRight, bottomRight);
// Define the extent based on the center and dimensions
const extent = [
centerX - width / 2, // minX
centerY - height / 2, // minY
centerX + width / 2, // maxX
centerY + height / 2 // maxY
];
return extent;
}
export {
rotateProjection,
calculateRotationAngle,
calculateExtent,
calculateCentroid
}

View File

@ -0,0 +1,268 @@
import { useEffect, useRef } from 'react'
import { AppBar, Box, Button, CircularProgress, Dialog, IconButton, Toolbar, Typography } from '@mui/material';
import { ChevronLeft, ChevronRight, Close, Warning } from '@mui/icons-material';
import { useDownload, useFileType } from '../../hooks/swrHooks';
import jsPreviewExcel from "@js-preview/excel"
import '@js-preview/excel/lib/index.css'
import jsPreviewDocx from "@js-preview/docx"
import '@js-preview/docx/lib/index.css'
import jsPreviewPdf from '@js-preview/pdf'
import { IDocument } from '../../interfaces/documents';
interface Props {
open: boolean;
setOpen: (state: boolean) => void;
docs: IDocument[];
currentFileNo: number;
setCurrentFileNo: (state: number) => void;
}
interface ViewerProps {
url: string
}
function PdfViewer({
url
}: ViewerProps) {
const previewContainerRef = useRef(null)
const pdfPreviewer = jsPreviewPdf
useEffect(() => {
if (previewContainerRef && previewContainerRef.current) {
pdfPreviewer.init(previewContainerRef.current)
.preview(url)
}
return () => {
if (previewContainerRef && previewContainerRef.current) {
previewContainerRef.current = null
}
}
}, [previewContainerRef])
return (
<Box ref={previewContainerRef} sx={{
width: '100%',
height: '100%'
}} />
)
}
function DocxViewer({
url
}: ViewerProps) {
const previewContainerRef = useRef(null)
useEffect(() => {
if (previewContainerRef && previewContainerRef.current) {
jsPreviewDocx.init(previewContainerRef.current, {
breakPages: true,
inWrapper: true,
ignoreHeight: true,
})
.preview(url)
}
return () => {
if (previewContainerRef && previewContainerRef.current) {
previewContainerRef.current = null
}
}
}, [])
return (
<Box ref={previewContainerRef} sx={{
width: '100%',
height: '100%'
}} />
)
}
function ExcelViewer({
url
}: ViewerProps) {
const previewContainerRef = useRef(null)
useEffect(() => {
if (previewContainerRef && previewContainerRef.current) {
jsPreviewExcel.init(previewContainerRef.current)
.preview(url)
}
return () => {
if (previewContainerRef && previewContainerRef.current) {
previewContainerRef.current = null
}
}
}, [])
return (
<Box ref={previewContainerRef} sx={{
width: '100%',
height: '100%'
}} />
)
}
function ImageViewer({
url
}: ViewerProps) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
objectFit: 'contain',
width: '100%',
height: '100%'
}}>
<img alt='image-preview' src={url} style={{
display: 'flex',
maxWidth: '100%',
maxHeight: '100%'
}} />
</Box>
)
}
export default function FileViewer({
open,
setOpen,
docs,
currentFileNo,
setCurrentFileNo
}: Props) {
const { file, isLoading: fileIsLoading } = useDownload(currentFileNo >= 0 ? docs[currentFileNo]?.document_folder_id : null, currentFileNo >= 0 ? docs[currentFileNo]?.id : null)
const { fileType, isLoading: fileTypeIsLoading } = useFileType(currentFileNo >= 0 ? docs[currentFileNo]?.name : null, currentFileNo >= 0 ? file : null)
const handleSave = async () => {
const url = window.URL.createObjectURL(file)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', docs[currentFileNo].name)
document.body.appendChild(link)
link.click()
link.remove()
}
return (
<Dialog
fullScreen
open={open}
onClose={() => {
setOpen(false)
setCurrentFileNo(-1)
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<AppBar sx={{ position: 'sticky' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
setOpen(false)
setCurrentFileNo(-1)
}}
aria-label="close"
>
<Close />
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
{currentFileNo != -1 && docs[currentFileNo].name}
</Typography>
<div>
<IconButton
color='inherit'
onClick={() => {
if (currentFileNo >= 0 && currentFileNo > 0) {
setCurrentFileNo(currentFileNo - 1)
}
}}
disabled={currentFileNo >= 0 && currentFileNo === 0}
>
<ChevronLeft />
</IconButton>
<IconButton
color='inherit'
onClick={() => {
if (currentFileNo >= 0 && currentFileNo < docs.length) {
setCurrentFileNo(currentFileNo + 1)
}
}}
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
>
<ChevronRight />
</IconButton>
</div>
<Button
autoFocus
color="inherit"
onClick={handleSave}
>
Сохранить
</Button>
</Toolbar>
</AppBar>
<Box sx={{
flexGrow: '1',
overflowY: 'hidden'
}}>
{fileIsLoading || fileTypeIsLoading ?
<Box sx={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center'
}}>
<CircularProgress />
</Box>
:
fileType === 'application/pdf' ?
<PdfViewer url={window.URL.createObjectURL(file)} />
:
fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ?
<ExcelViewer url={window.URL.createObjectURL(file)} />
:
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ?
<DocxViewer url={window.URL.createObjectURL(file)} />
:
fileType?.startsWith('image/') ?
<ImageViewer url={window.URL.createObjectURL(file)} />
:
fileType && file ?
<Box sx={{ display: 'flex', gap: '16px', flexDirection: 'column', p: '16px' }}>
<Box sx={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<Warning />
<Typography>
Предпросмотр данного файла невозможен.
</Typography>
</Box>
<Box>
<Button variant='contained' onClick={() => {
handleSave()
}}>
Сохранить
</Button>
</Box>
</Box>
:
null
}
</Box>
</Dialog>
)
}

View File

@ -0,0 +1,171 @@
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import InboxIcon from '@mui/icons-material/MoveToInbox';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MailIcon from '@mui/icons-material/Mail';
import MenuIcon from '@mui/icons-material/Menu';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
const drawerWidth = 240;
export default function ResponsiveDrawer() {
//const { window } = props;
const [mobileOpen, setMobileOpen] = React.useState(false);
const [isClosing, setIsClosing] = React.useState(false);
const handleDrawerClose = () => {
setIsClosing(true);
setMobileOpen(false);
};
const handleDrawerTransitionEnd = () => {
setIsClosing(false);
};
const handleDrawerToggle = () => {
if (!isClosing) {
setMobileOpen(!mobileOpen);
}
};
const drawer = (
<div>
<Toolbar />
<Divider />
<List>
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
{['All mail', 'Trash', 'Spam'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</div>
);
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
Dashboard
</Typography>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
aria-label="mailbox folders"
>
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
<Drawer
variant="temporary"
open={mobileOpen}
onTransitionEnd={handleDrawerTransitionEnd}
onClose={handleDrawerClose}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{ flexGrow: 1, p: 3, width: { sm: `calc(100% - ${drawerWidth}px)` } }}
>
<Toolbar />
<Typography paragraph>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus non
enim praesent elementum facilisis leo vel. Risus at ultrices mi tempus
imperdiet. Semper risus in hendrerit gravida rutrum quisque non tellus.
Convallis convallis tellus id interdum velit laoreet id donec ultrices.
Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl suscipit
adipiscing bibendum est ultricies integer quis. Cursus euismod quis viverra
nibh cras. Metus vulputate eu scelerisque felis imperdiet proin fermentum
leo. Mauris commodo quis imperdiet massa tincidunt. Cras tincidunt lobortis
feugiat vivamus at augue. At augue eget arcu dictum varius duis at
consectetur lorem. Velit sed ullamcorper morbi tincidunt. Lorem donec massa
sapien faucibus et molestie ac.
</Typography>
<Typography paragraph>
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est ullamcorper
eget nulla facilisi etiam dignissim diam. Pulvinar elementum integer enim
neque volutpat ac tincidunt. Ornare suspendisse sed nisi lacus sed viverra
tellus. Purus sit amet volutpat consequat mauris. Elementum eu facilisis
sed odio morbi. Euismod lacinia at quis risus sed vulputate odio. Morbi
tincidunt ornare massa eget egestas purus viverra accumsan in. In hendrerit
gravida rutrum quisque non tellus orci ac. Pellentesque nec nam aliquam sem
et tortor. Habitant morbi tristique senectus et. Adipiscing elit duis
tristique sollicitudin nibh sit. Ornare aenean euismod elementum nisi quis
eleifend. Commodo viverra maecenas accumsan lacus vel facilisis. Nulla
posuere sollicitudin aliquam ultrices sagittis orci a.
</Typography>
</Box>
</Box>
);
}

View File

@ -0,0 +1,30 @@
import { Tab, Tabs } from "@mui/material"
import { Link, matchPath, useLocation } from "react-router-dom"
function useRouteMatch(patterns: readonly string[]) {
const { pathname } = useLocation()
for (let i = 0; i < patterns.length; i += 1) {
const pattern = patterns[i]
const possibleMatch = matchPath(pattern, pathname)
if (possibleMatch !== null) {
return possibleMatch
}
}
return null
}
export default function NavTabs() {
const routeMatch = useRouteMatch(['/', '/user', '/role']);
const currentTab = routeMatch?.pattern?.path;
return (
<Tabs value={currentTab}>
<Tab label="Главная" value="/" to="/" component={Link} />
<Tab label="Пользователи" value="/user" to="/user" component={Link} />
<Tab label="Роли" value="/role" to="/role" component={Link} />
</Tabs>
);
}

View File

@ -0,0 +1,12 @@
export const USER_DATA_KEY = 'userData';
export const TOKEN_AUTH_KEY = 'authToken'
export const TOKEN_ISSUED_DATE_KEY = 'tokenIssuedDate';
export const TOKEN_EXPIRY_DURATION = 7 * 24 * 60 * 60 * 1000;
export const BASE_URL = {
auth: import.meta.env.VITE_API_AUTH_URL,
info: import.meta.env.VITE_API_INFO_URL,
fuel: import.meta.env.VITE_API_FUEL_URL,
servers: import.meta.env.VITE_API_SERVERS_URL,
ems: import.meta.env.VITE_API_EMS_URL,
}

View File

@ -0,0 +1,302 @@
import useSWR, { SWRConfiguration } from "swr";
import RoleService from "../services/RoleService";
import UserService from "../services/UserService";
import { fetcher } from "../http/axiosInstance";
import { fileTypeFromBlob } from "file-type/core";
import { BASE_URL } from "../constants";
const swrOptions: SWRConfiguration = {
revalidateOnFocus: false,
}
export function useRoles() {
const { data, error, isLoading } = useSWR(`/auth/roles`, RoleService.getRoles, swrOptions)
return {
roles: data?.data,
isLoading,
isError: error
}
}
export function useUsers() {
const { data, error, isLoading } = useSWR(`/auth/user`, UserService.getUsers, swrOptions)
return {
users: data?.data,
isLoading,
isError: error
}
}
export function useCompanies(limit?: number, offset?: number) {
const { data, error, isLoading } = useSWR(`/info/companies?limit=${limit || 10}&offset=${offset || 0}`, fetcher, swrOptions)
return {
companies: data,
isLoading,
isError: error
}
}
export function useFolders(limit?: number, offset?: number) {
const { data, error, isLoading } = useSWR(
`/info/document_folder?limit=${limit || 10}&offset=${offset || 0}`,
fetcher,
swrOptions
)
return {
folders: data,
isLoading,
isError: error
}
}
export function useDocuments(folder_id?: number) {
const { data, error, isLoading } = useSWR(
folder_id ? `/info/documents/${folder_id}` : null,
fetcher,
swrOptions
)
return {
documents: data,
isLoading,
isError: error
}
}
export function useDownload(folder_id?: number | null, id?: number | null) {
const { data, error, isLoading } = useSWR(
folder_id && id ? `/info/document/${folder_id}&${id}` : null,
folder_id && id ? (url) => fetcher(url, BASE_URL.info, "blob") : null,
swrOptions
)
return {
file: data,
isLoading,
isError: error
}
}
export function useFileType(fileName?: string | null, file?: Blob | null) {
const { data, error, isLoading } = useSWR(
fileName && file ? `/filetype/${fileName}` : null,
file ? () => fileTypeFromBlob(file) : null,
swrOptions
)
return {
fileType: data?.mime,
isLoading,
isError: error
}
}
export function useReport(city_id?: number | null) {
const { data, error, isLoading } = useSWR(
city_id ? `/info/reports/${city_id}?to_export=false` : null,
(url) => fetcher(url, BASE_URL.info),
swrOptions
)
return {
report: data ? JSON.parse(data) : [],
isLoading,
isError: error
}
}
export function useReportExport(city_id?: number | null, to_export?: boolean) {
const { data, error, isLoading } = useSWR(
city_id && to_export ? `/info/reports/${city_id}?to_export=${to_export}` : null,
(url) => fetcher(url, BASE_URL.info, 'blob'),
swrOptions
)
return {
reportExported: data ? data : null,
isLoading,
isError: error
}
}
// API general (fuel)
export function useAddress(limit?: number, page?: number) {
const { data, error, isLoading } = useSWR(
`/general/address?limit=${limit || 10}&page=${page || 1}`,
(url) => fetcher(url, BASE_URL.fuel),
swrOptions
)
return {
address: data,
isLoading,
isError: error
}
}
export function useRegions(limit?: number, page?: number, search?: string | null) {
const { data, error, isLoading } = useSWR(
`/general/regions?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
(url) => fetcher(url, BASE_URL.fuel),
swrOptions
)
return {
regions: data,
isLoading,
isError: error
}
}
export function useCities(limit?: number, page?: number, search?: string | null) {
const { data, error, isLoading } = useSWR(
`/general/cities?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
(url) => fetcher(url, BASE_URL.fuel),
swrOptions
)
return {
cities: data,
isLoading,
isError: error
}
}
export function useBoilers(limit?: number, page?: number, search?: string) {
const { data, error, isLoading } = useSWR(
`/general/boilers?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
(url) => fetcher(url, BASE_URL.fuel),
swrOptions
)
return {
boilers: data,
isLoading,
isError: error
}
}
// Servers
export function useServers(region_id?: number | null, offset?: number, limit?: number) {
const { data, error, isLoading } = useSWR(
region_id ? `/api/servers?region_id=${region_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/servers?offset=${offset || 0}&limit=${limit || 10}`,
(url: string) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
servers: data,
isLoading,
isError: error
}
}
export function useServersInfo(region_id?: number, offset?: number, limit?: number) {
const { data, error, isLoading } = useSWR(
region_id ? `/api/servers_info?region_id=${region_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/servers_info?offset=${offset || 0}&limit=${limit || 10}`,
(url: string) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
serversInfo: data,
isLoading,
isError: error
}
}
export function useServer(server_id?: number) {
const { data, error, isLoading } = useSWR(
server_id ? `/api/server/${server_id}` : null,
(url) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
server: data,
isLoading,
isError: error
}
}
export function useServerIps(server_id?: number | null, offset?: number, limit?: number) {
const { data, error, isLoading } = useSWR(
server_id ? `/api/server_ips?server_id=${server_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/server_ips?offset=${offset || 0}&limit=${limit || 10}`,
(url: string) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
serverIps: data,
isLoading,
isError: error
}
}
// Hardware
export function useHardwares(server_id?: number, offset?: number, limit?: number) {
const { data, error, isLoading } = useSWR(
server_id ? `/api/hardwares?server_id=${server_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/hardwares?offset=${offset || 0}&limit=${limit || 10}`,
(url: string) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
hardwares: data,
isLoading,
isError: error
}
}
export function useHardware(hardware_id?: number) {
const { data, error, isLoading } = useSWR(
hardware_id ? `/api/hardware/${hardware_id}` : null,
(url) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
hardware: data,
isLoading,
isError: error
}
}
// Storage
export function useStorages(hardware_id?: number, offset?: number, limit?: number) {
const { data, error, isLoading } = useSWR(
hardware_id ? `/api/storages?hardware_id=${hardware_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/storages?offset=${offset || 0}&limit=${limit || 10}`,
(url: string) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
storages: data,
isLoading,
isError: error
}
}
export function useStorage(storage_id?: number) {
const { data, error, isLoading } = useSWR(
storage_id ? `/api/storage/${storage_id}` : null,
(url) => fetcher(url, BASE_URL.servers),
swrOptions
)
return {
storage: data,
isLoading,
isError: error
}
}

View File

@ -0,0 +1,27 @@
import axios, { ResponseType } from 'axios';
import { useAuthStore } from '../store/auth';
const axiosInstance = axios.create();
axiosInstance.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
export const fetcher = (url: string, baseURL?: string, responseType?: ResponseType) => axiosInstance.get(url, {
baseURL: baseURL || import.meta.env.VITE_API_INFO_URL,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
responseType: responseType ? responseType : "json"
}).then(res => res.data)
export default axiosInstance;

View File

@ -0,0 +1,39 @@
export interface User {
id: number;
}
export interface UserData extends User {
email: string;
login: string;
phone: string;
name: string;
surname: string;
is_active: boolean;
role_id: number;
}
export interface UserCreds extends User {
password: string;
}
export interface AuthState {
isAuthenticated: boolean;
token: string | null;
userData: UserData | null;
}
export interface LoginFormData {
username: string;
password: string;
grant_type: string;
scope?: string;
client_id?: string;
client_secret?: string;
}
export interface ApiResponse {
access_token: string;
data: JSON;
status: number;
statusText: string;
}

View File

@ -0,0 +1,35 @@
import { Validate } from "react-hook-form";
export interface CreateFieldTypes {
string: 'string';
number: 'number';
date: 'date';
dateTime: 'dateTime';
boolean: 'boolean';
singleSelect: 'singleSelect';
actions: 'actions';
custom: 'custom';
}
export interface InputTypes {
password: 'password';
}
export type CreateFieldType = CreateFieldTypes[keyof CreateFieldTypes]
export type InputType = InputTypes[keyof InputTypes]
export interface CreateField {
key: string;
headerName?: string;
type: CreateFieldType;
required?: boolean;
defaultValue?: any;
inputType?: InputType;
validate?: Validate<string, boolean>;
/** Watch for field */
watch?: string;
/** Message on watch */
watchMessage?: string;
/** Should field be included in the request */
include?: boolean;
}

View File

@ -0,0 +1,65 @@
// owner_id relates to other companies
export interface ICompany {
name: string;
fullname: string;
description: string;
owner_id: number;
}
export interface IDepartment {
name: string;
fullname: string;
description: string;
company_id: number;
owner_id: number;
}
export interface IDocumentFolder {
id: number;
name: string;
description: string;
create_date: string;
}
export interface IDocument {
id: number;
document_folder_id: number,
name: string;
description: string;
department_id: number;
create_date: string;
}
export interface IBank {
name: string;
bik: string;
corschet: string;
activ: boolean;
id_1c: string;
}
export interface IOrganization {
full_name: string;
name: string;
inn: string;
ogrn: string;
kpp: string;
okopf: string;
legal_address: string;
actual_address: string;
mail_address: string;
id_budget: number;
fio_dir: string;
phone: string;
email: string;
comment: string;
id_bank: string;
id_1c: string;
active: boolean;
}
export interface IOrganizationBank {
id_organization: string;
id_banks: string;
rasch_schet: string;
}

View File

@ -0,0 +1,18 @@
export interface IRegion {
id: number;
name: string;
}
export interface ICity {
id: number;
name: string;
}
export interface IBoiler {
id: string;
id_object: string;
boiler_name: string;
boiler_code: string;
id_city: number;
activity: boolean;
}

View File

@ -0,0 +1,6 @@
export interface SatelliteMapsProviders {
google: 'google';
yandex: 'yandex';
custom: 'custom';
}
export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders]

View File

@ -0,0 +1,3 @@
export interface PreferencesState {
darkMode: boolean;
}

View File

@ -0,0 +1,10 @@
export interface IRole {
name: string;
description?: string | null;
id: number;
}
export interface IRoleCreate {
name: string;
description?: string | null;
}

View File

@ -0,0 +1,33 @@
export interface IServer {
id: number;
name: string;
region_id: number;
}
export interface IServersInfo extends IServer {
servers_count: number;
IPs_count: number;
status: string;
}
export interface IServerIP {
name: string;
is_actual: boolean;
ip: string;
server_id: number;
}
export interface IHardware {
name: string;
os_info: string;
ram: string;
processor: string;
server_id: number;
}
export interface IStorage {
name: string;
size: string;
storage_type: string;
hardware_id: number;
}

View File

@ -0,0 +1,10 @@
export interface IUser {
id: number;
password: string;
email: string;
login: string;
phone: string;
name: string;
surname: string;
is_active: boolean;
}

View File

@ -0,0 +1,211 @@
import * as React from 'react';
import { styled, createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import MuiDrawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import List from '@mui/material/List';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import { colors, ListItem, ListItemButton, ListItemIcon, ListItemText, } from '@mui/material';
import { Outlet, useNavigate } from 'react-router-dom';
import { UserData } from '../interfaces/auth';
import { getUserData, useAuthStore } from '../store/auth';
import { useTheme } from '@emotion/react';
import AccountMenu from '../components/AccountMenu';
import { pages } from '../App';
const drawerWidth: number = 240;
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
}
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open',
})<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme, open }) => ({
'& .MuiDrawer-paper': {
position: 'relative',
whiteSpace: 'nowrap',
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
boxSizing: 'border-box',
...(!open && {
overflowX: 'hidden',
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing(7),
[theme.breakpoints.up('sm')]: {
//width: theme.spacing(9),
},
}),
},
}),
);
export default function DashboardLayout() {
const theme = useTheme()
const innerTheme = createTheme(theme)
const [open, setOpen] = React.useState(true);
const toggleDrawer = () => {
setOpen(!open);
};
const authStore = useAuthStore();
const navigate = useNavigate()
const getPageTitle = () => {
const currentPath = location.pathname;
const allPages = [...pages];
const currentPage = allPages.find(page => page.path === currentPath);
return currentPage ? currentPage.label : "Dashboard";
};
const [userData, setUserData] = React.useState<UserData>();
React.useEffect(() => {
if (authStore) {
const stored = getUserData()
if (stored) {
setUserData(stored)
}
}
}, [authStore])
return (
<ThemeProvider theme={innerTheme}>
<Box sx={{
display: 'flex',
height: "100%"
}}>
<CssBaseline />
<AppBar position="absolute" open={open}>
<Toolbar
sx={{
pr: '24px', // keep right padding when drawer closed
}}
>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
sx={{
marginRight: '36px',
//...(open && { display: 'none' }),
}}
>
<MenuIcon />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
{getPageTitle()}
</Typography>
<Box sx={{ display: "flex", gap: "8px" }}>
<Box>
<Typography>{userData?.name} {userData?.surname}</Typography>
<Divider />
<Typography variant="caption">{userData?.login}</Typography>
</Box>
<AccountMenu />
</Box>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
px: [1],
}}
>
<Box display="flex" justifyContent={'space-between'} width={"100%"}>
</Box>
</Toolbar>
<Divider />
<List component="nav">
{pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item, index) => (
<ListItem
key={index}
disablePadding
>
<ListItemButton
onClick={() => {
navigate(item.path)
}}
style={{ background: location.pathname === item.path ? innerTheme.palette.action.selected : "transparent" }}
selected={location.pathname === item.path}
>
<ListItemIcon>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
sx={{ color: location.pathname === item.path ? colors.blue[700] : innerTheme.palette.text.primary }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<Box
component="main"
sx={{
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[900],
flexGrow: 1,
maxHeight: "100vh",
overflow: 'auto',
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
</ThemeProvider>
);
}

View File

@ -0,0 +1,252 @@
// Layout for dashboard with responsive drawer
import { Outlet, useLocation, useNavigate } from "react-router-dom"
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MenuIcon from '@mui/icons-material/Menu';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { Api, ExitToApp, Home, People, Settings, Shield } from "@mui/icons-material";
import { getUserData, useAuthStore } from "../store/auth";
import { UserData } from "../interfaces/auth";
const drawerWidth = 240;
export default function DashboardLayoutResponsive() {
const [open, setOpen] = React.useState(true);
const authStore = useAuthStore();
const [userData, setUserData] = React.useState<UserData>();
const location = useLocation()
const navigate = useNavigate()
//const { window } = props;
const [mobileOpen, setMobileOpen] = React.useState(false);
const [isClosing, setIsClosing] = React.useState(false);
const handleDrawerClose = () => {
setIsClosing(true);
setMobileOpen(false);
};
const handleDrawerTransitionEnd = () => {
setIsClosing(false);
};
const handleDrawerToggle = () => {
if (!isClosing) {
setMobileOpen(!mobileOpen);
}
};
const pages = [
{
label: "Главная",
path: "/",
icon: <Home />
},
{
label: "Пользователи",
path: "/user",
icon: <People />
},
{
label: "Роли",
path: "/role",
icon: <Shield />
},
{
label: "API Test",
path: "/api-test",
icon: <Api />
},
]
const misc = [
{
label: "Настройки",
path: "/settings",
icon: <Settings />
},
{
label: "Выход",
path: "/signOut",
icon: <ExitToApp />
}
]
const getPageTitle = () => {
const currentPath = location.pathname;
const allPages = [...pages, ...misc];
const currentPage = allPages.find(page => page.path === currentPath);
return currentPage ? currentPage.label : "Dashboard";
};
const toggleDrawer = () => {
setOpen(!open);
};
const drawer = (
<div>
<Toolbar>
<Box display="flex" justifyContent={'space-between'} width={"100%"}>
<Box>
<Typography>{userData?.name} {userData?.surname}</Typography>
<Divider />
<Typography variant="caption">{userData?.login}</Typography>
</Box>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
sx={{
...(open && { display: 'none' }),
}}
>
<MenuIcon />
</IconButton>
</Box>
</Toolbar>
<Divider />
<List>
{pages.map((item, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => {
navigate(item.path)
}}
selected={location.pathname === item.path}
>
<ListItemIcon>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
{misc.map((item, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => {
navigate(item.path)
}}
selected={location.pathname === item.path}
>
<ListItemIcon>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
</div >
);
React.useEffect(() => {
if (authStore) {
const stored = getUserData()
if (stored) {
setUserData(stored)
}
}
}, [authStore])
return (
<Box sx={{
display: 'flex',
flexGrow: 1,
height: "100vh"
}}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{getPageTitle()}
</Typography>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
aria-label="mailbox folders"
>
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
<Drawer
variant="temporary"
open={mobileOpen}
onTransitionEnd={handleDrawerTransitionEnd}
onClose={handleDrawerClose}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${drawerWidth}px)` }
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
);
}

View File

@ -0,0 +1,31 @@
// Layout for fullscreen pages
import { Box, createTheme, ThemeProvider, useTheme } from "@mui/material";
import { Outlet } from "react-router-dom";
export default function MainLayout() {
const theme = useTheme()
const innerTheme = createTheme(theme)
return (
<ThemeProvider theme={innerTheme}>
<Box
sx={{
color: (theme) => theme.palette.mode === 'light'
? theme.palette.grey[900]
: theme.palette.grey[100],
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[900],
flexGrow: 1,
maxHeight: "100vh",
height: '100%',
overflow: 'auto',
}}
>
<Outlet />
</Box>
</ThemeProvider>
)
}

98
client/src/main.tsx Normal file
View File

@ -0,0 +1,98 @@
import "@fontsource/inter";
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { ThemeProvider } from '@emotion/react'
import { createTheme } from '@mui/material'
import { ruRU } from '@mui/material/locale'
import { getDarkMode, usePrefStore } from "./store/preferences.ts";
const mainTheme = createTheme(
{
typography: {
fontFamily: [
'Inter'
].join(',')
},
components: {
MuiAppBar: {
// styleOverrides: {
// colorPrimary: {
// backgroundColor: 'gray'
// }
// }
},
MuiListItemButton: {
defaultProps: {
//disableRipple: true
}
},
MuiButton: {
defaultProps: {
//disableRipple: true
}
},
MuiButtonBase: {
defaultProps: {
//disableRipple: true,
}
},
MuiButtonGroup: {
defaultProps: {
//disableRipple: true,
}
},
MuiIconButton: {
defaultProps: {
}
},
MuiIcon: {
defaultProps: {
}
}
},
},
ruRU
)
const darkTheme = createTheme(
{
...mainTheme,
palette: {
mode: "dark",
primary: { main: '#1976d2' },
},
},
);
const lightTheme = createTheme(
{
...mainTheme,
palette: {
mode: "light",
primary: { main: '#1976d2' },
},
},
);
function ThemedApp() {
const prefStore = usePrefStore()
useEffect(() => {
getDarkMode()
}, [])
return (
<ThemeProvider theme={prefStore.darkMode ? darkTheme : lightTheme}>
<App />
</ThemeProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ThemedApp />
</React.StrictMode>,
)

View File

@ -0,0 +1,51 @@
import { Box } from "@mui/material"
import { useCities } from "../hooks/swrHooks"
import { useEffect, useState } from "react"
import { DataGrid, GridColDef } from "@mui/x-data-grid"
import axiosInstance from "../http/axiosInstance"
import { BASE_URL } from "../constants"
export default function ApiTest() {
const limit = 10
const [paginationModel, setPaginationModel] = useState({
page: 1,
pageSize: limit
})
const [rowCount, setRowCount] = useState(0)
const fetchCount = async () => {
await axiosInstance.get(`/general/cities_count`, {
baseURL: BASE_URL.fuel
}).then(response => {
setRowCount(response.data)
})
}
const { cities, isLoading } = useCities(paginationModel.pageSize, paginationModel.page)
useEffect(() => {
fetchCount()
}, [])
const citiesColumns: GridColDef[] = [
{ field: 'id' },
{ field: 'name' },
]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }}>
<DataGrid
rows={cities || []}
columns={citiesColumns}
paginationMode='server'
rowCount={rowCount}
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
/>
</Box>
)
}

View File

@ -0,0 +1,57 @@
import { Box, Typography } from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { useEffect, useState } from 'react'
import { IBoiler } from '../interfaces/fuel'
import { useBoilers } from '../hooks/swrHooks'
function Boilers() {
const [boilersPage, setBoilersPage] = useState(1)
const [boilerSearch, setBoilerSearch] = useState("")
const [debouncedBoilerSearch, setDebouncedBoilerSearch] = useState("")
const { boilers } = useBoilers(10, boilersPage, debouncedBoilerSearch)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedBoilerSearch(boilerSearch)
}, 500)
return () => {
clearTimeout(handler)
}
}, [boilerSearch])
useEffect(() => {
setBoilersPage(1)
setBoilerSearch("")
}, [])
const boilersColumns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: "number" },
{ field: 'boiler_name', headerName: 'Название', type: "string", flex: 1 },
{ field: 'boiler_code', headerName: 'Код', type: "string", flex: 1 },
{ field: 'id_city', headerName: 'Город', type: "string", flex: 1 },
{ field: 'activity', headerName: 'Активен', type: "boolean", flex: 1 },
]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%', p: '16px' }}>
<Typography variant='h6' fontWeight='600'>
Котельные
</Typography>
{boilers &&
<DataGrid
rows={boilers.map((boiler: IBoiler) => {
return { ...boiler, id: boiler.id_object }
})}
columns={boilersColumns}
/>
}
</Box>
</Box>
)
}
export default Boilers

View File

@ -0,0 +1,7 @@
import FolderViewer from '../components/FolderViewer'
export default function Documents() {
return (
<FolderViewer />
)
}

15
client/src/pages/Main.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Box, Card, Typography } from "@mui/material";
export default function Main() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', p: '16px' }}>
<Typography variant='h6' fontWeight='700'>
Последние файлы
</Typography>
<Card>
</Card>
</Box>
)
}

View File

@ -0,0 +1,9 @@
import MapComponent from '../components/map/MapComponent'
function MapTest() {
return (
<MapComponent />
)
}
export default MapTest

View File

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { Card, Stack } from '@mui/material';
function CardComponent({
url,
is_alive
}: { url: any, is_alive: any }) {
return (
<Card>
<Stack p='24px' direction='column'>
<p>{url}</p>
<p>{JSON.stringify(is_alive)}</p>
</Stack>
</Card>
)
}
export default function MonitorPage() {
const [servers, setServers] = useState<any>([])
useEffect(() => {
const eventSource = new EventSource(`${import.meta.env.VITE_API_MONITOR_URL}/watch`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setServers(data)
}
eventSource.onerror = (error) => {
console.error('Error with SSE connection:', error)
eventSource.close()
}
return () => {
eventSource.close()
};
}, [])
return (
<div>
<Stack direction='column' spacing={1}>
{servers.length > 0 && servers.map((server: any) => (
<CardComponent url={server.name} is_alive={server.status} />
))}
</Stack>
</div>
)
}

View File

@ -0,0 +1,13 @@
import { Error } from "@mui/icons-material";
import { Box, Typography } from "@mui/material";
export default function NotFound() {
return (
<>
<Box sx={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<Error />
<Typography>Запрашиваемая страница не найдена.</Typography>
</Box>
</>
)
}

View File

@ -0,0 +1,124 @@
import { Fragment, useEffect, useState } from "react"
import { Autocomplete, Box, Button, CircularProgress, IconButton, TextField } from "@mui/material"
import { DataGrid } from "@mui/x-data-grid"
import { useCities, useReport, useReportExport } from "../hooks/swrHooks"
import { useDebounce } from "@uidotdev/usehooks"
import { ICity } from "../interfaces/fuel"
import { Update } from "@mui/icons-material"
import { mutate } from "swr"
export default function Reports() {
const [download, setDownload] = useState(false)
const [search, setSearch] = useState<string | null>("")
const debouncedSearch = useDebounce(search, 500)
const [selectedOption, setSelectedOption] = useState<ICity | null>(null)
const { cities, isLoading } = useCities(10, 1, debouncedSearch)
const { report, isLoading: reportLoading } = useReport(selectedOption?.id)
const { reportExported } = useReportExport(selectedOption?.id, download)
const refreshReport = async () => {
mutate(`/info/reports/${selectedOption?.id}?to_export=false`)
}
useEffect(() => {
if (selectedOption && reportExported && download) {
const url = window.URL.createObjectURL(reportExported)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', 'report.xlsx')
document.body.appendChild(link);
link.click();
link.remove();
setDownload(false)
}
}, [selectedOption, reportExported, download])
const exportReport = async () => {
setDownload(true)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', p: '16px' }}>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Autocomplete
fullWidth
onInputChange={(_, value) => setSearch(value)}
onChange={(_, value) => setSelectedOption(value)}
isOptionEqualToValue={(option: ICity, value: ICity) => option.id === value.id}
getOptionLabel={(option: ICity) => option.name ? option.name : ""}
options={cities || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
size='small'
label="Населенный пункт"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
/>
)}
/>
<IconButton onClick={() => refreshReport()}>
<Update />
</IconButton>
<Button onClick={() => exportReport()}>
Экспорт
</Button>
</Box>
<DataGrid
autoHeight
style={{ width: "100%" }}
loading={reportLoading}
rows={
report ?
[...new Set(Object.keys(report).flatMap(key => Object.keys(report[key])))].map(id => {
const row: any = { id: Number(id) };
Object.keys(report).forEach(key => {
row[key] = report[key][id];
});
return row;
})
:
[]
}
columns={[
{ field: 'id', headerName: '№', width: 70 },
...Object.keys(report).map(key => ({
field: key,
headerName: key.charAt(0).toUpperCase() + key.slice(1),
width: 150
}))
]}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
checkboxSelection={false}
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
)
}

View File

@ -0,0 +1,84 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Modal } from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { useRoles } from '../hooks/swrHooks'
import { CreateField } from '../interfaces/create'
import RoleService from '../services/RoleService'
import FormFields from '../components/FormFields'
export default function Roles() {
const { roles, isError, isLoading } = useRoles()
const [open, setOpen] = useState(false)
const createFields: CreateField[] = [
{ key: 'name', headerName: 'Название', type: 'string', required: true, defaultValue: '' },
{ key: 'description', headerName: 'Описание', type: 'string', required: false, defaultValue: '' },
]
const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: "number" },
{ field: 'name', headerName: 'Название', flex: 1, editable: true },
{ field: 'description', headerName: 'Описание', flex: 1, editable: true },
];
if (isError) return <div>Произошла ошибка при получении данных.</div>
if (isLoading) return <CircularProgress />
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '16px',
flexGrow: 1,
p: '16px'
}}>
<Button onClick={() => setOpen(true)}>
Добавить роль
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
>
<FormFields
sx={{
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
}}
fields={createFields}
submitHandler={RoleService.createRole}
title="Создание роли"
/>
</Modal>
<DataGrid
autoHeight
style={{ width: "100%" }}
rows={roles}
columns={columns}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
)
}

View File

@ -0,0 +1,74 @@
import { Box, Tab, Tabs } from "@mui/material"
import { useState } from "react"
import ServersView from "../components/ServersView"
import ServerIpsView from "../components/ServerIpsView"
import ServerHardware from "../components/ServerHardware"
import ServerStorage from "../components/ServerStorages"
export default function Servers() {
const [currentTab, setCurrentTab] = useState(0)
const handleTabChange = (newValue: number) => {
setCurrentTab(newValue);
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function CustomTabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>{children}</Box>}
</div>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%', p: '16px' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={currentTab} onChange={(_, value) =>
handleTabChange(value)
} aria-label="basic tabs example">
<Tab label="Серверы" />
<Tab label="IP-адреса" />
<Tab label="Hardware" />
<Tab label="Storages" />
</Tabs>
</Box>
<CustomTabPanel value={currentTab} index={0}>
<ServersView />
</CustomTabPanel>
<CustomTabPanel value={currentTab} index={1}>
<ServerIpsView />
</CustomTabPanel>
<CustomTabPanel value={currentTab} index={2}>
<ServerHardware />
</CustomTabPanel>
<CustomTabPanel value={currentTab} index={3}>
<ServerStorage />
</CustomTabPanel>
{/* <BarChart
xAxis={[{ scaleType: 'band', data: ['group A', 'group B', 'group C'] }]}
series={[{ data: [4, 3, 5] }, { data: [1, 6, 3] }, { data: [2, 5, 6] }]}
width={500}
height={300}
/> */}
</Box>
)
}

View File

@ -0,0 +1,75 @@
import { Box, Stack } from "@mui/material"
import UserService from "../services/UserService"
import { setUserData, useAuthStore } from "../store/auth"
import { useEffect, useState } from "react"
import { CreateField } from "../interfaces/create"
import { IUser } from "../interfaces/user"
import FormFields from "../components/FormFields"
import AuthService from "../services/AuthService"
export default function Settings() {
const { token } = useAuthStore()
const [currentUser, setCurrentUser] = useState<IUser>()
const fetchCurrentUser = async () => {
if (token) {
await UserService.getCurrentUser(token).then(response => {
setCurrentUser(response.data)
})
}
}
useEffect(() => {
if (token) {
fetchCurrentUser()
}
}, [token])
const profileFields: CreateField[] = [
//{ key: 'email', headerName: 'E-mail', type: 'string', required: true },
//{ key: 'login', headerName: 'Логин', type: 'string', required: true },
{ key: 'phone', headerName: 'Телефон', type: 'string', required: false },
{ key: 'name', headerName: 'Имя', type: 'string', required: true },
{ key: 'surname', headerName: 'Фамилия', type: 'string', required: true },
]
const passwordFields: CreateField[] = [
{ key: 'password', headerName: 'Новый пароль', type: 'string', required: true, inputType: 'password' },
{ key: 'password_confirm', headerName: 'Подтверждение пароля', type: 'string', required: true, inputType: 'password', watch: 'password', watchMessage: 'Пароли не совпадают', include: false },
]
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "16px",
}}
>
{currentUser &&
<Stack spacing={2} width='100%'>
<Stack width='100%'>
<FormFields
fields={profileFields}
defaultValues={currentUser}
mutateHandler={(data: any) => {
setUserData(data)
}}
submitHandler={(data) => UserService.updateUser({ id: currentUser.id, ...data })}
title="Пользователь"
/>
</Stack>
<Stack width='100%'>
<FormFields
fields={passwordFields}
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
title="Смена пароля"
/>
</Stack>
</Stack>
}
</Box>
)
}

105
client/src/pages/Users.tsx Normal file
View File

@ -0,0 +1,105 @@
import { Box, Button, CircularProgress, Modal } from "@mui/material"
import { DataGrid, GridColDef } from "@mui/x-data-grid"
import { useRoles, useUsers } from "../hooks/swrHooks"
import { IRole } from "../interfaces/role"
import { useState } from "react"
import { CreateField } from "../interfaces/create"
import UserService from "../services/UserService"
import FormFields from "../components/FormFields"
export default function Users() {
const { users, isError, isLoading } = useUsers()
const { roles } = useRoles()
const [open, setOpen] = useState(false)
const createFields: CreateField[] = [
{ key: 'email', headerName: 'E-mail', type: 'string', required: true, defaultValue: '' },
{ key: 'login', headerName: 'Логин', type: 'string', required: true, defaultValue: '' },
{ key: 'phone', headerName: 'Телефон', type: 'string', required: false, defaultValue: '' },
{ key: 'name', headerName: 'Имя', type: 'string', required: true, defaultValue: '' },
{ key: 'surname', headerName: 'Фамилия', type: 'string', required: true, defaultValue: '' },
{ key: 'password', headerName: 'Пароль', type: 'string', required: true, defaultValue: '' },
]
const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', type: "number", flex: 1 },
{ field: 'email', headerName: 'Email', flex: 1, editable: true },
{ field: 'login', headerName: 'Логин', flex: 1, editable: true },
{ field: 'phone', headerName: 'Телефон', flex: 1, editable: true },
{ field: 'name', headerName: 'Имя', flex: 1, editable: true },
{ field: 'surname', headerName: 'Фамилия', flex: 1, editable: true },
{ field: 'is_active', headerName: 'Активен', type: "boolean", flex: 1, editable: true },
{
field: 'role_id',
headerName: 'Роль',
valueOptions: roles ? roles.map((role: IRole) => ({ label: role.name, value: role.id })) : [],
type: 'singleSelect',
flex: 1,
editable: true
},
];
if (isError) return <div>Произошла ошибка при получении данных.</div>
if (isLoading) return <CircularProgress />
return (
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "16px",
p: '16px'
}}
>
<Button onClick={() => setOpen(true)}>
Добавить пользователя
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
>
<FormFields
fields={createFields}
submitHandler={UserService.createUser}
title="Создание пользователя"
sx={{
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
}}
/>
</Modal>
<DataGrid
density="compact"
autoHeight
style={{ width: "100%" }}
rows={users}
columns={columns}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
checkboxSelection
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
)
}

View File

@ -0,0 +1,92 @@
import { Box, Button, CircularProgress, Container, Fade, Grow, Stack, TextField, Typography } from '@mui/material'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form';
import AuthService from '../../services/AuthService';
import { CheckCircle } from '@mui/icons-material';
interface PasswordResetProps {
email: string;
}
function PasswordReset() {
const [success, setSuccess] = useState(false)
const { register, handleSubmit, watch, setError, formState: { errors, isSubmitting } } = useForm<PasswordResetProps>({
defaultValues: {
email: ''
}
})
const onSubmit: SubmitHandler<PasswordResetProps> = async (data) => {
await AuthService.resetPassword(data.email).then(response => {
if (response.status === 200) {
//setError('email', { message: response.data.msg })
setSuccess(true)
} else if (response.status === 422) {
setError('email', { message: response.statusText })
}
}).catch((error: Error) => {
setError('email', { message: error.message })
})
}
return (
<Container maxWidth="sm">
<Box my={4}>
<Typography variant="h4" component="h1" gutterBottom>
Восстановление пароля
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
{!success && <Fade in={!success}>
<Stack spacing={2}>
<Typography>
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
</Typography>
<TextField
fullWidth
margin="normal"
label="E-mail"
required
{...register('email', { required: 'Введите E-mail' })}
error={!!errors.email}
helperText={errors.email?.message}
/>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Button fullWidth type="submit" disabled={isSubmitting || watch('email').length == 0} variant="contained" color="primary">
{isSubmitting ? <CircularProgress size={16} /> : 'Восстановить пароль'}
</Button>
<Button fullWidth href="/auth/signin" type="button" variant="text" color="primary">
Назад
</Button>
</Box>
</Stack>
</Fade>}
{success &&
<Grow in={success}>
<Stack spacing={2}>
<Stack direction='row' alignItems='center' spacing={2}>
<CheckCircle color='success' />
<Typography>
На указанный адрес было отправлено письмо с новыми данными для авторизации.
</Typography>
</Stack>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Button fullWidth href="/auth/signin" type="button" variant="contained" color="primary">
Войти
</Button>
</Box>
</Stack>
</Grow>
}
</form>
</Box>
</Container>
)
}
export default PasswordReset

View File

@ -0,0 +1,101 @@
import { useForm, SubmitHandler } from 'react-hook-form';
import { TextField, Button, Container, Typography, Box, Stack, Link, CircularProgress } from '@mui/material';
import { AxiosResponse } from 'axios';
import { ApiResponse, LoginFormData } from '../../interfaces/auth';
import { login, setUserData } from '../../store/auth';
import { useNavigate } from 'react-router-dom';
import AuthService from '../../services/AuthService';
import UserService from '../../services/UserService';
const SignIn = () => {
const { register, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<LoginFormData>({
defaultValues: {
username: '',
password: '',
grant_type: 'password',
scope: '',
client_id: '',
client_secret: ''
}
})
const navigate = useNavigate();
const onSubmit: SubmitHandler<LoginFormData> = async (data) => {
const formBody = new URLSearchParams();
for (const key in data) {
formBody.append(key, data[key as keyof LoginFormData] as string);
}
try {
const response: AxiosResponse<ApiResponse> = await AuthService.login(formBody)
const token = response.data.access_token
const userDataResponse: AxiosResponse<ApiResponse> = await UserService.getCurrentUser(token)
setUserData(JSON.stringify(userDataResponse.data))
login(token)
navigate('/');
} catch (error: any) {
setError('password', {
message: error?.response?.data?.detail
})
}
};
return (
<Container maxWidth="sm">
<Box my={4}>
<Typography variant="h4" component="h1" gutterBottom>
Вход
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={2}>
<TextField
fullWidth
margin="normal"
label="Логин"
required
{...register('username', { required: 'Введите логин' })}
error={!!errors.username}
helperText={errors.username?.message}
/>
<TextField
fullWidth
margin="normal"
type="password"
label="Пароль"
required
{...register('password', { required: 'Введите пароль' })}
error={!!errors.password}
helperText={errors.password?.message}
/>
<Box sx={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
<Link href="/auth/password-reset" color="primary">
Восстановить пароль
</Link>
</Box>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Button fullWidth type="submit" variant="contained" color="primary">
{isSubmitting ? <CircularProgress size={16} /> : 'Вход'}
</Button>
{/* <Button fullWidth href="/auth/signup" type="button" variant="text" color="primary">
Регистрация
</Button> */}
</Box>
</Stack>
</form>
</Box>
</Container>
);
};
export default SignIn;

View File

@ -0,0 +1,103 @@
import { useForm, SubmitHandler } from 'react-hook-form';
import { TextField, Button, Container, Typography, Box } from '@mui/material';
import UserService from '../../services/UserService';
import { IUser } from '../../interfaces/user';
const SignUp = () => {
const { register, handleSubmit, formState: { errors } } = useForm<IUser>({
defaultValues: {
email: '',
login: '',
phone: '',
name: '',
surname: '',
is_active: true,
password: '',
}
})
const onSubmit: SubmitHandler<IUser> = async (data) => {
try {
await UserService.createUser(data)
} catch (error) {
console.error('Ошибка регистрации:', error);
}
};
return (
<Container maxWidth="sm">
<Box my={4}>
<Typography variant="h4" component="h1" gutterBottom>
Регистрация
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
fullWidth
margin="normal"
label="Email"
required
{...register('email', { required: 'Email обязателен' })}
error={!!errors.email}
helperText={errors.email?.message}
/>
<TextField
fullWidth
margin="normal"
label="Логин"
required
{...register('login', { required: 'Логин обязателен' })}
error={!!errors.login}
helperText={errors.login?.message}
/>
<TextField
fullWidth
margin="normal"
label="Телефон"
{...register('phone')}
error={!!errors.phone}
helperText={errors.phone?.message}
/>
<TextField
fullWidth
margin="normal"
label="Имя"
{...register('name')}
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
fullWidth
margin="normal"
label="Фамилия"
{...register('surname')}
error={!!errors.surname}
helperText={errors.surname?.message}
/>
<TextField
fullWidth
margin="normal"
type="password"
label="Пароль"
required
{...register('password', { required: 'Пароль обязателен' })}
error={!!errors.password}
helperText={errors.password?.message}
/>
<Button type="submit" variant="contained" color="primary">
Зарегистрироваться
</Button>
</form>
</Box>
</Container>
);
};
export default SignUp;

View File

@ -0,0 +1,32 @@
import { AxiosRequestConfig } from "axios";
import { BASE_URL } from "../constants";
import axiosInstance from "../http/axiosInstance";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.auth,
// headers: {
// 'Content-Type': 'application/x-www-form-urlencoded'
// }
}
export default class AuthService {
static async login(data: URLSearchParams) {
return await axiosInstance.post(`/auth/login`, data, config)
}
static async refreshToken(token: string) {
return await axiosInstance.post(`/auth/refresh_token/${token}`, null, config)
}
static async getCurrentUser(token: string) {
return await axiosInstance.get(`/auth/get_current_user/${token}`, config)
}
static async resetPassword(email: string) {
return await axiosInstance.put(`/auth/user/reset_password?email=${email}`, null, config)
}
static async updatePassword(data: { id: number, password: string }) {
return await axiosInstance.put(`/auth/user/password_change`, data, config)
}
}

View File

@ -0,0 +1,247 @@
import { AxiosProgressEvent, AxiosRequestConfig } from "axios";
import axiosInstance from "../http/axiosInstance";
import { IBank, ICompany, IDepartment, IDocument, IDocumentFolder, IOrganization, IOrganizationBank } from "../interfaces/documents";
import { BASE_URL } from "../constants";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.info,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
export default class DocumentService {
// Get Main
static async getMain() {
return await axiosInstance.get(`/info/`, config)
}
// Get Companies
static async getCompanies(limit?: number, offset?: number) {
return await axiosInstance.get(`/info/companies`, {
params: {
limit: limit || 10,
offset: offset || 0
},
...config
})
}
// Create Company
static async createCompany(data: ICompany) {
return await axiosInstance.post(`/info/companies/`, data, config)
}
// Delete Company
static async deleteCompany(company_id: number) {
return await axiosInstance.delete(`/info/companies/${company_id}`, config)
}
// Update Company
static async updateCompany(company_id: number) {
return await axiosInstance.patch(`/info/companies/${company_id}`, config)
}
// Get Departments
static async getDepartments(limit?: number, offset?: number) {
return await axiosInstance.get(`/info/departments/`, {
params: {
limit: limit || 10,
offset: offset || 0
},
...config
})
}
// Get Department
static async getDepartment(department_id: number) {
return await axiosInstance.get(`/info/departments/${department_id}`, config)
}
// Delete Department
static async deleteDepartment(department_id: number) {
return await axiosInstance.delete(`/info/departments/${department_id}`, config)
}
// Update Department
static async updateDepartment(department_id: number, data: IDepartment) {
return await axiosInstance.patch(`/info/departments/${department_id}`, data, config)
}
// Create Department
static async createDepartment(data: IDepartment) {
return await axiosInstance.post(`/info/department/`, data, config)
}
// Get Documents
static async getDocuments(limit?: number, offset?: number) {
return await axiosInstance.get(`/info/document_folder/`, {
params: {
limit: limit || 10,
offset: offset || 0
},
...config
})
}
// Create Documentfolder
static async createDocumentFolder(data: IDocumentFolder) {
return await axiosInstance.post(`/info/document_folder/`, data, config)
}
// Get Document
static async getDocument(folder_id: number) {
return await axiosInstance.get(`/info/document_folder/${folder_id}`, config)
}
// Delete Document
static async deleteDocument(folder_id: number) {
return await axiosInstance.delete(`/info/document_folder/${folder_id}`, config)
}
// Update Document
static async updateDocument(folder_id: number, data: IDocument) {
return await axiosInstance.patch(`/info/document_folder/${folder_id}`, data, config)
}
// Get Docs
static async getDocs(folder_id: number) {
return await axiosInstance.get(`/info/documents/${folder_id}`, config)
}
// Upload Files
static async uploadFiles(folder_id: number, files: FormData, setUploadProgress?: (value: number) => void) {
return await axiosInstance.post(`/info/documents/upload/${folder_id}`, files, {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
const percentCompleted = progressEvent.progress
setUploadProgress?.(percentCompleted || 0)
},
...config
})
}
// Download Doc
static async downloadDoc(folder_id: number, doc_id: number) {
return await axiosInstance.get(`/info/document/${folder_id}&${doc_id}`, {
responseType: 'blob',
...config
})
}
// Delete Doc
static async deleteDoc(folder_id: number, doc_id: number) {
return await axiosInstance.delete(`/info/document/`, {
params: {
folder_id: folder_id,
doc_id: doc_id
},
...config
})
}
// Convert Phones
static async convertPhones(data: FormData) {
return await axiosInstance.post(`/info/other/phones/`, data, config)
}
// Get Budget
static async getBudget() {
return await axiosInstance.get(`/info/organization/budget/`, config)
}
// Add Bank
static async addBank(data: IBank) {
return await axiosInstance.post(`/info/organization/bank`, data, config)
}
// Update Bank
static async updateBank(bank_id: string, bank_1c_id: string, data: IBank) {
return await axiosInstance.patch(`/info/organization/bank`, data, {
params: {
bank_id: bank_id,
bank_1c_id: bank_1c_id
},
...config
})
}
// Get Banks
static async getBanks(bank_id?: string, search?: string, limit?: number, offset?: number) {
return await axiosInstance.get(`/info/organization/banks`, {
params: {
bank_id: bank_id,
search: search || null,
limit: limit || 10,
offset: offset || 0
},
...config
})
}
// Get Bank
static async getBank(id_1c: string) {
return await axiosInstance.get(`/info/organization/bank/${id_1c}`, config)
}
// Delete Bank
static async deleteBank(bank_id: string, bank_1c_id: string) {
return await axiosInstance.get(`/info/organization/bank/`, {
params: {
bank_id: bank_id,
bank_1c_id: bank_1c_id
},
...config
})
}
// Add Org
static async addOrganization(data: IOrganization) {
return await axiosInstance.post(`/info/organization/org/`, data, config)
}
// Update Org
static async updateOrganization(org_id: string, org_1c_id: string, data: IOrganization) {
return await axiosInstance.patch(`/info/organization/org`, data, {
params: {
org_id: org_id,
org_1c_id: org_1c_id
},
...config
})
}
// Delete Org
static async deleteOrganization(org_id: string, org_1c_id: string) {
return await axiosInstance.delete(`/info/organization/org`, {
params: {
org_id: org_id,
org_1c_id: org_1c_id
},
...config
})
}
// Get Orgs
static async getOrganizations(org_id?: string, search?: string, limit?: number, offset?: number) {
return await axiosInstance.get(`/info/organization/orgs`, {
params: {
org_id: org_id,
search: search || null,
limit: limit || 10,
offset: offset || 0
},
...config
})
}
// Get Org
static async getOrganization(id_1c: string) {
return await axiosInstance.get(`/info/organization/org/${id_1c}`, config)
}
// Add Orgbank
static async addOrganizationBank(data: IOrganizationBank) {
return await axiosInstance.post(`/info/organization/org_bank`, data, config)
}
}

View File

@ -0,0 +1,13 @@
import { AxiosRequestConfig } from "axios";
import axiosInstance from "../http/axiosInstance";
import { BASE_URL } from "../constants";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.fuel
}
export default class FuelService {
static async getAddress(limit?: number, page?: number) {
return await axiosInstance.get(`/general/address?limit=${limit || 10}&page=${page || 1}`, config)
}
}

View File

@ -0,0 +1,27 @@
import { AxiosRequestConfig } from "axios";
import axiosInstance from "../http/axiosInstance";
import { IRoleCreate } from "../interfaces/role";
import { BASE_URL } from "../constants";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.auth
}
export default class RoleService {
static async getRoles() {
return await axiosInstance.get(`/auth/roles`, config)
}
static async createRole(data: IRoleCreate) {
return await axiosInstance.post(`/auth/roles/`, data, config)
}
static async getRoleById(id: number) {
return await axiosInstance.get(`/auth/roles/${id}`, config)
}
// static async deleteRole(id: number) {
// return await axiosInstance.delete(`/auth/roles/${id}`)
// }
}

View File

@ -0,0 +1,42 @@
import { AxiosRequestConfig } from "axios";
import axiosInstance from "../http/axiosInstance";
import { IHardware, IServer, IServerIP, IStorage } from "../interfaces/servers";
import { BASE_URL } from "../constants";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.servers
}
export default class ServerService {
static async removeServer(server_id: number) {
return await axiosInstance.delete(`/api/server/${server_id}`, config)
}
static async addServer(data: IServer) {
return await axiosInstance.post(`/api/server/`, data, config)
}
static async removeHardware(hardware_id: number) {
return await axiosInstance.delete(`/api/hardware/${hardware_id}`, config)
}
static async addHardware(data: IHardware) {
return await axiosInstance.post(`/api/hardware`, data, config)
}
static async removeStorage(storage_id: number) {
return await axiosInstance.delete(`/api/storage/${storage_id}`, config)
}
static async addStorage(data: IStorage) {
return await axiosInstance.post(`/api/storage`, data, config)
}
static async addServerIp(data: IServerIP) {
return await axiosInstance.post(`/api/server_ip`, data, config)
}
static async removeServerIp(ip_id: number) {
return await axiosInstance.delete(`/api/server_ip/${ip_id}`, config)
}
}

View File

@ -0,0 +1,39 @@
import { AxiosRequestConfig } from "axios";
import axiosInstance from "../http/axiosInstance";
import { UserCreds, UserData } from "../interfaces/auth";
import { BASE_URL } from "../constants";
import { IUser } from "../interfaces/user";
const config: AxiosRequestConfig = {
baseURL: BASE_URL.auth
}
export default class UserService {
static async createUser(data: IUser) {
return await axiosInstance.post(`/auth/user`, data, config)
}
static async getCurrentUser(token: string) {
return await axiosInstance.get(`/auth/get_current_user/${token}`, config)
}
static async getUsers() {
return await axiosInstance.get(`/auth/user`, config)
}
// static async deleteUser(id: number) {
// return await axiosInstance.delete(`/auth/user/${id}`)
// }
static async getUser(id: number) {
return await axiosInstance.get(`/auth/user/${id}`, config)
}
static async updatePassword(data: UserCreds) {
return await axiosInstance.put(`/auth/user/password_change`, data, config)
}
static async updateUser(data: UserData) {
return await axiosInstance.put(`/auth/user`, data, config)
}
}

82
client/src/store/auth.ts Normal file
View File

@ -0,0 +1,82 @@
import { create } from 'zustand';
import { TOKEN_AUTH_KEY, TOKEN_EXPIRY_DURATION, TOKEN_ISSUED_DATE_KEY, USER_DATA_KEY } from '../constants';
import { AuthState } from '../interfaces/auth';
import AuthService from '../services/AuthService';
export const useAuthStore = create<AuthState>(() => ({
isAuthenticated: false,
token: null,
userData: null,
}));
const login = (token: string) => {
const issuedDate = Date.now();
localStorage.setItem(TOKEN_AUTH_KEY, token);
localStorage.setItem(TOKEN_ISSUED_DATE_KEY, issuedDate.toString());
useAuthStore.setState(() => ({ isAuthenticated: true, token: token }))
}
const logout = () => {
localStorage.removeItem(TOKEN_AUTH_KEY);
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem(TOKEN_ISSUED_DATE_KEY);
useAuthStore.setState(() => ({ isAuthenticated: false, token: null, userData: null }));
}
const initAuth = async () => {
const token = localStorage.getItem(TOKEN_AUTH_KEY);
const issuedDate = parseInt(localStorage.getItem(TOKEN_ISSUED_DATE_KEY) || '0', 10);
const currentTime = Date.now();
if (token && issuedDate) {
if (currentTime - issuedDate < TOKEN_EXPIRY_DURATION) {
useAuthStore.setState(() => ({ isAuthenticated: true, token: token }))
} else {
try {
await refreshToken();
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
}
} else {
logout()
}
}
const refreshToken = async () => {
const token = useAuthStore.getState().token
if (!token) throw new Error('No token to refresh');
try {
const response = await AuthService.refreshToken(token)
const newToken = response.data.access_token;
const newIssuedDate = Date.now();
localStorage.setItem(TOKEN_AUTH_KEY, newToken);
localStorage.setItem(TOKEN_ISSUED_DATE_KEY, newIssuedDate.toString());
useAuthStore.setState(() => ({ token: newToken }))
} catch (error) {
console.error('Failed to refresh token:', error);
logout();
}
}
const getUserData = () => {
const userData = localStorage.getItem(USER_DATA_KEY)
return userData ? JSON.parse(userData) : {}
}
const setUserData = (userData: string) => {
const parsedData = JSON.parse(userData)
localStorage.setItem(USER_DATA_KEY, userData)
useAuthStore.setState(() => ({ userData: parsedData }))
}
export {
getUserData,
setUserData,
refreshToken,
initAuth,
login,
logout,
}

View File

@ -0,0 +1,22 @@
import { create } from 'zustand';
import { PreferencesState } from '../interfaces/preferences';
export const usePrefStore = create<PreferencesState>(() => ({
darkMode: false
}));
const getDarkMode = () => {
const darkMode = localStorage.getItem('darkMode')
usePrefStore.setState(() => ({ darkMode: darkMode?.toLowerCase() === "true" ? true : false }))
return darkMode
}
const setDarkMode = (darkMode: boolean) => {
localStorage.setItem('darkMode', JSON.stringify(darkMode))
usePrefStore.setState(() => ({ darkMode: darkMode }))
}
export {
getDarkMode,
setDarkMode
}

0
client/src/store/user.ts Normal file
View File

View File

@ -1,11 +1,17 @@
{
"compilerOptions": {
"types": [
"vite-plugin-pwa/client"
],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@ -13,13 +19,18 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
nodePolyfills(),
react(),
],
})

View File

@ -0,0 +1,14 @@
// vite.config.ts
import { defineConfig } from "file:///app/node_modules/vite/dist/node/index.js";
import react from "file:///app/node_modules/@vitejs/plugin-react-swc/index.mjs";
import { nodePolyfills } from "file:///app/node_modules/vite-plugin-node-polyfills/dist/index.js";
var vite_config_default = defineConfig({
plugins: [
nodePolyfills(),
react()
]
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvYXBwXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvYXBwL3ZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9hcHAvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xyXG5pbXBvcnQgcmVhY3QgZnJvbSAnQHZpdGVqcy9wbHVnaW4tcmVhY3Qtc3djJ1xyXG5pbXBvcnQgeyBub2RlUG9seWZpbGxzIH0gZnJvbSAndml0ZS1wbHVnaW4tbm9kZS1wb2x5ZmlsbHMnXHJcblxyXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xyXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xyXG4gIHBsdWdpbnM6IFtcclxuICAgIG5vZGVQb2x5ZmlsbHMoKSxcclxuICAgIHJlYWN0KCksXHJcbiAgXSxcclxufSlcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4TCxTQUFTLG9CQUFvQjtBQUMzTixPQUFPLFdBQVc7QUFDbEIsU0FBUyxxQkFBcUI7QUFHOUIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUztBQUFBLElBQ1AsY0FBYztBQUFBLElBQ2QsTUFBTTtBQUFBLEVBQ1I7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=

6020
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

96
docker-compose.yml Normal file
View File

@ -0,0 +1,96 @@
services:
client_app:
container_name: client_app
build:
context: ./client
dockerfile: Dockerfile
ports:
- 5173:5173
restart: always
redis_db:
image: "redis:alpine"
container_name: redis_db
ports:
- ${REDIS_PORT}:${REDIS_PORT}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}" ]
volumes:
- ./redis_data:/data
expose:
- ${REDIS_PORT}:${REDIS_PORT}
restart: unless-stopped
ems:
container_name: ems
build:
context: ./ems
dockerfile: Dockerfile
volumes:
- ./ems/public:/app/public
links:
- redis_db:redis_db
- psql_db:psql_db
depends_on:
- redis_db
- psql_db
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_HOST=${REDIS_HOST}
- REDIS_PORT=${REDIS_PORT}
- EMS_PORT=${EMS_PORT}
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@psql_db:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
ports:
- ${EMS_PORT}:${EMS_PORT}
restart: always
monitor:
container_name: monitor
build:
context: ./monitor
dockerfile: Dockerfile
environment:
- MONITOR_PORT=${MONITOR_PORT}
ports:
- ${MONITOR_PORT}:${MONITOR_PORT}
volumes:
- ./monitor/data:/app/data
restart: always
psql_db:
container_name: psql_db
image: postgres:16.4-alpine
volumes:
- ./psql_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT}
expose:
- ${POSTGRES_PORT}
healthcheck:
test:
['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: always
clickhouse_test:
container_name: clickhouse_test
image: clickhouse/clickhouse-server
environment:
- CLICKHOUSE_DB=${CLICKHOUSE_DB}
- CLICKHOUSE_USER=${CLICKHOUSE_USER}
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=${CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT}
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
ports:
- 8123:8123
- 9000:9000
expose:
- 8123
- 9000

28
ems/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Docker volumes
tile_data
public

24
ems/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:lts-alpine AS base
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV REDIS_HOST=$REDIS_HOST
ENV REDIS_PORT=$REDIS_PORT
ENV REDIS_PASSWORD=$REDIS_PASSWORD
ENV EMS_PORT=$EMS_PORT
ENV DATABASE_URL=$DATABASE_URL
EXPOSE $EMS_PORT
CMD ["npm", "run", "start"]

1
ems/README.md Normal file
View File

@ -0,0 +1 @@
# EMS (ИКС)

2574
ems/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
ems/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "ems",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "npx tsc",
"start": "node dist/index.js",
"dev": "nodemon src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@prisma/client": "^5.19.1",
"axios": "^1.7.4",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-validator": "^7.2.0",
"ioredis": "^5.4.1",
"md5": "^2.3.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"pump": "^3.0.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/body-parser": "^1.19.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/md5": "^2.3.5",
"@types/multer": "^1.4.12",
"@types/node": "^22.4.1",
"@types/pump": "^1.1.3",
"@types/redis": "^4.0.11",
"nodemon": "^3.1.4",
"prisma": "^5.19.1",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

29
ems/prisma/schema.prisma Normal file
View File

@ -0,0 +1,29 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum ShapeType {
CIRCLE
ELLIPSIS
POLYGON
LINE
}
model nodes {
id String @id @default(uuid())
object_id Int?
shape_type ShapeType
shape Json @db.Json
label String @db.Text
}

View File

@ -0,0 +1,12 @@
import { Extent } from "../interfaces/map"
const epsg3857extent = [
-20037508.342789244,
-20037508.342789244,
20037508.342789244,
20037508.342789244
] as Extent
export {
epsg3857extent
}

119
ems/src/index-redis.ts Normal file
View File

@ -0,0 +1,119 @@
import express, { Request, Response } from 'express'
import { Redis } from 'ioredis'
import dotenv from 'dotenv'
import bodyParser from 'body-parser'
import { SatelliteMapsProvider } from './interfaces/map'
const axios = require('axios');
const cors = require('cors')
const redis = new Redis({
port: Number(process.env.REDIS_PORT) || 6379,
host: process.env.REDIS_HOST,
password: process.env.REDIS_PASSWORD,
})
dotenv.config()
const app = express()
const port = process.env.EMS_PORT
// Middleware to parse JSON requests
app.use(bodyParser.json())
const getTileUrl = (provider: string, x: string, y: string, z: string) => {
if (provider === 'google') {
return `https://khms2.google.com/kh/v=984?x=${x}&y=${y}&z=${z}`;
} else if (provider === 'yandex') {
return `https://core-sat.maps.yandex.net/tiles?l=sat&x=${x}&y=${y}&z=${z}&scale=1&lang=ru_RU`;
}
throw new Error('Invalid provider');
}
app.get('/tile/:provider/:z/:x/:y', async (req, res) => {
const { provider, x, y, z } = req.params;
const cacheKey = `${provider}:${z}:${x}:${y}`;
try {
// Check if tile is in cache
redis.get(cacheKey, async (err, cachedTile) => {
if (err) {
console.error('Redis GET error:', err);
return res.status(500).send('Server error');
}
if (cachedTile) {
// If cached, return tile
console.log('Tile served from cache');
const imgBuffer = Buffer.from(cachedTile, 'base64');
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': imgBuffer.length,
});
return res.end(imgBuffer);
} else {
// Fetch tile from provider
const tileUrl = getTileUrl(provider, x, y, z);
const response = await axios.get(tileUrl, {
responseType: 'arraybuffer',
});
// Cache the tile in Redis
const base64Tile = Buffer.from(response.data).toString('base64');
redis.setex(cacheKey, 3600 * 24 * 30, base64Tile); // Cache for 1 hour
// Return the tile to the client
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': response.data.length,
});
return res.end(response.data);
}
});
} catch (error) {
console.error('Error fetching tile:', error);
res.status(500).send('Error fetching tile');
}
})
app.get('/hello', cors(), (req: Request, res: Response) => {
res.send('Hello, World!')
})
// Route to store GeoJSON data
app.post('/geojson', cors(), async (req: Request, res: Response) => {
const geoJSON = req.body
if (!geoJSON || !geoJSON.features) {
return res.status(400).send('Invalid GeoJSON')
}
const id = `geojson:${Date.now()}`;
redis.set(id, JSON.stringify(geoJSON), (err, reply) => {
if (err) {
return res.status(500).send('Error saving GeoJSON to Redis');
}
res.send({ status: 'success', id });
})
})
// Route to fetch GeoJSON data
app.get('/geojson/:id', cors(), async (req: Request, res: Response) => {
const id = req.params.id;
redis.get(id, (err, data) => {
if (err) {
return res.status(500).send('Error fetching GeoJSON from Redis');
}
if (data) {
res.send(JSON.parse(data));
} else {
res.status(404).send('GeoJSON not found');
}
})
})
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
})

159
ems/src/index.ts Normal file
View File

@ -0,0 +1,159 @@
import express, { Request, Response } from 'express'
import { PrismaClient } from '@prisma/client'
import fs from 'fs'
import path from 'path'
import axios from 'axios'
import multer from 'multer'
import bodyParser from 'body-parser'
import cors from 'cors'
import { Coordinate } from './interfaces/map'
import { generateTilesForZoomLevel } from './utils/tiles'
import { query, validationResult } from 'express-validator'
const prisma = new PrismaClient()
const app = express()
const PORT = process.env.EMS_PORT || 5000
const tileFolder = path.join(__dirname, '..', 'public', 'tile_data')
const uploadDir = path.join(__dirname, '..', 'public', 'temp')
app.use(cors())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, '..', 'public', 'temp'))
},
filename: function (req, file, cb) {
cb(null, Date.now() + path.extname(file.originalname))
}
})
const upload = multer({ storage: storage })
app.get('/nodes/all', async (req: Request, res: Response) => {
try {
const nodes = await prisma.nodes.findMany()
res.json(nodes)
} catch (error) {
console.error('Error getting node:', error);
res.status(500).json({ error: 'Failed to get node' });
}
})
app.get('/nodes', query('id').isString().isUUID(), async (req: Request, res: Response) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
return res.send({ errors: result.array() })
}
const { id } = req.params
const node = await prisma.nodes.findFirst({
where: {
id: id
}
})
res.json(node)
} catch (error) {
console.error('Error getting node:', error);
res.status(500).json({ error: 'Failed to get node' });
}
})
app.post('/nodes', async (req: Request, res: Response) => {
try {
const { coordinates, object_id, type } = req.body;
// Convert the incoming array of coordinates into the shape structure
const shape = coordinates.map((point: number[]) => ({
object_id: object_id || null,
x: point[0],
y: point[1]
}));
console.log(shape)
// Create a new node in the database
const node = await prisma.nodes.create({
data: {
object_id: object_id || null, // Nullable if object_id is not provided
shape_type: type, // You can adjust this dynamically
shape: shape, // Store the shape array as Json[]
label: 'Default'
}
});
res.status(201).json(node);
} catch (error) {
console.error('Error creating node:', error);
res.status(500).json({ error: 'Failed to create node' });
}
})
app.post('/upload', upload.single('file'), async (req: Request, res: Response) => {
const { extentMinX, extentMinY, extentMaxX, extentMaxY, blX, blY, tlX, tlY, trX, trY, brX, brY } = req.body
const bottomLeft: Coordinate = { x: blX, y: blY }
const topLeft: Coordinate = { x: tlX, y: tlY }
const topRight: Coordinate = { x: trX, y: trY }
const bottomRight: Coordinate = { x: brX, y: brY }
if (req.file) {
for (let z = 0; z <= 21; z++) {
await generateTilesForZoomLevel(uploadDir, tileFolder, req.file, [extentMinX, extentMinY, extentMaxX, extentMaxY], bottomLeft, topLeft, topRight, bottomRight, z)
}
}
return res.status(200)
})
const fetchTileFromAPI = async (provider: string, z: string, x: string, y: string): Promise<Buffer> => {
const url = provider === 'google'
? `https://khms2.google.com/kh/v=984?x=${x}&y=${y}&z=${z}`
: `https://core-sat.maps.yandex.net/tiles?l=sat&x=${x}&y=${y}&z=${z}&scale=1&lang=ru_RU`
const response = await axios.get(url, { responseType: 'arraybuffer' })
return response.data
}
app.get('/tile/:provider/:z/:x/:y', async (req: Request, res: Response) => {
const { provider, z, x, y } = req.params
if (!['google', 'yandex', 'custom'].includes(provider)) {
return res.status(400).send('Invalid provider')
}
const tilePath = provider === 'custom' ? path.join(tileFolder, provider, z, x, `${y}.png`) : path.join(tileFolder, provider, z, x, `${y}.jpg`)
if (fs.existsSync(tilePath)) {
return res.sendFile(tilePath)
} else {
if (provider !== 'custom') {
try {
const tileData = await fetchTileFromAPI(provider, z, x, y)
fs.mkdirSync(path.dirname(tilePath), { recursive: true })
fs.writeFileSync(tilePath, tileData)
res.contentType('image/jpeg')
res.send(tileData)
} catch (error) {
console.error('Error fetching tile from API:', error)
res.status(500).send('Error fetching tile from API')
}
} else {
res.status(404).send('Tile is not generated or not provided')
}
}
});
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

12
ems/src/interfaces/map.ts Normal file
View File

@ -0,0 +1,12 @@
export interface SatelliteMapsProviders {
google: 'google';
yandex: 'yandex';
}
export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders]
export interface Coordinate {
x: number,
y: number
}
export type Extent = [number, number, number, number]

167
ems/src/utils/tiles.ts Normal file
View File

@ -0,0 +1,167 @@
import sharp from "sharp"
import { epsg3857extent } from "../constants"
import { Coordinate, Extent } from "../interfaces/map"
import path from "path"
import fs from 'fs'
function getTilesPerSide(zoom: number) {
return Math.pow(2, zoom)
}
function normalize(value: number, min: number, max: number) {
return (value - min) / (max - min)
}
function getTileIndex(normalized: number, tilesPerSide: number) {
return Math.floor(normalized * tilesPerSide)
}
function getGridCellPosition(x: number, y: number, extent: Extent, zoom: number) {
const tilesPerSide = getTilesPerSide(zoom)
const minX = extent[0]
const minY = extent[1]
const maxX = extent[2]
const maxY = extent[3]
const xNormalized = normalize(x, minX, maxX)
const yNormalized = normalize(y, minY, maxY)
const tileX = getTileIndex(xNormalized, tilesPerSide)
const tileY = getTileIndex(1 - yNormalized, tilesPerSide)
return { tileX, tileY }
}
function calculateRotationAngle(bottomLeft: Coordinate, bottomRight: Coordinate) {
const deltaX = bottomRight.x - bottomLeft.x
const deltaY = bottomRight.y - bottomLeft.y
const angle = -Math.atan2(deltaY, deltaX)
return angle
}
function roundUpToNearest(number: number, mod: number) {
return Math.floor(number / mod) * mod
}
export async function generateTilesForZoomLevel(uploadDir: string, tileFolder: string, file: Express.Multer.File, polygonExtent: Extent, bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate, zoomLevel: number) {
const angleDegrees = calculateRotationAngle(bottomLeft, bottomRight) * 180 / Math.PI
const { tileX: blX, tileY: blY } = getGridCellPosition(bottomLeft.x, bottomLeft.y, epsg3857extent, zoomLevel)
const { tileX: tlX, tileY: tlY } = getGridCellPosition(topLeft.x, topLeft.y, epsg3857extent, zoomLevel)
const { tileX: trX, tileY: trY } = getGridCellPosition(topRight.x, topRight.y, epsg3857extent, zoomLevel)
const { tileX: brX, tileY: brY } = getGridCellPosition(bottomRight.x, topRight.y, epsg3857extent, zoomLevel)
const minX = Math.min(blX, tlX, trX, brX)
const maxX = Math.max(blX, tlX, trX, brX)
const minY = Math.min(blY, tlY, trY, brY)
const maxY = Math.max(blY, tlY, trY, brY)
const mapWidth = Math.abs(epsg3857extent[0] - epsg3857extent[2])
const mapHeight = Math.abs(epsg3857extent[1] - epsg3857extent[3])
const tilesH = Math.sqrt(Math.pow(4, zoomLevel))
const tileWidth = mapWidth / (Math.sqrt(Math.pow(4, zoomLevel)))
const tileHeight = mapHeight / (Math.sqrt(Math.pow(4, zoomLevel)))
let minPosX = minX - (tilesH / 2)
let maxPosX = maxX - (tilesH / 2) + 1
let minPosY = -(minY - (tilesH / 2))
let maxPosY = -(maxY - (tilesH / 2) + 1)
const newMinX = tileWidth * minPosX
const newMaxX = tileWidth * maxPosX
const newMinY = tileHeight * maxPosY
const newMaxY = tileHeight * minPosY
const paddingLeft = Math.abs(polygonExtent[0] - newMinX)
const paddingRight = Math.abs(polygonExtent[2] - newMaxX)
const paddingTop = Math.abs(polygonExtent[3] - newMaxY)
const paddingBottom = Math.abs(polygonExtent[1] - newMinY)
const pixelWidth = Math.abs(minX - (maxX + 1)) * 256
const pixelHeight = Math.abs(minY - (maxY + 1)) * 256
const width = Math.abs(newMinX - newMaxX)
try {
let perPixel = width / pixelWidth
// constraint to original image width
const imageMetadata = await sharp(file.path).metadata().then(res => {
if (res.width) {
perPixel = pixelWidth <= res.width ? perPixel : width / res.width
}
})
const paddingLeftPixel = paddingLeft / perPixel
const paddingRightPixel = paddingRight / perPixel
const paddingTopPixel = paddingTop / perPixel
const paddingBottomPixel = paddingBottom / perPixel
const boundsWidthPixel = Math.abs(polygonExtent[0] - polygonExtent[2]) / perPixel
const boundsHeightPixel = Math.abs(polygonExtent[1] - polygonExtent[3]) / perPixel
if (!fs.existsSync(path.join(tileFolder, 'custom', zoomLevel.toString()))) {
fs.mkdirSync(path.join(tileFolder, 'custom', zoomLevel.toString()), { recursive: true });
}
const initialZoomImage = await sharp(path.join(uploadDir, file.filename))
.rotate(Math.ceil(angleDegrees), {
background: '#00000000'
})
.resize({
width: Math.ceil(boundsWidthPixel),
height: Math.ceil(boundsHeightPixel),
background: '#00000000'
})
.extend({
top: Math.ceil(paddingTopPixel),
left: Math.ceil(paddingLeftPixel),
bottom: Math.ceil(paddingBottomPixel),
right: Math.ceil(paddingRightPixel),
background: '#00000000'
})
.toFormat('png')
.toBuffer({ resolveWithObject: true })
if (initialZoomImage) {
await sharp(initialZoomImage.data.buffer)
.resize({
width: roundUpToNearest(Math.ceil(boundsWidthPixel) + Math.ceil(paddingLeftPixel) + Math.ceil(paddingRightPixel), Math.abs(minX - (maxX + 1))),
height: roundUpToNearest(Math.ceil(boundsHeightPixel) + Math.ceil(paddingTopPixel) + Math.ceil(paddingBottomPixel), Math.abs(minY - (maxY + 1))),
})
.toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png'))
.then(async (res) => {
let left = 0
for (let x = minX; x <= maxX; x++) {
if (!fs.existsSync(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString()))) {
fs.mkdirSync(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString()), { recursive: true });
}
let top = 0
for (let y = minY; y <= maxY; y++) {
console.log(`z: ${zoomLevel} x: ${x} y: ${y}`)
try {
await sharp(path.join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png'))
.extract({
width: res.width / Math.abs(minX - (maxX + 1)),
height: res.height / Math.abs(minY - (maxY + 1)),
left: left,
top: top
})
.toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString(), y.toString() + '.png'))
.then(() => {
top = top + res.height / Math.abs(minY - (maxY + 1))
})
} catch (error) {
console.log(error)
}
}
left = left + res.width / Math.abs(minX - (maxX + 1))
}
})
}
} catch (error) {
console.log(error)
}
}

Some files were not shown because too many files have changed in this diff Show More