forked from VinokurovVE/tests
Rename; Added EMS server; redis compose
This commit is contained in:
4
client/.env.example
Normal file
4
client/.env.example
Normal 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
18
client/.eslintrc.cjs
Normal 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
24
client/.gitignore
vendored
Normal 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
15
client/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "serve"]
|
18
client/README.md
Normal file
18
client/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Experimental Frontend
|
||||
|
||||
## Структура проекта
|
||||
- `src/assets/` - Статические ассеты
|
||||
- `src/components/` - Компоненты
|
||||
- `src/constants/` - Константы
|
||||
- `src/layouts/` - Макеты для разных частей, пока есть MainLayout, используемый всеми роутами
|
||||
- `src/pages/` - Страницы
|
||||
- `src/services/` - сервисы / API
|
||||
|
||||
## UI
|
||||
|
||||
В основном, используется Material UI https://mui.com/material-ui
|
||||
Для кастомных компонентов следует создать директорию в `src/components/НазваниеКомпонента` со стилями, если необходимо
|
||||
|
||||
## Env vars
|
||||
|
||||
`.env.example` должен описывать используемые переменные, в работе же используется `.env.local` или `.env`
|
BIN
client/bun.lockb
Normal file
BIN
client/bun.lockb
Normal file
Binary file not shown.
13
client/index.html
Normal file
13
client/index.html
Normal 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
11059
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
client/package.json
Normal file
57
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal 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
177
client/src/App.tsx
Normal 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
|
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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 |
156
client/src/components/AccountMenu.tsx
Normal file
156
client/src/components/AccountMenu.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import Logout from '@mui/icons-material/Logout';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logout } from '../store/auth';
|
||||
import { ListItemText, Switch, styled } from '@mui/material';
|
||||
import { setDarkMode, usePrefStore } from '../store/preferences';
|
||||
|
||||
const Android12Switch = styled(Switch)(({ theme }) => ({
|
||||
padding: 8,
|
||||
'& .MuiSwitch-track': {
|
||||
borderRadius: 22 / 2,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'&::before': {
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 24 24"><path fill="${encodeURIComponent(
|
||||
theme.palette.getContrastText(theme.palette.primary.main),
|
||||
)}" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/></svg>')`,
|
||||
left: 12,
|
||||
},
|
||||
'&::after': {
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 24 24"><path fill="${encodeURIComponent(
|
||||
theme.palette.getContrastText(theme.palette.primary.main),
|
||||
)}" d="M19,13H5V11H19V13Z" /></svg>')`,
|
||||
right: 12,
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
boxShadow: 'none',
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function AccountMenu() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const prefStore = usePrefStore()
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', textAlign: 'center' }}>
|
||||
<Tooltip title="Account settings">
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
aria-controls={open ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }}></Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
id="account-menu"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<Android12Switch
|
||||
checked={prefStore.darkMode}
|
||||
onChange={(e) => {
|
||||
setDarkMode(e.target.checked)
|
||||
}} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
Тема: {prefStore.darkMode ? "темная" : "светлая"}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
|
||||
<MenuItem onClick={() => {
|
||||
navigate('/settings')
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<Settings fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Настройки
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate("/auth/signin")
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Logout fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Выход
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
23
client/src/components/CardInfo/CardInfo.tsx
Normal file
23
client/src/components/CardInfo/CardInfo.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Divider, Paper, Typography } from '@mui/material'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface CardInfoProps extends PropsWithChildren {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function CardInfo({
|
||||
children,
|
||||
label
|
||||
}: CardInfoProps) {
|
||||
return (
|
||||
<Paper sx={{ display: 'flex', flexDirection: 'column', gap: '16px', p: '16px' }}>
|
||||
<Typography fontWeight={600}>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
<Divider />
|
||||
|
||||
{children}
|
||||
</Paper>
|
||||
)
|
||||
}
|
25
client/src/components/CardInfo/CardInfoChip.tsx
Normal file
25
client/src/components/CardInfo/CardInfoChip.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Chip } from '@mui/material'
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
interface CardInfoChipProps {
|
||||
status: boolean;
|
||||
label: string;
|
||||
iconOn: ReactElement
|
||||
iconOff: ReactElement
|
||||
}
|
||||
|
||||
export default function CardInfoChip({
|
||||
status,
|
||||
label,
|
||||
iconOn,
|
||||
iconOff
|
||||
}: CardInfoChipProps) {
|
||||
return (
|
||||
<Chip
|
||||
icon={status ? iconOn : iconOff}
|
||||
variant="outlined"
|
||||
label={label}
|
||||
color={status ? "success" : "error"}
|
||||
/>
|
||||
)
|
||||
}
|
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal file
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
interface CardInfoLabelProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export default function CardInfoLabel({
|
||||
label,
|
||||
value
|
||||
}: CardInfoLabelProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
21
client/src/components/FetchingData.ts
Normal file
21
client/src/components/FetchingData.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import axiosInstance from '../http/axiosInstance'
|
||||
export function useDataFetching<T>(url: string, initData: T): T {
|
||||
const [data, setData] = useState<T>(initData)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const response = await axiosInstance.get(url)
|
||||
const result = await response.data
|
||||
setData(result)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [url])
|
||||
|
||||
// Memoize the data value
|
||||
const memoizedData = useMemo<T>(() => data, [data])
|
||||
return memoizedData
|
||||
}
|
||||
|
||||
export default useDataFetching;
|
343
client/src/components/FolderViewer.tsx
Normal file
343
client/src/components/FolderViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
100
client/src/components/FormFields.tsx
Normal file
100
client/src/components/FormFields.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { CreateField } from '../interfaces/create'
|
||||
import { Box, Button, CircularProgress, Stack, SxProps, TextField, Typography } from '@mui/material';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
submitHandler?: (data: any) => Promise<AxiosResponse<any, any>>;
|
||||
fields: CreateField[];
|
||||
submitButtonText?: string;
|
||||
mutateHandler?: any;
|
||||
defaultValues?: {};
|
||||
watchValues?: string[];
|
||||
sx?: SxProps | null;
|
||||
}
|
||||
|
||||
function FormFields({
|
||||
title = '',
|
||||
submitHandler,
|
||||
fields,
|
||||
submitButtonText = 'Сохранить',
|
||||
mutateHandler,
|
||||
defaultValues,
|
||||
sx
|
||||
}: Props) {
|
||||
const getDefaultValues = (fields: CreateField[]) => {
|
||||
let result: { [key: string]: string | boolean } = {}
|
||||
fields.forEach((field: CreateField) => {
|
||||
result[field.key] = field.defaultValue || defaultValues?.[field.key as keyof {}]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting, dirtyFields, isValid } } = useForm({
|
||||
mode: 'onChange',
|
||||
defaultValues: defaultValues ? getDefaultValues(fields) : {}
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<any> = async (data) => {
|
||||
fields.forEach((field: CreateField) => {
|
||||
if (field.include === false) {
|
||||
delete data[field.key]
|
||||
}
|
||||
})
|
||||
try {
|
||||
const submitResponse = await submitHandler?.(data)
|
||||
mutateHandler?.(JSON.stringify(submitResponse?.data))
|
||||
reset(submitResponse?.data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack sx={sx} spacing={2} width='100%'>
|
||||
<Typography variant="h6" component="h6" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{fields.map((field: CreateField) => {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
margin='normal'
|
||||
key={field.key}
|
||||
type={field.inputType ? field.inputType : 'text'}
|
||||
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
|
||||
required={field.required || false}
|
||||
{...register(field.key, {
|
||||
required: field.required ? `${field.headerName} обязателен` : false,
|
||||
validate: (val: string | boolean) => {
|
||||
if (field.watch) {
|
||||
if (watch(field.watch) != val) {
|
||||
return field.watchMessage || ''
|
||||
}
|
||||
}
|
||||
},
|
||||
})}
|
||||
error={!!errors[field.key]}
|
||||
helperText={errors[field.key]?.message}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px"
|
||||
}}>
|
||||
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type="submit" variant="contained" color="primary">
|
||||
{isSubmitting ? <CircularProgress size={16} /> : submitButtonText}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormFields
|
39
client/src/components/ServerData.tsx
Normal file
39
client/src/components/ServerData.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { IServer } from '../interfaces/servers'
|
||||
import { useServerIps } from '../hooks/swrHooks'
|
||||
import FullFeaturedCrudGrid from './TableEditable'
|
||||
import { GridColDef } from '@mui/x-data-grid'
|
||||
|
||||
function ServerData({ id }: IServer) {
|
||||
const { serverIps } = useServerIps(id, 0, 10)
|
||||
|
||||
const serverIpsColumns: GridColDef[] = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
|
||||
{ field: 'ip', headerName: 'IP', type: 'string' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', p: '16px' }}>
|
||||
{serverIps &&
|
||||
<FullFeaturedCrudGrid
|
||||
initialRows={serverIps}
|
||||
columns={serverIpsColumns}
|
||||
actions
|
||||
onRowClick={() => {
|
||||
//setCurrentServerData(params.row)
|
||||
//setServerDataOpen(true)
|
||||
}}
|
||||
onSave={undefined}
|
||||
onDelete={undefined}
|
||||
loading={false}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerData
|
125
client/src/components/ServerHardware.tsx
Normal file
125
client/src/components/ServerHardware.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useHardwares, useServers } from '../hooks/swrHooks'
|
||||
import FullFeaturedCrudGrid from './TableEditable'
|
||||
import ServerService from '../services/ServersService'
|
||||
import { GridColDef } from '@mui/x-data-grid'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import ServerData from './ServerData'
|
||||
|
||||
export default function ServerHardware() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
|
||||
const { servers, isLoading } = useServers()
|
||||
|
||||
const [serverDataOpen, setServerDataOpen] = useState(false)
|
||||
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const handleOptionChange = (value: IRegion | null) => {
|
||||
setSelectedOption(value)
|
||||
}
|
||||
|
||||
const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption?.id, 0, 10)
|
||||
|
||||
const hardwareColumns: GridColDef[] = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
{ field: 'os_info', headerName: 'ОС', type: 'string' },
|
||||
{ field: 'ram', headerName: 'ОЗУ', type: 'string' },
|
||||
{ field: 'processor', headerName: 'Проц.', type: 'string' },
|
||||
{ field: 'storages_count', headerName: 'Кол-во хранилищ', type: 'number' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={serverDataOpen}
|
||||
onClose={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description">
|
||||
<AppBar sx={{ position: 'sticky' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-label="close"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{currentServerData &&
|
||||
<ServerData
|
||||
id={currentServerData?.id}
|
||||
region_id={currentServerData?.region_id}
|
||||
name={currentServerData?.name}
|
||||
/>
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
{serversLoading ?
|
||||
<CircularProgress />
|
||||
:
|
||||
<FullFeaturedCrudGrid
|
||||
autoComplete={
|
||||
<Autocomplete
|
||||
open={open}
|
||||
onOpen={() => {
|
||||
setOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
onInputChange={(_, value) => handleInputChange(value)}
|
||||
onChange={(_, value) => handleOptionChange(value)}
|
||||
filterOptions={(x) => x}
|
||||
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
|
||||
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
|
||||
options={servers || []}
|
||||
loading={isLoading}
|
||||
value={selectedOption}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Сервер"
|
||||
size='small'
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
}} />
|
||||
)} />}
|
||||
onSave={() => {
|
||||
}}
|
||||
onDelete={ServerService.removeServer}
|
||||
initialRows={hardwares || []}
|
||||
columns={hardwareColumns}
|
||||
actions
|
||||
onRowClick={(params) => {
|
||||
setCurrentServerData(params.row)
|
||||
setServerDataOpen(true)
|
||||
}}
|
||||
loading={false}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
121
client/src/components/ServerIpsView.tsx
Normal file
121
client/src/components/ServerIpsView.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useServerIps, useServers } from '../hooks/swrHooks'
|
||||
import FullFeaturedCrudGrid from './TableEditable'
|
||||
import ServerService from '../services/ServersService'
|
||||
import { GridColDef } from '@mui/x-data-grid'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import ServerData from './ServerData'
|
||||
|
||||
export default function ServerIpsView() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
|
||||
const { servers, isLoading } = useServers()
|
||||
|
||||
const [serverDataOpen, setServerDataOpen] = useState(false)
|
||||
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const handleOptionChange = (value: IRegion | null) => {
|
||||
setSelectedOption(value)
|
||||
}
|
||||
|
||||
const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption?.id, 0, 10)
|
||||
|
||||
const serverIpsColumns: GridColDef[] = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
|
||||
{ field: 'ip', headerName: 'IP', type: 'string' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={serverDataOpen}
|
||||
onClose={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description">
|
||||
<AppBar sx={{ position: 'sticky' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-label="close"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{currentServerData &&
|
||||
<ServerData
|
||||
id={currentServerData?.id}
|
||||
region_id={currentServerData?.region_id}
|
||||
name={currentServerData?.name}
|
||||
/>
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
{serversLoading ?
|
||||
<CircularProgress />
|
||||
:
|
||||
<FullFeaturedCrudGrid
|
||||
autoComplete={
|
||||
<Autocomplete
|
||||
open={open}
|
||||
onOpen={() => {
|
||||
setOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
onInputChange={(_, value) => handleInputChange(value)}
|
||||
onChange={(_, value) => handleOptionChange(value)}
|
||||
filterOptions={(x) => x}
|
||||
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
|
||||
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
|
||||
options={servers || []}
|
||||
loading={isLoading}
|
||||
value={selectedOption}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size='small'
|
||||
label="Сервер"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
}} />
|
||||
)} />}
|
||||
onSave={() => {
|
||||
}}
|
||||
onDelete={ServerService.removeServer}
|
||||
initialRows={serverIps || []}
|
||||
columns={serverIpsColumns}
|
||||
actions
|
||||
onRowClick={(params) => {
|
||||
setCurrentServerData(params.row)
|
||||
setServerDataOpen(true)
|
||||
}} loading={false} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
122
client/src/components/ServerStorages.tsx
Normal file
122
client/src/components/ServerStorages.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { AppBar, Autocomplete, CircularProgress, Dialog, IconButton, TextField, Toolbar } from '@mui/material'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useHardwares, useStorages } from '../hooks/swrHooks'
|
||||
import FullFeaturedCrudGrid from './TableEditable'
|
||||
import ServerService from '../services/ServersService'
|
||||
import { GridColDef } from '@mui/x-data-grid'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import ServerData from './ServerData'
|
||||
|
||||
export default function ServerStorage() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
|
||||
const { hardwares, isLoading } = useHardwares()
|
||||
|
||||
const [serverDataOpen, setServerDataOpen] = useState(false)
|
||||
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const handleOptionChange = (value: IRegion | null) => {
|
||||
setSelectedOption(value)
|
||||
}
|
||||
|
||||
const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10)
|
||||
|
||||
const storageColumns: GridColDef[] = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'hardware_id', headerName: 'Hardware ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'size', headerName: 'Размер', type: 'string' },
|
||||
{ field: 'storage_type', headerName: 'Тип хранилища', type: 'string' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={serverDataOpen}
|
||||
onClose={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description">
|
||||
<AppBar sx={{ position: 'sticky' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setServerDataOpen(false)
|
||||
}}
|
||||
aria-label="close"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{currentServerData &&
|
||||
<ServerData
|
||||
id={currentServerData?.id}
|
||||
region_id={currentServerData?.region_id}
|
||||
name={currentServerData?.name}
|
||||
/>
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
{serversLoading ?
|
||||
<CircularProgress />
|
||||
:
|
||||
<FullFeaturedCrudGrid
|
||||
autoComplete={
|
||||
<Autocomplete
|
||||
open={open}
|
||||
onOpen={() => {
|
||||
setOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
onInputChange={(_, value) => handleInputChange(value)}
|
||||
onChange={(_, value) => handleOptionChange(value)}
|
||||
filterOptions={(x) => x}
|
||||
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
|
||||
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
|
||||
options={hardwares || []}
|
||||
loading={isLoading}
|
||||
value={selectedOption}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size='small'
|
||||
label="Hardware"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
}} />
|
||||
)} />}
|
||||
onSave={() => {
|
||||
}}
|
||||
onDelete={ServerService.removeServer}
|
||||
initialRows={storages || []}
|
||||
columns={storageColumns}
|
||||
actions
|
||||
onRowClick={(params) => {
|
||||
setCurrentServerData(params.row)
|
||||
setServerDataOpen(true)
|
||||
}}
|
||||
loading={false}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
177
client/src/components/ServersView.tsx
Normal file
177
client/src/components/ServersView.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { AppBar, Autocomplete, Box, CircularProgress, Dialog, Grid, IconButton, TextField, Toolbar } from '@mui/material'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useRegions, useServers, useServersInfo } from '../hooks/swrHooks'
|
||||
import FullFeaturedCrudGrid from './TableEditable'
|
||||
import ServerService from '../services/ServersService'
|
||||
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
|
||||
import { Close, Cloud, CloudOff } from '@mui/icons-material'
|
||||
import ServerData from './ServerData'
|
||||
import { IServersInfo } from '../interfaces/servers'
|
||||
import CardInfo from './CardInfo/CardInfo'
|
||||
import CardInfoLabel from './CardInfo/CardInfoLabel'
|
||||
import CardInfoChip from './CardInfo/CardInfoChip'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
|
||||
export default function ServersView() {
|
||||
const [search, setSearch] = useState<string | null>("")
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
|
||||
|
||||
const { regions, isLoading } = useRegions(10, 1, debouncedSearch)
|
||||
|
||||
const { serversInfo } = useServersInfo(selectedOption?.id)
|
||||
|
||||
const [serverDataOpen, setServerDataOpen] = useState(false)
|
||||
const [currentServerData, setCurrentServerData] = useState<any | null>(null)
|
||||
|
||||
const { servers, isLoading: serversLoading } = useServers(selectedOption?.id, 0, 10)
|
||||
|
||||
const serversColumns: GridColDef[] = [
|
||||
//{ field: 'id', headerName: 'ID', type: "number" },
|
||||
{
|
||||
field: 'name', headerName: 'Название', type: "string", editable: true,
|
||||
},
|
||||
{
|
||||
field: 'region_id',
|
||||
editable: true,
|
||||
renderCell: (params) => (
|
||||
<div>
|
||||
{params.value}
|
||||
</div>
|
||||
),
|
||||
renderEditCell: (params: GridRenderCellParams) => (
|
||||
<Autocomplete
|
||||
sx={{ display: 'flex', flexGrow: '1' }}
|
||||
onInputChange={(_, value) => setSearch(value)}
|
||||
onChange={(_, value) => {
|
||||
params.value = value
|
||||
}}
|
||||
isOptionEqualToValue={(option: IRegion, value: IRegion) => option.name === value.name}
|
||||
getOptionLabel={(option: IRegion) => option.name ? option.name : ""}
|
||||
options={regions || []}
|
||||
loading={isLoading}
|
||||
value={params.value}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size='small'
|
||||
variant='standard'
|
||||
label="Район"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{isLoading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
233
client/src/components/TableEditable.tsx
Normal file
233
client/src/components/TableEditable.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Close';
|
||||
import {
|
||||
GridRowsProp,
|
||||
GridRowModesModel,
|
||||
GridRowModes,
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridToolbarContainer,
|
||||
GridActionsCellItem,
|
||||
GridEventListener,
|
||||
GridRowId,
|
||||
GridRowModel,
|
||||
GridRowEditStopReasons,
|
||||
GridSlots,
|
||||
} from '@mui/x-data-grid';
|
||||
|
||||
interface EditToolbarProps {
|
||||
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
|
||||
setRowModesModel: (
|
||||
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
|
||||
) => void;
|
||||
columns: GridColDef[];
|
||||
autoComplete?: React.ReactElement | null;
|
||||
}
|
||||
|
||||
function EditToolbar(props: EditToolbarProps) {
|
||||
const { setRows, setRowModesModel, columns, autoComplete } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
const id = Date.now().toString(36)
|
||||
const newValues: any = {};
|
||||
|
||||
columns.forEach(column => {
|
||||
if (column.type === 'number') {
|
||||
newValues[column.field] = 0
|
||||
} else if (column.type === 'string') {
|
||||
newValues[column.field] = ''
|
||||
} else if (column.type === 'boolean') {
|
||||
newValues[column.field] = false
|
||||
} else {
|
||||
newValues[column.field] = undefined
|
||||
}
|
||||
|
||||
if (column.field === 'region_id') {
|
||||
// column.valueGetter = (value: any) => {
|
||||
// console.log(value)
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
setRows((oldRows) => [...oldRows, { id, ...newValues, isNew: true }]);
|
||||
setRowModesModel((oldModel) => ({
|
||||
...oldModel,
|
||||
[id]: { mode: GridRowModes.Edit, fieldToFocus: columns[0].field },
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<GridToolbarContainer sx={{ px: '16px', py: '16px' }}>
|
||||
{autoComplete &&
|
||||
<Box sx={{ flexGrow: '1' }}>
|
||||
{autoComplete}
|
||||
</Box>
|
||||
}
|
||||
|
||||
<Button color="primary" startIcon={<AddIcon />} onClick={handleClick}>
|
||||
Добавить
|
||||
</Button>
|
||||
</GridToolbarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataGridProps {
|
||||
initialRows: GridRowsProp;
|
||||
columns: GridColDef[];
|
||||
actions: boolean;
|
||||
onRowClick: GridEventListener<"rowClick">;
|
||||
onSave: any;
|
||||
onDelete: any;
|
||||
autoComplete?: React.ReactElement | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function FullFeaturedCrudGrid({
|
||||
initialRows,
|
||||
columns,
|
||||
actions = false,
|
||||
onRowClick,
|
||||
onSave,
|
||||
onDelete,
|
||||
autoComplete,
|
||||
loading
|
||||
}: DataGridProps) {
|
||||
const [rows, setRows] = useState(initialRows);
|
||||
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
|
||||
|
||||
const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => {
|
||||
if (params.reason === GridRowEditStopReasons.rowFocusOut) {
|
||||
event.defaultMuiPrevented = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (id: GridRowId) => () => {
|
||||
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
|
||||
};
|
||||
|
||||
const handleSaveClick = (id: GridRowId) => () => {
|
||||
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
|
||||
onSave?.(id)
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: GridRowId) => () => {
|
||||
setRows(rows.filter((row) => row.id !== id));
|
||||
onDelete?.(id)
|
||||
};
|
||||
|
||||
const handleCancelClick = (id: GridRowId) => () => {
|
||||
setRowModesModel({
|
||||
...rowModesModel,
|
||||
[id]: { mode: GridRowModes.View, ignoreModifications: true },
|
||||
});
|
||||
|
||||
const editedRow = rows.find((row) => row.id === id);
|
||||
if (editedRow!.isNew) {
|
||||
setRows(rows.filter((row) => row.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const processRowUpdate = (newRow: GridRowModel) => {
|
||||
const updatedRow = { ...newRow, isNew: false };
|
||||
setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
|
||||
return updatedRow;
|
||||
};
|
||||
|
||||
const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
|
||||
setRowModesModel(newRowModesModel);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRows) {
|
||||
setRows(initialRows)
|
||||
}
|
||||
}, [initialRows])
|
||||
|
||||
const actionColumns: GridColDef[] = [
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Действия',
|
||||
width: 100,
|
||||
cellClassName: 'actions',
|
||||
getActions: ({ id }) => {
|
||||
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
|
||||
|
||||
if (isInEditMode) {
|
||||
return [
|
||||
<GridActionsCellItem
|
||||
icon={<SaveIcon />}
|
||||
label="Save"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
}}
|
||||
onClick={handleSaveClick(id)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<CancelIcon />}
|
||||
label="Cancel"
|
||||
className="textPrimary"
|
||||
onClick={handleCancelClick(id)}
|
||||
color="inherit"
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
className="textPrimary"
|
||||
onClick={handleEditClick(id)}
|
||||
color="inherit"
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={handleDeleteClick(id)}
|
||||
color="inherit"
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 500,
|
||||
width: '100%',
|
||||
'& .actions': {
|
||||
color: 'text.secondary',
|
||||
},
|
||||
'& .textPrimary': {
|
||||
color: 'text.primary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
loading={loading}
|
||||
rows={rows || []}
|
||||
columns={actions ? [...columns, ...actionColumns] : columns}
|
||||
editMode="row"
|
||||
rowModesModel={rowModesModel}
|
||||
//onRowClick={onRowClick}
|
||||
onRowModesModelChange={handleRowModesModelChange}
|
||||
onRowEditStop={handleRowEditStop}
|
||||
processRowUpdate={processRowUpdate}
|
||||
slots={{
|
||||
toolbar: EditToolbar as GridSlots['toolbar'],
|
||||
}}
|
||||
slotProps={{
|
||||
toolbar: { setRows, setRowModesModel, columns, autoComplete },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
18
client/src/components/UserData.ts
Normal file
18
client/src/components/UserData.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import UserService from "../services/UserService";
|
||||
|
||||
export default function useUserData<T>(token: string, initData: T): T {
|
||||
const [userData, setUserData] = useState<T>(initData)
|
||||
|
||||
useEffect(()=> {
|
||||
const fetchUserData = async (token: string) => {
|
||||
const response = await UserService.getCurrentUser(token)
|
||||
setUserData(response.data)
|
||||
}
|
||||
|
||||
fetchUserData(token)
|
||||
}, [token])
|
||||
|
||||
const memoizedData = useMemo<T>(() => userData, [userData])
|
||||
return memoizedData
|
||||
}
|
175
client/src/components/map/MapComponent.tsx
Normal file
175
client/src/components/map/MapComponent.tsx
Normal 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
|
268
client/src/components/modals/FileViewer.tsx
Normal file
268
client/src/components/modals/FileViewer.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AppBar, Box, Button, CircularProgress, Dialog, IconButton, Toolbar, Typography } from '@mui/material';
|
||||
import { ChevronLeft, ChevronRight, Close, Warning } from '@mui/icons-material';
|
||||
import { useDownload, useFileType } from '../../hooks/swrHooks';
|
||||
|
||||
import jsPreviewExcel from "@js-preview/excel"
|
||||
import '@js-preview/excel/lib/index.css'
|
||||
|
||||
import jsPreviewDocx from "@js-preview/docx"
|
||||
import '@js-preview/docx/lib/index.css'
|
||||
|
||||
import jsPreviewPdf from '@js-preview/pdf'
|
||||
import { IDocument } from '../../interfaces/documents';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (state: boolean) => void;
|
||||
docs: IDocument[];
|
||||
currentFileNo: number;
|
||||
setCurrentFileNo: (state: number) => void;
|
||||
}
|
||||
|
||||
interface ViewerProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
function PdfViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
const previewContainerRef = useRef(null)
|
||||
|
||||
const pdfPreviewer = jsPreviewPdf
|
||||
|
||||
useEffect(() => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
pdfPreviewer.init(previewContainerRef.current)
|
||||
.preview(url)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
previewContainerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [previewContainerRef])
|
||||
|
||||
return (
|
||||
<Box ref={previewContainerRef} sx={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
function DocxViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
const previewContainerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
jsPreviewDocx.init(previewContainerRef.current, {
|
||||
breakPages: true,
|
||||
inWrapper: true,
|
||||
ignoreHeight: true,
|
||||
})
|
||||
.preview(url)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
previewContainerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box ref={previewContainerRef} sx={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
function ExcelViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
const previewContainerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
jsPreviewExcel.init(previewContainerRef.current)
|
||||
.preview(url)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (previewContainerRef && previewContainerRef.current) {
|
||||
previewContainerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box ref={previewContainerRef} sx={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
function ImageViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}>
|
||||
<img alt='image-preview' src={url} style={{
|
||||
display: 'flex',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%'
|
||||
}} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileViewer({
|
||||
open,
|
||||
setOpen,
|
||||
docs,
|
||||
currentFileNo,
|
||||
setCurrentFileNo
|
||||
}: Props) {
|
||||
const { file, isLoading: fileIsLoading } = useDownload(currentFileNo >= 0 ? docs[currentFileNo]?.document_folder_id : null, currentFileNo >= 0 ? docs[currentFileNo]?.id : null)
|
||||
|
||||
const { fileType, isLoading: fileTypeIsLoading } = useFileType(currentFileNo >= 0 ? docs[currentFileNo]?.name : null, currentFileNo >= 0 ? file : null)
|
||||
|
||||
const handleSave = async () => {
|
||||
const url = window.URL.createObjectURL(file)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', docs[currentFileNo].name)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
setCurrentFileNo(-1)
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<AppBar sx={{ position: 'sticky' }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setCurrentFileNo(-1)
|
||||
}}
|
||||
aria-label="close"
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
{currentFileNo != -1 && docs[currentFileNo].name}
|
||||
</Typography>
|
||||
|
||||
<div>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo > 0) {
|
||||
setCurrentFileNo(currentFileNo - 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo === 0}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
color='inherit'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo < docs.length) {
|
||||
setCurrentFileNo(currentFileNo + 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
autoFocus
|
||||
color="inherit"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{
|
||||
flexGrow: '1',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
{fileIsLoading || fileTypeIsLoading ?
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
:
|
||||
fileType === 'application/pdf' ?
|
||||
<PdfViewer url={window.URL.createObjectURL(file)} />
|
||||
:
|
||||
fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ?
|
||||
<ExcelViewer url={window.URL.createObjectURL(file)} />
|
||||
:
|
||||
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ?
|
||||
<DocxViewer url={window.URL.createObjectURL(file)} />
|
||||
:
|
||||
fileType?.startsWith('image/') ?
|
||||
<ImageViewer url={window.URL.createObjectURL(file)} />
|
||||
:
|
||||
fileType && file ?
|
||||
<Box sx={{ display: 'flex', gap: '16px', flexDirection: 'column', p: '16px' }}>
|
||||
<Box sx={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<Warning />
|
||||
<Typography>
|
||||
Предпросмотр данного файла невозможен.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Button variant='contained' onClick={() => {
|
||||
handleSave()
|
||||
}}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
171
client/src/components/navigation/Drawer/ResponsiveDrawer.tsx
Normal file
171
client/src/components/navigation/Drawer/ResponsiveDrawer.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InboxIcon from '@mui/icons-material/MoveToInbox';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MailIcon from '@mui/icons-material/Mail';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
export default function ResponsiveDrawer() {
|
||||
//const { window } = props;
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const [isClosing, setIsClosing] = React.useState(false);
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setIsClosing(true);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleDrawerTransitionEnd = () => {
|
||||
setIsClosing(false);
|
||||
};
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
if (!isClosing) {
|
||||
setMobileOpen(!mobileOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar />
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
|
||||
<ListItem key={text} disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
{['All mail', 'Trash', 'Spam'].map((text, index) => (
|
||||
<ListItem key={text} disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemIcon>
|
||||
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
Dashboard
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
|
||||
aria-label="mailbox folders"
|
||||
>
|
||||
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onTransitionEnd={handleDrawerTransitionEnd}
|
||||
onClose={handleDrawerClose}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{ flexGrow: 1, p: 3, width: { sm: `calc(100% - ${drawerWidth}px)` } }}
|
||||
>
|
||||
<Toolbar />
|
||||
<Typography paragraph>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus non
|
||||
enim praesent elementum facilisis leo vel. Risus at ultrices mi tempus
|
||||
imperdiet. Semper risus in hendrerit gravida rutrum quisque non tellus.
|
||||
Convallis convallis tellus id interdum velit laoreet id donec ultrices.
|
||||
Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl suscipit
|
||||
adipiscing bibendum est ultricies integer quis. Cursus euismod quis viverra
|
||||
nibh cras. Metus vulputate eu scelerisque felis imperdiet proin fermentum
|
||||
leo. Mauris commodo quis imperdiet massa tincidunt. Cras tincidunt lobortis
|
||||
feugiat vivamus at augue. At augue eget arcu dictum varius duis at
|
||||
consectetur lorem. Velit sed ullamcorper morbi tincidunt. Lorem donec massa
|
||||
sapien faucibus et molestie ac.
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est ullamcorper
|
||||
eget nulla facilisi etiam dignissim diam. Pulvinar elementum integer enim
|
||||
neque volutpat ac tincidunt. Ornare suspendisse sed nisi lacus sed viverra
|
||||
tellus. Purus sit amet volutpat consequat mauris. Elementum eu facilisis
|
||||
sed odio morbi. Euismod lacinia at quis risus sed vulputate odio. Morbi
|
||||
tincidunt ornare massa eget egestas purus viverra accumsan in. In hendrerit
|
||||
gravida rutrum quisque non tellus orci ac. Pellentesque nec nam aliquam sem
|
||||
et tortor. Habitant morbi tristique senectus et. Adipiscing elit duis
|
||||
tristique sollicitudin nibh sit. Ornare aenean euismod elementum nisi quis
|
||||
eleifend. Commodo viverra maecenas accumsan lacus vel facilisis. Nulla
|
||||
posuere sollicitudin aliquam ultrices sagittis orci a.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
30
client/src/components/navigation/NavTabs.tsx
Normal file
30
client/src/components/navigation/NavTabs.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Tab, Tabs } from "@mui/material"
|
||||
import { Link, matchPath, useLocation } from "react-router-dom"
|
||||
|
||||
function useRouteMatch(patterns: readonly string[]) {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
for (let i = 0; i < patterns.length; i += 1) {
|
||||
const pattern = patterns[i]
|
||||
const possibleMatch = matchPath(pattern, pathname)
|
||||
if (possibleMatch !== null) {
|
||||
return possibleMatch
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function NavTabs() {
|
||||
const routeMatch = useRouteMatch(['/', '/user', '/role']);
|
||||
const currentTab = routeMatch?.pattern?.path;
|
||||
|
||||
return (
|
||||
<Tabs value={currentTab}>
|
||||
<Tab label="Главная" value="/" to="/" component={Link} />
|
||||
<Tab label="Пользователи" value="/user" to="/user" component={Link} />
|
||||
<Tab label="Роли" value="/role" to="/role" component={Link} />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
}
|
11
client/src/constants/index.ts
Normal file
11
client/src/constants/index.ts
Normal 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
|
||||
}
|
334
client/src/hooks/swrHooks.ts
Normal file
334
client/src/hooks/swrHooks.ts
Normal 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
|
||||
}
|
||||
}
|
27
client/src/http/axiosInstance.ts
Normal file
27
client/src/http/axiosInstance.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import axios, { ResponseType } from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const fetcher = (url: string, baseURL?: string, responseType?: ResponseType) => axiosInstance.get(url, {
|
||||
baseURL: baseURL || import.meta.env.VITE_API_INFO_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
responseType: responseType ? responseType : "json"
|
||||
}).then(res => res.data)
|
||||
|
||||
export default axiosInstance;
|
3
client/src/index.css
Normal file
3
client/src/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
39
client/src/interfaces/auth.ts
Normal file
39
client/src/interfaces/auth.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface UserData extends User {
|
||||
email: string;
|
||||
login: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
is_active: boolean;
|
||||
role_id: number;
|
||||
}
|
||||
|
||||
export interface UserCreds extends User {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
userData: UserData | null;
|
||||
}
|
||||
|
||||
export interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
grant_type: string;
|
||||
scope?: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
access_token: string;
|
||||
data: JSON;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
35
client/src/interfaces/create.ts
Normal file
35
client/src/interfaces/create.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Validate } from "react-hook-form";
|
||||
|
||||
export interface CreateFieldTypes {
|
||||
string: 'string';
|
||||
number: 'number';
|
||||
date: 'date';
|
||||
dateTime: 'dateTime';
|
||||
boolean: 'boolean';
|
||||
singleSelect: 'singleSelect';
|
||||
actions: 'actions';
|
||||
custom: 'custom';
|
||||
}
|
||||
|
||||
export interface InputTypes {
|
||||
password: 'password';
|
||||
}
|
||||
|
||||
export type CreateFieldType = CreateFieldTypes[keyof CreateFieldTypes]
|
||||
export type InputType = InputTypes[keyof InputTypes]
|
||||
|
||||
export interface CreateField {
|
||||
key: string;
|
||||
headerName?: string;
|
||||
type: CreateFieldType;
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
inputType?: InputType;
|
||||
validate?: Validate<string, boolean>;
|
||||
/** Watch for field */
|
||||
watch?: string;
|
||||
/** Message on watch */
|
||||
watchMessage?: string;
|
||||
/** Should field be included in the request */
|
||||
include?: boolean;
|
||||
}
|
65
client/src/interfaces/documents.ts
Normal file
65
client/src/interfaces/documents.ts
Normal file
@ -0,0 +1,65 @@
|
||||
// owner_id relates to other companies
|
||||
export interface ICompany {
|
||||
name: string;
|
||||
fullname: string;
|
||||
description: string;
|
||||
owner_id: number;
|
||||
}
|
||||
|
||||
export interface IDepartment {
|
||||
name: string;
|
||||
fullname: string;
|
||||
description: string;
|
||||
company_id: number;
|
||||
owner_id: number;
|
||||
}
|
||||
|
||||
export interface IDocumentFolder {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
create_date: string;
|
||||
}
|
||||
|
||||
export interface IDocument {
|
||||
id: number;
|
||||
document_folder_id: number,
|
||||
name: string;
|
||||
description: string;
|
||||
department_id: number;
|
||||
create_date: string;
|
||||
}
|
||||
|
||||
export interface IBank {
|
||||
name: string;
|
||||
bik: string;
|
||||
corschet: string;
|
||||
activ: boolean;
|
||||
id_1c: string;
|
||||
}
|
||||
|
||||
export interface IOrganization {
|
||||
full_name: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
ogrn: string;
|
||||
kpp: string;
|
||||
okopf: string;
|
||||
legal_address: string;
|
||||
actual_address: string;
|
||||
mail_address: string;
|
||||
id_budget: number;
|
||||
fio_dir: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
comment: string;
|
||||
id_bank: string;
|
||||
id_1c: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface IOrganizationBank {
|
||||
id_organization: string;
|
||||
id_banks: string;
|
||||
rasch_schet: string;
|
||||
}
|
18
client/src/interfaces/fuel.ts
Normal file
18
client/src/interfaces/fuel.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface IRegion {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ICity {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IBoiler {
|
||||
id: string;
|
||||
id_object: string;
|
||||
boiler_name: string;
|
||||
boiler_code: string;
|
||||
id_city: number;
|
||||
activity: boolean;
|
||||
}
|
3
client/src/interfaces/preferences.ts
Normal file
3
client/src/interfaces/preferences.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface PreferencesState {
|
||||
darkMode: boolean;
|
||||
}
|
10
client/src/interfaces/role.ts
Normal file
10
client/src/interfaces/role.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface IRole {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface IRoleCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
33
client/src/interfaces/servers.ts
Normal file
33
client/src/interfaces/servers.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export interface IServer {
|
||||
id: number;
|
||||
name: string;
|
||||
region_id: number;
|
||||
}
|
||||
|
||||
export interface IServersInfo extends IServer {
|
||||
servers_count: number;
|
||||
IPs_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IServerIP {
|
||||
name: string;
|
||||
is_actual: boolean;
|
||||
ip: string;
|
||||
server_id: number;
|
||||
}
|
||||
|
||||
export interface IHardware {
|
||||
name: string;
|
||||
os_info: string;
|
||||
ram: string;
|
||||
processor: string;
|
||||
server_id: number;
|
||||
}
|
||||
|
||||
export interface IStorage {
|
||||
name: string;
|
||||
size: string;
|
||||
storage_type: string;
|
||||
hardware_id: number;
|
||||
}
|
10
client/src/interfaces/user.ts
Normal file
10
client/src/interfaces/user.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface IUser {
|
||||
id: number;
|
||||
password: string;
|
||||
email: string;
|
||||
login: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
is_active: boolean;
|
||||
}
|
217
client/src/layouts/DashboardLayout.tsx
Normal file
217
client/src/layouts/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
252
client/src/layouts/DashboardLayoutResponsive.tsx
Normal file
252
client/src/layouts/DashboardLayoutResponsive.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
// Layout for dashboard with responsive drawer
|
||||
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom"
|
||||
import * as React from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Api, ExitToApp, Home, People, Settings, Shield } from "@mui/icons-material";
|
||||
import { getUserData, useAuthStore } from "../store/auth";
|
||||
import { UserData } from "../interfaces/auth";
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
export default function DashboardLayoutResponsive() {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const [userData, setUserData] = React.useState<UserData>();
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
//const { window } = props;
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const [isClosing, setIsClosing] = React.useState(false);
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setIsClosing(true);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleDrawerTransitionEnd = () => {
|
||||
setIsClosing(false);
|
||||
};
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
if (!isClosing) {
|
||||
setMobileOpen(!mobileOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const pages = [
|
||||
{
|
||||
label: "Главная",
|
||||
path: "/",
|
||||
icon: <Home />
|
||||
},
|
||||
{
|
||||
label: "Пользователи",
|
||||
path: "/user",
|
||||
icon: <People />
|
||||
},
|
||||
{
|
||||
label: "Роли",
|
||||
path: "/role",
|
||||
icon: <Shield />
|
||||
},
|
||||
{
|
||||
label: "API Test",
|
||||
path: "/api-test",
|
||||
icon: <Api />
|
||||
},
|
||||
]
|
||||
|
||||
const misc = [
|
||||
{
|
||||
label: "Настройки",
|
||||
path: "/settings",
|
||||
icon: <Settings />
|
||||
},
|
||||
{
|
||||
label: "Выход",
|
||||
path: "/signOut",
|
||||
icon: <ExitToApp />
|
||||
}
|
||||
]
|
||||
|
||||
const getPageTitle = () => {
|
||||
const currentPath = location.pathname;
|
||||
const allPages = [...pages, ...misc];
|
||||
const currentPage = allPages.find(page => page.path === currentPath);
|
||||
return currentPage ? currentPage.label : "Dashboard";
|
||||
};
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<Box display="flex" justifyContent={'space-between'} width={"100%"}>
|
||||
<Box>
|
||||
<Typography>{userData?.name} {userData?.surname}</Typography>
|
||||
<Divider />
|
||||
<Typography variant="caption">{userData?.login}</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={toggleDrawer}
|
||||
sx={{
|
||||
...(open && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
{pages.map((item, index) => (
|
||||
<ListItem key={index} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(item.path)
|
||||
}}
|
||||
selected={location.pathname === item.path}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
{misc.map((item, index) => (
|
||||
<ListItem key={index} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(item.path)
|
||||
}}
|
||||
selected={location.pathname === item.path}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div >
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authStore) {
|
||||
const stored = getUserData()
|
||||
if (stored) {
|
||||
setUserData(stored)
|
||||
}
|
||||
}
|
||||
}, [authStore])
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
height: "100vh"
|
||||
}}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
{getPageTitle()}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
|
||||
aria-label="mailbox folders"
|
||||
>
|
||||
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onTransitionEnd={handleDrawerTransitionEnd}
|
||||
onClose={handleDrawerClose}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile.
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` }
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
31
client/src/layouts/MainLayout.tsx
Normal file
31
client/src/layouts/MainLayout.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
// Layout for fullscreen pages
|
||||
|
||||
import { Box, createTheme, ThemeProvider, useTheme } from "@mui/material";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function MainLayout() {
|
||||
const theme = useTheme()
|
||||
const innerTheme = createTheme(theme)
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={innerTheme}>
|
||||
<Box
|
||||
sx={{
|
||||
color: (theme) => theme.palette.mode === 'light'
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[100],
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[100]
|
||||
: theme.palette.grey[900],
|
||||
flexGrow: 1,
|
||||
maxHeight: "100vh",
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
83
client/src/main.tsx
Normal file
83
client/src/main.tsx
Normal 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>,
|
||||
)
|
51
client/src/pages/ApiTest.tsx
Normal file
51
client/src/pages/ApiTest.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { Box } from "@mui/material"
|
||||
import { useCities } from "../hooks/swrHooks"
|
||||
import { useEffect, useState } from "react"
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid"
|
||||
import axiosInstance from "../http/axiosInstance"
|
||||
import { BASE_URL } from "../constants"
|
||||
|
||||
|
||||
export default function ApiTest() {
|
||||
const limit = 10
|
||||
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 1,
|
||||
pageSize: limit
|
||||
})
|
||||
|
||||
const [rowCount, setRowCount] = useState(0)
|
||||
|
||||
const fetchCount = async () => {
|
||||
await axiosInstance.get(`/general/cities_count`, {
|
||||
baseURL: BASE_URL.fuel
|
||||
}).then(response => {
|
||||
setRowCount(response.data)
|
||||
})
|
||||
}
|
||||
|
||||
const { cities, isLoading } = useCities(paginationModel.pageSize, paginationModel.page)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCount()
|
||||
}, [])
|
||||
|
||||
const citiesColumns: GridColDef[] = [
|
||||
{ field: 'id' },
|
||||
{ field: 'name' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }}>
|
||||
<DataGrid
|
||||
rows={cities || []}
|
||||
columns={citiesColumns}
|
||||
paginationMode='server'
|
||||
rowCount={rowCount}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
57
client/src/pages/Boilers.tsx
Normal file
57
client/src/pages/Boilers.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { DataGrid, GridColDef } from '@mui/x-data-grid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IBoiler } from '../interfaces/fuel'
|
||||
import { useBoilers } from '../hooks/swrHooks'
|
||||
|
||||
function Boilers() {
|
||||
const [boilersPage, setBoilersPage] = useState(1)
|
||||
const [boilerSearch, setBoilerSearch] = useState("")
|
||||
const [debouncedBoilerSearch, setDebouncedBoilerSearch] = useState("")
|
||||
const { boilers } = useBoilers(10, boilersPage, debouncedBoilerSearch)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedBoilerSearch(boilerSearch)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [boilerSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setBoilersPage(1)
|
||||
setBoilerSearch("")
|
||||
}, [])
|
||||
|
||||
const boilersColumns: GridColDef[] = [
|
||||
{ field: 'id', headerName: 'ID', type: "number" },
|
||||
{ field: 'boiler_name', headerName: 'Название', type: "string" },
|
||||
{ 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
|
9
client/src/pages/Documents.tsx
Normal file
9
client/src/pages/Documents.tsx
Normal 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
15
client/src/pages/Main.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Box, Card, Typography } from "@mui/material";
|
||||
|
||||
export default function Main() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant='h6' fontWeight='700'>
|
||||
Последние файлы
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
11
client/src/pages/MapTest.tsx
Normal file
11
client/src/pages/MapTest.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import MapComponent from '../components/map/MapComponent'
|
||||
|
||||
function MapTest() {
|
||||
return (
|
||||
<div>
|
||||
<MapComponent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapTest
|
13
client/src/pages/NotFound.tsx
Normal file
13
client/src/pages/NotFound.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Error } from "@mui/icons-material";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<Error />
|
||||
<Typography>Запрашиваемая страница не найдена.</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
126
client/src/pages/Reports.tsx
Normal file
126
client/src/pages/Reports.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
83
client/src/pages/Roles.tsx
Normal file
83
client/src/pages/Roles.tsx
Normal 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>
|
||||
)
|
||||
}
|
76
client/src/pages/Servers.tsx
Normal file
76
client/src/pages/Servers.tsx
Normal 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>
|
||||
)
|
||||
}
|
75
client/src/pages/Settings.tsx
Normal file
75
client/src/pages/Settings.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { Box, Stack } from "@mui/material"
|
||||
import UserService from "../services/UserService"
|
||||
import { setUserData, useAuthStore } from "../store/auth"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CreateField } from "../interfaces/create"
|
||||
import { IUser } from "../interfaces/user"
|
||||
import FormFields from "../components/FormFields"
|
||||
import AuthService from "../services/AuthService"
|
||||
|
||||
export default function Settings() {
|
||||
const { token } = useAuthStore()
|
||||
const [currentUser, setCurrentUser] = useState<IUser>()
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
if (token) {
|
||||
await UserService.getCurrentUser(token).then(response => {
|
||||
setCurrentUser(response.data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchCurrentUser()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const profileFields: CreateField[] = [
|
||||
//{ key: 'email', headerName: 'E-mail', type: 'string', required: true },
|
||||
//{ key: 'login', headerName: 'Логин', type: 'string', required: true },
|
||||
{ key: 'phone', headerName: 'Телефон', type: 'string', required: false },
|
||||
{ key: 'name', headerName: 'Имя', type: 'string', required: true },
|
||||
{ key: 'surname', headerName: 'Фамилия', type: 'string', required: true },
|
||||
]
|
||||
|
||||
const passwordFields: CreateField[] = [
|
||||
{ key: 'password', headerName: 'Новый пароль', type: 'string', required: true, inputType: 'password' },
|
||||
{ key: 'password_confirm', headerName: 'Подтверждение пароля', type: 'string', required: true, inputType: 'password', watch: 'password', watchMessage: 'Пароли не совпадают', include: false },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{currentUser &&
|
||||
<Stack spacing={2} width='100%'>
|
||||
<Stack width='100%'>
|
||||
<FormFields
|
||||
fields={profileFields}
|
||||
defaultValues={currentUser}
|
||||
mutateHandler={(data: any) => {
|
||||
setUserData(data)
|
||||
}}
|
||||
submitHandler={(data) => UserService.updateUser({ id: currentUser.id, ...data })}
|
||||
title="Пользователь"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack width='100%'>
|
||||
<FormFields
|
||||
fields={passwordFields}
|
||||
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
|
||||
title="Смена пароля"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
</Box>
|
||||
)
|
||||
}
|
103
client/src/pages/Users.tsx
Normal file
103
client/src/pages/Users.tsx
Normal 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>
|
||||
)
|
||||
}
|
92
client/src/pages/auth/PasswordReset.tsx
Normal file
92
client/src/pages/auth/PasswordReset.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Box, Button, CircularProgress, Container, Fade, Grow, Stack, TextField, Typography } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { CheckCircle } from '@mui/icons-material';
|
||||
|
||||
interface PasswordResetProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
function PasswordReset() {
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { register, handleSubmit, watch, setError, formState: { errors, isSubmitting } } = useForm<PasswordResetProps>({
|
||||
defaultValues: {
|
||||
email: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<PasswordResetProps> = async (data) => {
|
||||
await AuthService.resetPassword(data.email).then(response => {
|
||||
if (response.status === 200) {
|
||||
//setError('email', { message: response.data.msg })
|
||||
setSuccess(true)
|
||||
} else if (response.status === 422) {
|
||||
setError('email', { message: response.statusText })
|
||||
}
|
||||
}).catch((error: Error) => {
|
||||
setError('email', { message: error.message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box my={4}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Восстановление пароля
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!success && <Fade in={!success}>
|
||||
<Stack spacing={2}>
|
||||
<Typography>
|
||||
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="E-mail"
|
||||
required
|
||||
{...register('email', { required: 'Введите E-mail' })}
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: '16px' }}>
|
||||
<Button fullWidth type="submit" disabled={isSubmitting || watch('email').length == 0} variant="contained" color="primary">
|
||||
{isSubmitting ? <CircularProgress size={16} /> : 'Восстановить пароль'}
|
||||
</Button>
|
||||
|
||||
<Button fullWidth href="/auth/signin" type="button" variant="text" color="primary">
|
||||
Назад
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
</Stack>
|
||||
</Fade>}
|
||||
{success &&
|
||||
<Grow in={success}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction='row' alignItems='center' spacing={2}>
|
||||
<CheckCircle color='success' />
|
||||
<Typography>
|
||||
На указанный адрес было отправлено письмо с новыми данными для авторизации.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', gap: '16px' }}>
|
||||
<Button fullWidth href="/auth/signin" type="button" variant="contained" color="primary">
|
||||
Войти
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grow>
|
||||
}
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordReset
|
103
client/src/pages/auth/SignIn.tsx
Normal file
103
client/src/pages/auth/SignIn.tsx
Normal 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;
|
103
client/src/pages/auth/SignUp.tsx
Normal file
103
client/src/pages/auth/SignUp.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import { TextField, Button, Container, Typography, Box } from '@mui/material';
|
||||
import UserService from '../../services/UserService';
|
||||
import { IUser } from '../../interfaces/user';
|
||||
|
||||
const SignUp = () => {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<IUser>({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
login: '',
|
||||
phone: '',
|
||||
name: '',
|
||||
surname: '',
|
||||
is_active: true,
|
||||
password: '',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const onSubmit: SubmitHandler<IUser> = async (data) => {
|
||||
try {
|
||||
await UserService.createUser(data)
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box my={4}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Регистрация
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Email"
|
||||
required
|
||||
{...register('email', { required: 'Email обязателен' })}
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Логин"
|
||||
required
|
||||
{...register('login', { required: 'Логин обязателен' })}
|
||||
error={!!errors.login}
|
||||
helperText={errors.login?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Телефон"
|
||||
{...register('phone')}
|
||||
error={!!errors.phone}
|
||||
helperText={errors.phone?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Имя"
|
||||
{...register('name')}
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
label="Фамилия"
|
||||
{...register('surname')}
|
||||
error={!!errors.surname}
|
||||
helperText={errors.surname?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type="password"
|
||||
label="Пароль"
|
||||
required
|
||||
{...register('password', { required: 'Пароль обязателен' })}
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="contained" color="primary">
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUp;
|
32
client/src/services/AuthService.ts
Normal file
32
client/src/services/AuthService.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { BASE_URL } from "../constants";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.auth,
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/x-www-form-urlencoded'
|
||||
// }
|
||||
}
|
||||
|
||||
export default class AuthService {
|
||||
static async login(data: URLSearchParams) {
|
||||
return await axiosInstance.post(`/auth/login`, data, config)
|
||||
}
|
||||
|
||||
static async refreshToken(token: string) {
|
||||
return await axiosInstance.post(`/auth/refresh_token/${token}`, null, config)
|
||||
}
|
||||
|
||||
static async getCurrentUser(token: string) {
|
||||
return await axiosInstance.get(`/auth/get_current_user/${token}`, config)
|
||||
}
|
||||
|
||||
static async resetPassword(email: string) {
|
||||
return await axiosInstance.put(`/auth/user/reset_password?email=${email}`, null, config)
|
||||
}
|
||||
|
||||
static async updatePassword(data: { id: number, password: string }) {
|
||||
return await axiosInstance.put(`/auth/user/password_change`, data, config)
|
||||
}
|
||||
}
|
247
client/src/services/DocumentService.ts
Normal file
247
client/src/services/DocumentService.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import { AxiosProgressEvent, AxiosRequestConfig } from "axios";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
import { IBank, ICompany, IDepartment, IDocument, IDocumentFolder, IOrganization, IOrganizationBank } from "../interfaces/documents";
|
||||
import { BASE_URL } from "../constants";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.info,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
|
||||
export default class DocumentService {
|
||||
// Get Main
|
||||
static async getMain() {
|
||||
return await axiosInstance.get(`/info/`, config)
|
||||
}
|
||||
|
||||
// Get Companies
|
||||
static async getCompanies(limit?: number, offset?: number) {
|
||||
return await axiosInstance.get(`/info/companies`, {
|
||||
params: {
|
||||
limit: limit || 10,
|
||||
offset: offset || 0
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Create Company
|
||||
static async createCompany(data: ICompany) {
|
||||
return await axiosInstance.post(`/info/companies/`, data, config)
|
||||
}
|
||||
|
||||
// Delete Company
|
||||
static async deleteCompany(company_id: number) {
|
||||
return await axiosInstance.delete(`/info/companies/${company_id}`, config)
|
||||
}
|
||||
|
||||
// Update Company
|
||||
static async updateCompany(company_id: number) {
|
||||
return await axiosInstance.patch(`/info/companies/${company_id}`, config)
|
||||
}
|
||||
|
||||
// Get Departments
|
||||
static async getDepartments(limit?: number, offset?: number) {
|
||||
return await axiosInstance.get(`/info/departments/`, {
|
||||
params: {
|
||||
limit: limit || 10,
|
||||
offset: offset || 0
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Get Department
|
||||
static async getDepartment(department_id: number) {
|
||||
return await axiosInstance.get(`/info/departments/${department_id}`, config)
|
||||
}
|
||||
|
||||
|
||||
// Delete Department
|
||||
static async deleteDepartment(department_id: number) {
|
||||
return await axiosInstance.delete(`/info/departments/${department_id}`, config)
|
||||
}
|
||||
|
||||
// Update Department
|
||||
static async updateDepartment(department_id: number, data: IDepartment) {
|
||||
return await axiosInstance.patch(`/info/departments/${department_id}`, data, config)
|
||||
}
|
||||
|
||||
// Create Department
|
||||
static async createDepartment(data: IDepartment) {
|
||||
return await axiosInstance.post(`/info/department/`, data, config)
|
||||
}
|
||||
|
||||
// Get Documents
|
||||
static async getDocuments(limit?: number, offset?: number) {
|
||||
return await axiosInstance.get(`/info/document_folder/`, {
|
||||
params: {
|
||||
limit: limit || 10,
|
||||
offset: offset || 0
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Create Documentfolder
|
||||
static async createDocumentFolder(data: IDocumentFolder) {
|
||||
return await axiosInstance.post(`/info/document_folder/`, data, config)
|
||||
}
|
||||
|
||||
// Get Document
|
||||
static async getDocument(folder_id: number) {
|
||||
return await axiosInstance.get(`/info/document_folder/${folder_id}`, config)
|
||||
}
|
||||
|
||||
// Delete Document
|
||||
static async deleteDocument(folder_id: number) {
|
||||
return await axiosInstance.delete(`/info/document_folder/${folder_id}`, config)
|
||||
}
|
||||
|
||||
// Update Document
|
||||
static async updateDocument(folder_id: number, data: IDocument) {
|
||||
return await axiosInstance.patch(`/info/document_folder/${folder_id}`, data, config)
|
||||
}
|
||||
|
||||
// Get Docs
|
||||
static async getDocs(folder_id: number) {
|
||||
return await axiosInstance.get(`/info/documents/${folder_id}`, config)
|
||||
}
|
||||
|
||||
// Upload Files
|
||||
static async uploadFiles(folder_id: number, files: FormData, setUploadProgress?: (value: number) => void) {
|
||||
return await axiosInstance.post(`/info/documents/upload/${folder_id}`, files, {
|
||||
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
||||
const percentCompleted = progressEvent.progress
|
||||
setUploadProgress?.(percentCompleted || 0)
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Download Doc
|
||||
static async downloadDoc(folder_id: number, doc_id: number) {
|
||||
return await axiosInstance.get(`/info/document/${folder_id}&${doc_id}`, {
|
||||
responseType: 'blob',
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Delete Doc
|
||||
static async deleteDoc(folder_id: number, doc_id: number) {
|
||||
return await axiosInstance.delete(`/info/document/`, {
|
||||
params: {
|
||||
folder_id: folder_id,
|
||||
doc_id: doc_id
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Convert Phones
|
||||
static async convertPhones(data: FormData) {
|
||||
return await axiosInstance.post(`/info/other/phones/`, data, config)
|
||||
}
|
||||
|
||||
// Get Budget
|
||||
static async getBudget() {
|
||||
return await axiosInstance.get(`/info/organization/budget/`, config)
|
||||
}
|
||||
|
||||
// Add Bank
|
||||
static async addBank(data: IBank) {
|
||||
return await axiosInstance.post(`/info/organization/bank`, data, config)
|
||||
}
|
||||
|
||||
// Update Bank
|
||||
static async updateBank(bank_id: string, bank_1c_id: string, data: IBank) {
|
||||
return await axiosInstance.patch(`/info/organization/bank`, data, {
|
||||
params: {
|
||||
bank_id: bank_id,
|
||||
bank_1c_id: bank_1c_id
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Get Banks
|
||||
static async getBanks(bank_id?: string, search?: string, limit?: number, offset?: number) {
|
||||
return await axiosInstance.get(`/info/organization/banks`, {
|
||||
params: {
|
||||
bank_id: bank_id,
|
||||
search: search || null,
|
||||
limit: limit || 10,
|
||||
offset: offset || 0
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Get Bank
|
||||
static async getBank(id_1c: string) {
|
||||
return await axiosInstance.get(`/info/organization/bank/${id_1c}`, config)
|
||||
}
|
||||
|
||||
// Delete Bank
|
||||
static async deleteBank(bank_id: string, bank_1c_id: string) {
|
||||
return await axiosInstance.get(`/info/organization/bank/`, {
|
||||
params: {
|
||||
bank_id: bank_id,
|
||||
bank_1c_id: bank_1c_id
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Add Org
|
||||
static async addOrganization(data: IOrganization) {
|
||||
return await axiosInstance.post(`/info/organization/org/`, data, config)
|
||||
}
|
||||
|
||||
// Update Org
|
||||
static async updateOrganization(org_id: string, org_1c_id: string, data: IOrganization) {
|
||||
return await axiosInstance.patch(`/info/organization/org`, data, {
|
||||
params: {
|
||||
org_id: org_id,
|
||||
org_1c_id: org_1c_id
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Delete Org
|
||||
static async deleteOrganization(org_id: string, org_1c_id: string) {
|
||||
return await axiosInstance.delete(`/info/organization/org`, {
|
||||
params: {
|
||||
org_id: org_id,
|
||||
org_1c_id: org_1c_id
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Get Orgs
|
||||
static async getOrganizations(org_id?: string, search?: string, limit?: number, offset?: number) {
|
||||
return await axiosInstance.get(`/info/organization/orgs`, {
|
||||
params: {
|
||||
org_id: org_id,
|
||||
search: search || null,
|
||||
limit: limit || 10,
|
||||
offset: offset || 0
|
||||
},
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
// Get Org
|
||||
static async getOrganization(id_1c: string) {
|
||||
return await axiosInstance.get(`/info/organization/org/${id_1c}`, config)
|
||||
}
|
||||
|
||||
// Add Orgbank
|
||||
static async addOrganizationBank(data: IOrganizationBank) {
|
||||
return await axiosInstance.post(`/info/organization/org_bank`, data, config)
|
||||
}
|
||||
}
|
13
client/src/services/FuelService.ts
Normal file
13
client/src/services/FuelService.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
import { BASE_URL } from "../constants";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.fuel
|
||||
}
|
||||
|
||||
export default class FuelService {
|
||||
static async getAddress(limit?: number, page?: number) {
|
||||
return await axiosInstance.get(`/general/address?limit=${limit || 10}&page=${page || 1}`, config)
|
||||
}
|
||||
}
|
27
client/src/services/RoleService.ts
Normal file
27
client/src/services/RoleService.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
import { IRoleCreate } from "../interfaces/role";
|
||||
import { BASE_URL } from "../constants";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.auth
|
||||
}
|
||||
|
||||
export default class RoleService {
|
||||
static async getRoles() {
|
||||
return await axiosInstance.get(`/auth/roles`, config)
|
||||
}
|
||||
|
||||
static async createRole(data: IRoleCreate) {
|
||||
return await axiosInstance.post(`/auth/roles/`, data, config)
|
||||
}
|
||||
|
||||
|
||||
static async getRoleById(id: number) {
|
||||
return await axiosInstance.get(`/auth/roles/${id}`, config)
|
||||
}
|
||||
|
||||
// static async deleteRole(id: number) {
|
||||
// return await axiosInstance.delete(`/auth/roles/${id}`)
|
||||
// }
|
||||
}
|
42
client/src/services/ServersService.ts
Normal file
42
client/src/services/ServersService.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
import { IHardware, IServer, IServerIP, IStorage } from "../interfaces/servers";
|
||||
import { BASE_URL } from "../constants";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.servers
|
||||
}
|
||||
|
||||
export default class ServerService {
|
||||
static async removeServer(server_id: number) {
|
||||
return await axiosInstance.delete(`/api/server/${server_id}`, config)
|
||||
}
|
||||
|
||||
static async addServer(data: IServer) {
|
||||
return await axiosInstance.post(`/api/server/`, data, config)
|
||||
}
|
||||
|
||||
static async removeHardware(hardware_id: number) {
|
||||
return await axiosInstance.delete(`/api/hardware/${hardware_id}`, config)
|
||||
}
|
||||
|
||||
static async addHardware(data: IHardware) {
|
||||
return await axiosInstance.post(`/api/hardware`, data, config)
|
||||
}
|
||||
|
||||
static async removeStorage(storage_id: number) {
|
||||
return await axiosInstance.delete(`/api/storage/${storage_id}`, config)
|
||||
}
|
||||
|
||||
static async addStorage(data: IStorage) {
|
||||
return await axiosInstance.post(`/api/storage`, data, config)
|
||||
}
|
||||
|
||||
static async addServerIp(data: IServerIP) {
|
||||
return await axiosInstance.post(`/api/server_ip`, data, config)
|
||||
}
|
||||
|
||||
static async removeServerIp(ip_id: number) {
|
||||
return await axiosInstance.delete(`/api/server_ip/${ip_id}`, config)
|
||||
}
|
||||
}
|
39
client/src/services/UserService.ts
Normal file
39
client/src/services/UserService.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import axiosInstance from "../http/axiosInstance";
|
||||
import { UserCreds, UserData } from "../interfaces/auth";
|
||||
import { BASE_URL } from "../constants";
|
||||
import { IUser } from "../interfaces/user";
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: BASE_URL.auth
|
||||
}
|
||||
|
||||
export default class UserService {
|
||||
static async createUser(data: IUser) {
|
||||
return await axiosInstance.post(`/auth/user`, data, config)
|
||||
}
|
||||
|
||||
static async getCurrentUser(token: string) {
|
||||
return await axiosInstance.get(`/auth/get_current_user/${token}`, config)
|
||||
}
|
||||
|
||||
static async getUsers() {
|
||||
return await axiosInstance.get(`/auth/user`, config)
|
||||
}
|
||||
|
||||
// static async deleteUser(id: number) {
|
||||
// return await axiosInstance.delete(`/auth/user/${id}`)
|
||||
// }
|
||||
|
||||
static async getUser(id: number) {
|
||||
return await axiosInstance.get(`/auth/user/${id}`, config)
|
||||
}
|
||||
|
||||
static async updatePassword(data: UserCreds) {
|
||||
return await axiosInstance.put(`/auth/user/password_change`, data, config)
|
||||
}
|
||||
|
||||
static async updateUser(data: UserData) {
|
||||
return await axiosInstance.put(`/auth/user`, data, config)
|
||||
}
|
||||
}
|
82
client/src/store/auth.ts
Normal file
82
client/src/store/auth.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand';
|
||||
import { TOKEN_AUTH_KEY, TOKEN_EXPIRY_DURATION, TOKEN_ISSUED_DATE_KEY, USER_DATA_KEY } from '../constants';
|
||||
import { AuthState } from '../interfaces/auth';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
export const useAuthStore = create<AuthState>(() => ({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
userData: null,
|
||||
}));
|
||||
|
||||
const login = (token: string) => {
|
||||
const issuedDate = Date.now();
|
||||
localStorage.setItem(TOKEN_AUTH_KEY, token);
|
||||
localStorage.setItem(TOKEN_ISSUED_DATE_KEY, issuedDate.toString());
|
||||
useAuthStore.setState(() => ({ isAuthenticated: true, token: token }))
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(TOKEN_AUTH_KEY);
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
localStorage.removeItem(TOKEN_ISSUED_DATE_KEY);
|
||||
useAuthStore.setState(() => ({ isAuthenticated: false, token: null, userData: null }));
|
||||
}
|
||||
|
||||
const initAuth = async () => {
|
||||
const token = localStorage.getItem(TOKEN_AUTH_KEY);
|
||||
const issuedDate = parseInt(localStorage.getItem(TOKEN_ISSUED_DATE_KEY) || '0', 10);
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (token && issuedDate) {
|
||||
if (currentTime - issuedDate < TOKEN_EXPIRY_DURATION) {
|
||||
useAuthStore.setState(() => ({ isAuthenticated: true, token: token }))
|
||||
} else {
|
||||
try {
|
||||
await refreshToken();
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshToken = async () => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (!token) throw new Error('No token to refresh');
|
||||
|
||||
try {
|
||||
const response = await AuthService.refreshToken(token)
|
||||
const newToken = response.data.access_token;
|
||||
const newIssuedDate = Date.now();
|
||||
localStorage.setItem(TOKEN_AUTH_KEY, newToken);
|
||||
localStorage.setItem(TOKEN_ISSUED_DATE_KEY, newIssuedDate.toString());
|
||||
useAuthStore.setState(() => ({ token: newToken }))
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
const getUserData = () => {
|
||||
const userData = localStorage.getItem(USER_DATA_KEY)
|
||||
return userData ? JSON.parse(userData) : {}
|
||||
}
|
||||
|
||||
const setUserData = (userData: string) => {
|
||||
const parsedData = JSON.parse(userData)
|
||||
localStorage.setItem(USER_DATA_KEY, userData)
|
||||
useAuthStore.setState(() => ({ userData: parsedData }))
|
||||
}
|
||||
|
||||
export {
|
||||
getUserData,
|
||||
setUserData,
|
||||
refreshToken,
|
||||
initAuth,
|
||||
login,
|
||||
logout,
|
||||
}
|
22
client/src/store/preferences.ts
Normal file
22
client/src/store/preferences.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { PreferencesState } from '../interfaces/preferences';
|
||||
|
||||
export const usePrefStore = create<PreferencesState>(() => ({
|
||||
darkMode: false
|
||||
}));
|
||||
|
||||
const getDarkMode = () => {
|
||||
const darkMode = localStorage.getItem('darkMode')
|
||||
usePrefStore.setState(() => ({ darkMode: darkMode?.toLowerCase() === "true" ? true : false }))
|
||||
return darkMode
|
||||
}
|
||||
|
||||
const setDarkMode = (darkMode: boolean) => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode))
|
||||
usePrefStore.setState(() => ({ darkMode: darkMode }))
|
||||
}
|
||||
|
||||
export {
|
||||
getDarkMode,
|
||||
setDarkMode
|
||||
}
|
0
client/src/store/user.ts
Normal file
0
client/src/store/user.ts
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
10
client/tailwind.config.js
Normal file
10
client/tailwind.config.js
Normal 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
36
client/tsconfig.json
Normal 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
11
client/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
1
client/tsconfig.node.tsbuildinfo
Normal file
1
client/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
11
client/vite.config.ts
Normal file
11
client/vite.config.ts
Normal 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
6008
client/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user