Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
b88d83cd74 | |||
108dc5082c | |||
33f41aaab0 | |||
8c8c619143 | |||
ddacbcd837 | |||
3994989994 | |||
ab88fd5ea5 | |||
579bbf7764 | |||
97b44a4db7 | |||
61339f4c26 | |||
8d68119ded | |||
00af65ecdb | |||
3a090bf1ad | |||
878f206189 | |||
748aa81a99 | |||
1e802b4550 | |||
a1a5c2b3a6 | |||
a3b0b1b222 | |||
424217a895 | |||
0ac0534486 | |||
e1f9dc762c | |||
153806f392 | |||
ae2213b188 | |||
748cf89b35 | |||
492fbd7d89 | |||
e3af090119 | |||
53e9a8cadf | |||
a3043afa7b | |||
ca2d97f975 | |||
cf3fda43e4 | |||
4283bd20bb | |||
e566e23f6d | |||
416e2e39b5 | |||
f9de1124c3 | |||
a65a431b09 | |||
6f4aa1903d | |||
c74c911eea | |||
d298de0a72 | |||
3727fcabb3 | |||
261196afef | |||
2c71e4f6af | |||
704276037c | |||
e70d94afec | |||
7ba886e966 | |||
af1d497715 | |||
c41e59cd86 | |||
18fb120777 | |||
85f97e9e0e | |||
c2688855c3 | |||
62695acf74 | |||
d6906503d1 |
14
.env.example
Normal file
14
.env.example
Normal 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
2
.gitignore
vendored
@ -2,3 +2,5 @@
|
|||||||
.vscode
|
.vscode
|
||||||
__pycache__
|
__pycache__
|
||||||
.env
|
.env
|
||||||
|
redis_data
|
||||||
|
psql_data
|
14
client/.env.example
Normal file
14
client/.env.example
Normal 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
15
client/Dockerfile
Normal 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
18
client/README.md
Normal 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
BIN
client/bun.lockb
Normal file
Binary file not shown.
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
11055
client/package-lock.json
generated
Normal file
11055
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
client/package.json
Normal file
60
client/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
41
client/public/sakha_republic.geojson
Normal file
41
client/public/sakha_republic.geojson
Normal file
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
210
client/src/App.tsx
Normal file
210
client/src/App.tsx
Normal 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
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
156
client/src/components/AccountMenu.tsx
Normal file
156
client/src/components/AccountMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
23
client/src/components/CardInfo/CardInfo.tsx
Normal file
23
client/src/components/CardInfo/CardInfo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
25
client/src/components/CardInfo/CardInfoChip.tsx
Normal file
25
client/src/components/CardInfo/CardInfoChip.tsx
Normal 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"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal file
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
21
client/src/components/FetchingData.ts
Normal file
21
client/src/components/FetchingData.ts
Normal 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;
|
344
client/src/components/FolderViewer.tsx
Normal file
344
client/src/components/FolderViewer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
100
client/src/components/FormFields.tsx
Normal file
100
client/src/components/FormFields.tsx
Normal 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
|
39
client/src/components/ServerData.tsx
Normal file
39
client/src/components/ServerData.tsx
Normal 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
|
125
client/src/components/ServerHardware.tsx
Normal file
125
client/src/components/ServerHardware.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
121
client/src/components/ServerIpsView.tsx
Normal file
121
client/src/components/ServerIpsView.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
122
client/src/components/ServerStorages.tsx
Normal file
122
client/src/components/ServerStorages.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
177
client/src/components/ServersView.tsx
Normal file
177
client/src/components/ServersView.tsx
Normal 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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
233
client/src/components/TableEditable.tsx
Normal file
233
client/src/components/TableEditable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
18
client/src/components/UserData.ts
Normal file
18
client/src/components/UserData.ts
Normal 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
|
||||||
|
}
|
58
client/src/components/map/ChunkedUpload.tsx
Normal file
58
client/src/components/map/ChunkedUpload.tsx
Normal 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;
|
984
client/src/components/map/MapComponent.tsx
Normal file
984
client/src/components/map/MapComponent.tsx
Normal 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
|
9
client/src/components/map/MapConstants.ts
Normal file
9
client/src/components/map/MapConstants.ts
Normal 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
|
||||||
|
}
|
32
client/src/components/map/MapSources.ts
Normal file
32
client/src/components/map/MapSources.ts
Normal 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
|
||||||
|
}
|
39
client/src/components/map/MapStyles.ts
Normal file
39
client/src/components/map/MapStyles.ts
Normal 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
|
||||||
|
}
|
127
client/src/components/map/mapUtils.ts
Normal file
127
client/src/components/map/mapUtils.ts
Normal 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
|
||||||
|
}
|
268
client/src/components/modals/FileViewer.tsx
Normal file
268
client/src/components/modals/FileViewer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
171
client/src/components/navigation/Drawer/ResponsiveDrawer.tsx
Normal file
171
client/src/components/navigation/Drawer/ResponsiveDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
30
client/src/components/navigation/NavTabs.tsx
Normal file
30
client/src/components/navigation/NavTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
12
client/src/constants/index.ts
Normal file
12
client/src/constants/index.ts
Normal 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,
|
||||||
|
}
|
302
client/src/hooks/swrHooks.ts
Normal file
302
client/src/hooks/swrHooks.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
27
client/src/http/axiosInstance.ts
Normal file
27
client/src/http/axiosInstance.ts
Normal 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;
|
39
client/src/interfaces/auth.ts
Normal file
39
client/src/interfaces/auth.ts
Normal 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;
|
||||||
|
}
|
35
client/src/interfaces/create.ts
Normal file
35
client/src/interfaces/create.ts
Normal 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;
|
||||||
|
}
|
65
client/src/interfaces/documents.ts
Normal file
65
client/src/interfaces/documents.ts
Normal 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;
|
||||||
|
}
|
18
client/src/interfaces/fuel.ts
Normal file
18
client/src/interfaces/fuel.ts
Normal 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;
|
||||||
|
}
|
6
client/src/interfaces/map.ts
Normal file
6
client/src/interfaces/map.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface SatelliteMapsProviders {
|
||||||
|
google: 'google';
|
||||||
|
yandex: 'yandex';
|
||||||
|
custom: 'custom';
|
||||||
|
}
|
||||||
|
export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders]
|
3
client/src/interfaces/preferences.ts
Normal file
3
client/src/interfaces/preferences.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export interface PreferencesState {
|
||||||
|
darkMode: boolean;
|
||||||
|
}
|
10
client/src/interfaces/role.ts
Normal file
10
client/src/interfaces/role.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface IRole {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRoleCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
33
client/src/interfaces/servers.ts
Normal file
33
client/src/interfaces/servers.ts
Normal 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;
|
||||||
|
}
|
10
client/src/interfaces/user.ts
Normal file
10
client/src/interfaces/user.ts
Normal 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;
|
||||||
|
}
|
211
client/src/layouts/DashboardLayout.tsx
Normal file
211
client/src/layouts/DashboardLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
252
client/src/layouts/DashboardLayoutResponsive.tsx
Normal file
252
client/src/layouts/DashboardLayoutResponsive.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
31
client/src/layouts/MainLayout.tsx
Normal file
31
client/src/layouts/MainLayout.tsx
Normal 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
98
client/src/main.tsx
Normal 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>,
|
||||||
|
)
|
51
client/src/pages/ApiTest.tsx
Normal file
51
client/src/pages/ApiTest.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
57
client/src/pages/Boilers.tsx
Normal file
57
client/src/pages/Boilers.tsx
Normal 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
|
7
client/src/pages/Documents.tsx
Normal file
7
client/src/pages/Documents.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import FolderViewer from '../components/FolderViewer'
|
||||||
|
|
||||||
|
export default function Documents() {
|
||||||
|
return (
|
||||||
|
<FolderViewer />
|
||||||
|
)
|
||||||
|
}
|
15
client/src/pages/Main.tsx
Normal file
15
client/src/pages/Main.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
9
client/src/pages/MapTest.tsx
Normal file
9
client/src/pages/MapTest.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import MapComponent from '../components/map/MapComponent'
|
||||||
|
|
||||||
|
function MapTest() {
|
||||||
|
return (
|
||||||
|
<MapComponent />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapTest
|
48
client/src/pages/MonitorPage.tsx
Normal file
48
client/src/pages/MonitorPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
13
client/src/pages/NotFound.tsx
Normal file
13
client/src/pages/NotFound.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
124
client/src/pages/Reports.tsx
Normal file
124
client/src/pages/Reports.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
84
client/src/pages/Roles.tsx
Normal file
84
client/src/pages/Roles.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
74
client/src/pages/Servers.tsx
Normal file
74
client/src/pages/Servers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
75
client/src/pages/Settings.tsx
Normal file
75
client/src/pages/Settings.tsx
Normal 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
105
client/src/pages/Users.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
92
client/src/pages/auth/PasswordReset.tsx
Normal file
92
client/src/pages/auth/PasswordReset.tsx
Normal 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
|
101
client/src/pages/auth/SignIn.tsx
Normal file
101
client/src/pages/auth/SignIn.tsx
Normal 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;
|
103
client/src/pages/auth/SignUp.tsx
Normal file
103
client/src/pages/auth/SignUp.tsx
Normal 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;
|
32
client/src/services/AuthService.ts
Normal file
32
client/src/services/AuthService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
247
client/src/services/DocumentService.ts
Normal file
247
client/src/services/DocumentService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
13
client/src/services/FuelService.ts
Normal file
13
client/src/services/FuelService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
27
client/src/services/RoleService.ts
Normal file
27
client/src/services/RoleService.ts
Normal 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}`)
|
||||||
|
// }
|
||||||
|
}
|
42
client/src/services/ServersService.ts
Normal file
42
client/src/services/ServersService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
39
client/src/services/UserService.ts
Normal file
39
client/src/services/UserService.ts
Normal 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
82
client/src/store/auth.ts
Normal 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,
|
||||||
|
}
|
22
client/src/store/preferences.ts
Normal file
22
client/src/store/preferences.ts
Normal 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
0
client/src/store/user.ts
Normal file
@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"vite-plugin-pwa/client"
|
||||||
|
],
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
@ -13,13 +19,18 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": [
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
1
client/tsconfig.node.tsbuildinfo
Normal file
1
client/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@ -1,7 +1,11 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
nodePolyfills(),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
})
|
})
|
@ -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
6020
client/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal 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
28
ems/.gitignore
vendored
Normal 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
24
ems/Dockerfile
Normal 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
1
ems/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# EMS (ИКС)
|
2574
ems/package-lock.json
generated
Normal file
2574
ems/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
ems/package.json
Normal file
44
ems/package.json
Normal 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
29
ems/prisma/schema.prisma
Normal 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
|
||||||
|
}
|
12
ems/src/constants/index.ts
Normal file
12
ems/src/constants/index.ts
Normal 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
119
ems/src/index-redis.ts
Normal 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
159
ems/src/index.ts
Normal 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
12
ems/src/interfaces/map.ts
Normal 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
167
ems/src/utils/tiles.ts
Normal 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
Reference in New Issue
Block a user