Rename; Added EMS server; redis compose

This commit is contained in:
cracklesparkle
2024-08-20 17:34:21 +09:00
parent 61339f4c26
commit 97b44a4db7
85 changed files with 2832 additions and 188 deletions

4
client/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_API_AUTH_URL=
VITE_API_INFO_URL=
VITE_API_FUEL_URL=
VITE_API_SERVERS_URL=

18
client/.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# 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?

15
client/Dockerfile Normal file
View File

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

18
client/README.md Normal file
View File

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

BIN
client/bun.lockb Normal file

Binary file not shown.

13
client/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<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>Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

11059
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
client/package.json Normal file
View File

@ -0,0 +1,57 @@
{
"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",
"@uidotdev/usehooks": "^2.4.1",
"autoprefixer": "^10.4.19",
"axios": "^1.7.2",
"buffer": "^6.0.3",
"elysia-vite": "^0.2.0",
"file-type": "^19.0.0",
"ol": "^10.0.0",
"postcss": "^8.4.38",
"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/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"
}
}

6
client/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
client/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@ -0,0 +1,177 @@
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, Password, People, Settings as SettingsIcon, Shield, Storage } from "@mui/icons-material"
import Settings from "./pages/Settings"
import PasswordReset from "./pages/auth/PasswordReset"
import MapTest from "./pages/MapTest"
// Определение страниц с путями и компонентом для рендера
export const pages = [
{
label: "",
path: "/auth/signin",
icon: <Login />,
component: <SignIn />,
drawer: false,
dashboard: false,
},
{
label: "",
path: "/auth/signup",
icon: <Login />,
component: <SignUp />,
drawer: false,
dashboard: false,
},
{
label: "",
path: "/auth/password-reset",
icon: <Password />,
component: <PasswordReset />,
drawer: false,
dashboard: false,
},
{
label: "Настройки",
path: "/settings",
icon: <SettingsIcon />,
component: <Settings />,
drawer: false,
dashboard: true,
},
{
label: "Главная",
path: "/",
icon: <Home />,
component: <Main />,
drawer: true,
dashboard: true
},
{
label: "Пользователи",
path: "/user",
icon: <People />,
component: <Users />,
drawer: true,
dashboard: true
},
{
label: "Роли",
path: "/role",
icon: <Shield />,
component: <Roles />,
drawer: true,
dashboard: true
},
{
label: "Документы",
path: "/documents",
icon: <Storage />,
component: <Documents />,
drawer: true,
dashboard: true
},
{
label: "Отчеты",
path: "/reports",
icon: <Assignment />,
component: <Reports />,
drawer: true,
dashboard: true
},
{
label: "Серверы",
path: "/servers",
icon: <Cloud />,
component: <Servers />,
drawer: true,
dashboard: true
},
{
label: "Котельные",
path: "/boilers",
icon: <Factory />,
component: <Boilers />,
drawer: true,
dashboard: true
},
{
label: "API Test",
path: "/api-test",
icon: <Api />,
component: <ApiTest />,
drawer: true,
dashboard: true
},
{
label: "Карта",
path: "/map-test",
icon: <Map />,
component: <MapTest />,
drawer: true,
dashboard: true
},
]
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).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).map((page, index) => (
<Route key={`dl-${index}`} path={page.path} element={page.component} />
))}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</Box>
)
}
}
export default App

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,343 @@
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'
}}>
<FileViewer
open={fileViewerModal}
setOpen={setFileViewerModal}
currentFileNo={currentFileNo}
setCurrentFileNo={setCurrentFileNo}
docs={documents}
/>
<Breadcrumbs>
<Link
underline='hover'
color='inherit'
onClick={() => {
setCurrentFolder(null)
setBreadcrumbs([])
}}
sx={{ cursor: 'pointer' }}
>
Главная
</Link>
{breadcrumbs.map((breadcrumb, index) => (
<Link
key={breadcrumb.id}
underline="hover"
color="inherit"
onClick={() => handleBreadcrumbClick(index)}
sx={{ cursor: 'pointer' }}
>
{breadcrumb.name}
</Link>
))}
</Breadcrumbs>
{currentFolder &&
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
border: filesToUpload.length > 0 ? '1px dashed gray' : 'none',
borderRadius: '8px',
p: '16px'
}}>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Button
LinkComponent="label"
role={undefined}
variant="outlined"
tabIndex={-1}
startIcon={
isUploading ? <CircularProgress sx={{ maxHeight: "20px", maxWidth: "20px" }} variant="determinate" value={uploadProgress} /> : <UploadFile />
}
onClick={handleUploadClick}
>
<input
type='file'
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInput}
onClick={(e) => {
if (e.currentTarget) {
e.currentTarget.value = ''
}
}}
/>
Добавить
</Button>
{filesToUpload.length > 0 &&
<>
<Button
variant="contained"
color="primary"
startIcon={<Upload />}
onClick={uploadFiles}
>
Загрузить все
</Button>
<Button
variant='outlined'
startIcon={<Cancel />}
onClick={() => {
setFilesToUpload([])
}}
>
Отмена
</Button>
</>
}
</Box>
<Divider />
{filesToUpload.length > 0 &&
<Box>
{filesToUpload.map((file, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
<Box>
<InsertDriveFile />
<span>{file.name}</span>
</Box>
<IconButton sx={{ ml: 'auto' }} onClick={() => {
setFilesToUpload(prev => {
return prev.filter((_, i) => i != index)
})
}}>
<Close />
</IconButton>
</Box>
))}
</Box>
}
</Box>
</Box>
}
<List
dense
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
backgroundColor: dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'
}}
>
{currentFolder ? (
documents?.map((doc: IDocument, index: number) => (
<div key={`${doc.id}-${doc.name}`}>
<ItemDocument
doc={doc}
index={index}
handleDocumentClick={handleDocumentClick}
/>
{index < documents.length - 1 && <Divider />}
</div>
))
) : (
folders?.map((folder: IDocumentFolder, index: number) => (
<div key={`${folder.id}-${folder.name}`}>
<ItemFolder
folder={folder}
index={index}
handleFolderClick={handleFolderClick}
/>
{index < folders.length - 1 && <Divider />}
</div>
))
)}
</List>
</Box>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,175 @@
import { 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, Snap } from 'ol/interaction'
import { OSM, Vector as VectorSource } from 'ol/source'
import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'
import { transform, transformExtent } from 'ol/proj'
import { Divider, IconButton, Stack } from '@mui/material'
import { Adjust, Api, CircleOutlined, RectangleOutlined, Timeline, Undo, Warning } from '@mui/icons-material'
import { Type } from 'ol/geom/Geometry'
const MapComponent = () => {
const mapElement = useRef<HTMLDivElement | null>(null)
const [currentTool, setCurrentTool] = useState<Type>('Point')
const map = useRef<Map | null>(null)
const source = useRef<VectorSource>(new VectorSource())
const draw = useRef<Draw | null>(null)
const snap = useRef<Snap | null>(null)
const drawingLayer = useRef<VectorLayer | null>(null)
const addInteractions = () => {
draw.current = new Draw({
source: source.current,
type: currentTool,
})
map?.current?.addInteraction(draw.current)
snap.current = new Snap({ source: source.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)
}
}
// 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
})
source.current?.addFeatures(features) // Add features to the vector source
//drawingLayer.current?.getSource()?.changed()
}
}
useEffect(() => {
const geoLayer = new VectorLayer({
background: '#1a2b39',
source: new VectorSource({
url: 'https://openlayers.org/data/vector/ecoregions.json',
format: new GeoJSON(),
}),
style: {
'fill-color': ['string', ['get', 'COLOR'], '#eee'],
},
})
const raster = new TileLayer({
source: new OSM(),
})
drawingLayer.current = new VectorLayer({
source: source.current,
style: {
'fill-color': 'rgba(255, 255, 255, 0.2)',
'stroke-color': '#ffcc33',
'stroke-width': 2,
'circle-radius': 7,
'circle-fill-color': '#ffcc33',
},
})
// Center coordinates of Yakutia in EPSG:3857
const center = transform([129.7694, 66.9419], 'EPSG:4326', 'EPSG:3857')
// Extent for Yakutia in EPSG:4326
const extent4326 = [105.0, 55.0, 170.0, 75.0] // Approximate bounding box
// Transform extent to EPSG:3857
const extent = transformExtent(extent4326, 'EPSG:4326', 'EPSG:3857')
map.current = new Map({
layers: [geoLayer, raster, drawingLayer.current],
target: mapElement.current as HTMLDivElement,
view: new View({
center,
zoom: 4,
extent,
}),
})
const modify = new Modify({ source: source.current })
map.current.addInteraction(modify)
addInteractions()
loadFeatures()
return () => {
map?.current?.setTarget(undefined)
}
}, [])
useEffect(() => {
if (currentTool) {
if (draw.current) map?.current?.removeInteraction(draw.current)
if (snap.current) map?.current?.removeInteraction(snap.current)
addInteractions()
}
}, [currentTool])
return (
<div>
<Stack my={1} spacing={1} direction='row' divider={<Divider orientation='vertical' 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={() => setCurrentTool('Point')}>
<Adjust />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'LineString' ? 'Highlight' : 'transparent' }}
onClick={() => setCurrentTool('LineString')}>
<Timeline />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Polygon' ? 'Highlight' : 'transparent' }}
onClick={() => setCurrentTool('Polygon')}>
<RectangleOutlined />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Circle' ? 'Highlight' : 'transparent' }}
onClick={() => setCurrentTool('Circle')}>
<CircleOutlined />
</IconButton>
</Stack>
<div ref={mapElement} style={{ width: '100%', height: '400px' }}></div>
</div>
);
};
export default MapComponent

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
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
}

View File

@ -0,0 +1,334 @@
import useSWR 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";
export function useRoles() {
const { data, error, isLoading } = useSWR(`/auth/roles`, RoleService.getRoles)
return {
roles: data?.data,
isLoading,
isError: error
}
}
export function useUsers() {
const { data, error, isLoading } = useSWR(`/auth/user`, UserService.getUsers)
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)
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,
{
revalidateOnFocus: false
}
)
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,
{
revalidateOnFocus: false
}
)
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,
{
revalidateOnFocus: false
}
)
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,
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false
}
)
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'),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false
}
)
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),
{
revalidateOnFocus: false,
}
)
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),
{
revalidateOnFocus: false
}
)
return {
storage: data,
isLoading,
isError: error
}
}

View File

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

3
client/src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,217 @@
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 Container from '@mui/material/Container';
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).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 />
<Container
maxWidth="lg"
sx={{ mt: 4, mb: 4 }}
>
<Outlet />
</Container>
</Box>
</Box>
</ThemeProvider>
);
}

View File

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

View File

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

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

@ -0,0 +1,83 @@
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: {
MuiListItemButton: {
defaultProps: {
disableRipple: true
}
},
MuiButton: {
defaultProps: {
disableRipple: true
}
},
MuiButtonBase: {
defaultProps: {
disableRipple: true,
}
},
MuiButtonGroup: {
defaultProps: {
disableRipple: true,
}
}
},
}
)
const darkTheme = createTheme(
{
...mainTheme,
palette: {
mode: "dark",
primary: { main: '#1976d2' },
},
},
ruRU,
);
const lightTheme = createTheme(
{
...mainTheme,
palette: {
mode: "light",
primary: { main: '#1976d2' },
},
},
ruRU,
);
function ThemedApp() {
const prefStore = usePrefStore()
useEffect(() => {
getDarkMode()
}, [])
return (
<ThemeProvider theme={prefStore.darkMode ? darkTheme : lightTheme}>
<App />
</ThemeProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ThemedApp />
</React.StrictMode>,
)

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,126 @@
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' }}>
<Box sx={{ display: 'flex', gap: '16px' }}>
<Autocomplete
fullWidth
onInputChange={(_, value) => setSearch(value)}
onChange={(_, value) => setSelectedOption(value)}
isOptionEqualToValue={(option: ICity, value: ICity) => option.id === value.id}
getOptionLabel={(option: ICity) => option.name ? option.name : ""}
options={cities || []}
loading={isLoading}
value={selectedOption}
renderInput={(params) => (
<TextField
{...params}
size='small'
label="Населенный пункт"
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
/>
)}
/>
<IconButton onClick={() => refreshReport()}>
<Update />
</IconButton>
<Button onClick={() => exportReport()}>
Экспорт
</Button>
</Box>
<DataGrid
autoHeight
style={{ width: "100%" }}
loading={reportLoading}
rows={
report ?
[...new Set(Object.keys(report).flatMap(key => Object.keys(report[key])))].map(id => {
const row: any = { id: Number(id) };
Object.keys(report).forEach(key => {
row[key] = report[key][id];
});
return row;
})
:
[]
}
columns={[
{ field: 'id', headerName: '№', width: 70 },
...Object.keys(report).map(key => ({
field: key,
headerName: key.charAt(0).toUpperCase() + key.slice(1),
width: 150
}))
]}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
checkboxSelection={false}
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
</>
)
}

View File

@ -0,0 +1,83 @@
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", width: 70 },
{ field: 'name', headerName: 'Название', width: 90, editable: true },
{ field: 'description', headerName: 'Описание', width: 90, 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
}}>
<Button onClick={() => setOpen(true)}>
Добавить роль
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
>
<FormFields
sx={{
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
}}
fields={createFields}
submitHandler={RoleService.createRole}
title="Создание роли"
/>
</Modal>
<DataGrid
autoHeight
style={{ width: "100%" }}
rows={roles}
columns={columns}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
)
}

View File

@ -0,0 +1,76 @@
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%' }}>
<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>
</Box>
)
}

View File

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

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

@ -0,0 +1,103 @@
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", width: 70 },
{ field: 'email', headerName: 'Email', width: 130, editable: true },
{ field: 'login', headerName: 'Логин', width: 130, editable: true },
{ field: 'phone', headerName: 'Телефон', width: 90, editable: true },
{ field: 'name', headerName: 'Имя', width: 90, editable: true },
{ field: 'surname', headerName: 'Фамилия', width: 90, editable: true },
{ field: 'is_active', headerName: 'Активен', type: "boolean", width: 90, editable: true },
{
field: 'role_id',
headerName: 'Роль',
valueOptions: roles ? roles.map((role: IRole) => ({ label: role.name, value: role.id })) : [],
type: 'singleSelect',
width: 90,
editable: true
},
];
if (isError) return <div>Произошла ошибка при получении данных.</div>
if (isLoading) return <CircularProgress />
return (
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "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
autoHeight
style={{ width: "100%" }}
rows={users}
columns={columns}
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[10, 20, 50, 100]}
checkboxSelection
disableRowSelectionOnClick
processRowUpdate={(updatedRow) => {
return updatedRow
}}
onProcessRowUpdateError={() => {
}}
/>
</Box>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

1
client/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

10
client/tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["index.html",
"./src/**/*.{html,js,tsx,jsx,ts}"],
theme: {
extend: {},
},
plugins: [],
}

36
client/tsconfig.json Normal file
View File

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

11
client/tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

11
client/vite.config.ts Normal file
View File

@ -0,0 +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: [
nodePolyfills(),
react(),
],
})

6008
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff