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
|
||||
__pycache__
|
||||
.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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
11055
client/package-lock.json
generated
Normal file
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": {
|
||||
"types": [
|
||||
"vite-plugin-pwa/client"
|
||||
],
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
@ -13,13 +19,18 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
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 react from '@vitejs/plugin-react-swc'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
nodePolyfills(),
|
||||
react(),
|
||||
],
|
||||
})
|
@ -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