Compare commits
76 Commits
Author | SHA1 | Date | |
---|---|---|---|
2bf657e8ed | |||
59fded5cab | |||
242ed1aee2 | |||
75d6420d6b | |||
7e8d1f50c8 | |||
f04fc5f575 | |||
d2cb6d9cac | |||
a31ede2669 | |||
b1df6ca398 | |||
b54c2f0e20 | |||
65b7e275fe | |||
59dddfa02c | |||
5218ee851f | |||
71055e7cd0 | |||
9054c576a1 | |||
dec796b75e | |||
87866e4e51 | |||
eeae97288a | |||
e9595f9703 | |||
bd0a317e76 | |||
a4513e7e7a | |||
f51835584d | |||
115c6ec417 | |||
edb6ae00fb | |||
974fc12b34 | |||
b88d83cd74 | |||
108dc5082c | |||
33f41aaab0 | |||
8c8c619143 | |||
ddacbcd837 | |||
3994989994 | |||
ab88fd5ea5 | |||
579bbf7764 | |||
97b44a4db7 | |||
61339f4c26 | |||
8d68119ded | |||
00af65ecdb | |||
3a090bf1ad | |||
878f206189 | |||
748aa81a99 | |||
1e802b4550 | |||
a1a5c2b3a6 | |||
a3b0b1b222 | |||
424217a895 | |||
0ac0534486 | |||
e1f9dc762c | |||
153806f392 | |||
ae2213b188 | |||
748cf89b35 | |||
492fbd7d89 | |||
e3af090119 | |||
53e9a8cadf | |||
a3043afa7b | |||
ca2d97f975 | |||
cf3fda43e4 | |||
4283bd20bb | |||
e566e23f6d | |||
416e2e39b5 | |||
f9de1124c3 | |||
a65a431b09 | |||
6f4aa1903d | |||
c74c911eea | |||
d298de0a72 | |||
3727fcabb3 | |||
261196afef | |||
2c71e4f6af | |||
704276037c | |||
e70d94afec | |||
7ba886e966 | |||
af1d497715 | |||
c41e59cd86 | |||
18fb120777 | |||
85f97e9e0e | |||
c2688855c3 | |||
62695acf74 | |||
d6906503d1 |
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
REDIS_HOST=redis_db
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_DB=ems
|
||||||
|
POSTGRES_USER=ems
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
CLIENT_PORT=5173
|
||||||
|
EMS_PORT=5000
|
||||||
|
MONITOR_PORT=1234
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
__pycache__
|
__pycache__
|
||||||
.env
|
.env
|
||||||
|
redis_data
|
||||||
|
psql_data
|
@ -1,47 +0,0 @@
|
|||||||
from fastapi import APIRouter, BackgroundTasks
|
|
||||||
import backend_fastapi.schemas as schemas
|
|
||||||
from backend_fastapi.repositories import get_stored_roles, add_role, add_user, get_role_all, update_role, delete_role, update_user,delete_user,get_users
|
|
||||||
from typing import List
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/role")
|
|
||||||
async def create_role(role: schemas.RoleCreate) -> schemas.Role:
|
|
||||||
return await add_role(role)
|
|
||||||
|
|
||||||
@router.get("/stored_role")
|
|
||||||
async def get_stored_role() -> List[schemas.Role]:
|
|
||||||
return await get_stored_roles()
|
|
||||||
|
|
||||||
@router.get("/role")
|
|
||||||
async def get_role(limit:int=10, page:int=0) -> List[schemas.Role]:
|
|
||||||
return await get_role_all(limit, page)
|
|
||||||
|
|
||||||
@router.patch("/role")
|
|
||||||
async def change_role(role: schemas.Role, id: int) -> None:
|
|
||||||
return await update_role(role, id)
|
|
||||||
|
|
||||||
@router.delete("/role")
|
|
||||||
async def remove_role(id: int) -> schemas.Role:
|
|
||||||
return await delete_role(id)
|
|
||||||
|
|
||||||
@router.post("/user")
|
|
||||||
async def create_user(user: schemas.UserCreate) -> schemas.User:
|
|
||||||
import hashlib
|
|
||||||
user.hashed_password = hashlib.sha256(user.hashed_password.encode('utf-8')).hexdigest()
|
|
||||||
return await add_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/user')
|
|
||||||
async def show_users(limit:int=10, page:int=0):
|
|
||||||
return await get_users(limit, page)
|
|
||||||
|
|
||||||
@router.patch('/user')
|
|
||||||
async def change_users(user: schemas.User, id: int):
|
|
||||||
import hashlib
|
|
||||||
if user.hashed_password:
|
|
||||||
user.hashed_password = hashlib.sha256(user.hashed_password.encode('utf-8')).hexdigest()
|
|
||||||
return await update_user(user, id)
|
|
||||||
|
|
||||||
@router.delete('/user')
|
|
||||||
async def remove_users(id: int):
|
|
||||||
return await delete_user(id)
|
|
@ -1,29 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv()
|
|
||||||
class Model(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async_engine = create_async_engine(
|
|
||||||
os.getenv("SQL_URL"),
|
|
||||||
connect_args={"check_same_thread": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async_session = async_sessionmaker(
|
|
||||||
async_engine,
|
|
||||||
autoflush=True,
|
|
||||||
autocommit=False,
|
|
||||||
expire_on_commit =False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def connect() -> None:
|
|
||||||
async with async_engine.begin() as conn:
|
|
||||||
await conn.run_sync(Model.metadata.create_all, checkfirst=True)
|
|
||||||
|
|
||||||
async def disconnect() -> None:
|
|
||||||
if async_engine:
|
|
||||||
await async_engine.dispose()
|
|
@ -1,26 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
import backend_fastapi.schemas as schemas
|
|
||||||
import backend_fastapi.repositories as repo
|
|
||||||
import asyncio
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/values")
|
|
||||||
async def get_values() -> list[schemas.Value]:
|
|
||||||
return await repo.get_values()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/objects")
|
|
||||||
async def get_objects() -> list[schemas.Object]:
|
|
||||||
return await repo.get_objects()
|
|
||||||
|
|
||||||
@router.get("/report")
|
|
||||||
async def get_report() -> None:
|
|
||||||
import pandas as pd
|
|
||||||
values, objects = await asyncio.gather(repo.get_values(), repo.get_objects())
|
|
||||||
values = [schemas.Value.model_validate(value).model_dump() for value in values]
|
|
||||||
objects = [schemas.Object.model_validate(object).model_dump() for object in objects]
|
|
||||||
|
|
||||||
df_values= pd.DataFrame(data=values)
|
|
||||||
df_objects= pd.DataFrame(data=objects)
|
|
||||||
df_type = df_values['id_param'].where(df_values['id_param'] == 3).notnull()
|
|
||||||
print(df_values[["id_object","value"]][df_type].set_index("id_object").join(df_objects[["id","id_city"]].set_index("id"),how='inner'))
|
|
@ -1,47 +0,0 @@
|
|||||||
from .database import Model
|
|
||||||
from sqlalchemy.orm import mapped_column, Mapped, relationship
|
|
||||||
from sqlalchemy import String, Boolean, ForeignKey, Date
|
|
||||||
from sqlalchemy.dialects.mssql import SQL_VARIANT, UNIQUEIDENTIFIER
|
|
||||||
from uuid import UUID
|
|
||||||
from datetime import date
|
|
||||||
class Role(Model):
|
|
||||||
__tablename__ = "roles"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255),nullable=False)
|
|
||||||
|
|
||||||
class User(Model):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column( primary_key=True)
|
|
||||||
firstname: Mapped[str] = mapped_column(String(255),nullable=False)
|
|
||||||
lastname: Mapped[str] = mapped_column(String(255),nullable=False)
|
|
||||||
email: Mapped[str] = mapped_column(String(255),nullable=False)
|
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255),nullable=False)
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean,default=True)
|
|
||||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
|
|
||||||
role: Mapped["Role"] = relationship()
|
|
||||||
|
|
||||||
|
|
||||||
class Object(Model):
|
|
||||||
__tablename__ = "vObjects"
|
|
||||||
|
|
||||||
id: Mapped[UUID] = mapped_column(UNIQUEIDENTIFIER, primary_key=True)
|
|
||||||
id_parent: Mapped[UUID|None] = mapped_column(UNIQUEIDENTIFIER,nullable=True)
|
|
||||||
year: Mapped[int | None] = mapped_column(nullable=False)
|
|
||||||
id_city: Mapped[int] = mapped_column(nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Value(Model):
|
|
||||||
__tablename__ = "vValues"
|
|
||||||
id: Mapped[int] = mapped_column( primary_key=True)
|
|
||||||
id_object: Mapped[UUID] = mapped_column(ForeignKey("vObjects.id"))
|
|
||||||
id_param:Mapped[int] = mapped_column(nullable=False)
|
|
||||||
value:Mapped[str|None] = mapped_column(String(250))
|
|
||||||
date_s: Mapped[date] = mapped_column(Date,nullable=False)
|
|
||||||
date_po: Mapped[date|None] = mapped_column(Date,nullable=True)
|
|
||||||
id_user:Mapped[int] = mapped_column(nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
# class Parameter(Model):
|
|
||||||
# __tablename__ = "vParameters"
|
|
@ -1,3 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
import asyncio
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
|||||||
from sqlalchemy import select, update, delete
|
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from backend_fastapi.stored import exec_procedure
|
|
||||||
import backend_fastapi.models as models
|
|
||||||
from .database import async_session
|
|
||||||
import backend_fastapi.schemas as schemas
|
|
||||||
|
|
||||||
|
|
||||||
async def add_role(role: schemas.RoleCreate):
|
|
||||||
async with async_session() as session:
|
|
||||||
model = models.Role(name = role.name)
|
|
||||||
session.add(model)
|
|
||||||
await session.flush()
|
|
||||||
await session.commit()
|
|
||||||
return model
|
|
||||||
|
|
||||||
async def get_role_all(limit:int=10,page:int=0):
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.scalars(select(models.Role).order_by(models.Role.id).limit(limit).offset(page*limit))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def delete_role(id: int):
|
|
||||||
async with async_session() as session:
|
|
||||||
data = await session.scalars(select(models.Role).filter(models.Role.id == id))
|
|
||||||
result = data.one_or_none()
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
await session.execute(delete(models.Role).filter(models.Role.id == id))
|
|
||||||
await session.commit()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def update_role(role: schemas.Role, id: int):
|
|
||||||
async with async_session() as session:
|
|
||||||
data = await session.scalars(select(models.Role).filter(models.Role.id == id))
|
|
||||||
result = data.one_or_none()
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
|
|
||||||
query = update(models.Role).filter(models.Role.id == id).values(name = role.name)
|
|
||||||
await session.execute(query)
|
|
||||||
await session.commit()
|
|
||||||
return {f"{id=} был обновлен"}
|
|
||||||
|
|
||||||
async def add_user(user: schemas.UserCreate):
|
|
||||||
async with async_session() as session:
|
|
||||||
model = models.User(
|
|
||||||
**user.model_dump()
|
|
||||||
)
|
|
||||||
session.add(model)
|
|
||||||
await session.flush()
|
|
||||||
await session.commit()
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
async def get_users(limit:int=10,page:int=0):
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.scalars(select(models.User).order_by(models.User.id).limit(limit).offset(page*limit))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def delete_user(id: int):
|
|
||||||
async with async_session() as session:
|
|
||||||
data = await session.scalars(select(models.User).filter(models.User.id == id))
|
|
||||||
result = data.one_or_none()
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
await session.execute(delete(models.User).filter(models.User.id == id))
|
|
||||||
await session.commit()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def update_user(user: schemas.User, id: int):
|
|
||||||
async with async_session() as session:
|
|
||||||
data = await session.scalars(select(models.User).filter(models.User.id == id))
|
|
||||||
result = data.one_or_none()
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
query = update(models.User).filter(models.User.id == id)\
|
|
||||||
.values(
|
|
||||||
firstname = user.firstname,
|
|
||||||
lastname = user.lastname,
|
|
||||||
email = user.email,
|
|
||||||
hashed_password= user.hashed_password,
|
|
||||||
is_active = user.is_active,
|
|
||||||
role_id = user.role_id,
|
|
||||||
)
|
|
||||||
await session.execute(query)
|
|
||||||
await session.commit()
|
|
||||||
return {f"{id=} был обновлен"}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_objects():
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.scalars(select(models.Object))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_values():
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.scalars(select(models.Value))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stored_roles():
|
|
||||||
return await exec_procedure('get_roles')
|
|
@ -1,46 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
||||||
from uuid import UUID
|
|
||||||
from datetime import date
|
|
||||||
class RoleBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
firstname: str
|
|
||||||
lastname: str
|
|
||||||
email: EmailStr
|
|
||||||
hashed_password: str
|
|
||||||
role_id: int
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
class RoleCreate(RoleBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class User(UserBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int
|
|
||||||
|
|
||||||
class Role(RoleBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int
|
|
||||||
|
|
||||||
|
|
||||||
class Object(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: UUID
|
|
||||||
id_city: int
|
|
||||||
id_parent: UUID | None
|
|
||||||
year: int | None
|
|
||||||
|
|
||||||
|
|
||||||
class Value(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int
|
|
||||||
id_object: UUID
|
|
||||||
id_param: int
|
|
||||||
value: str|None
|
|
||||||
date_s: date
|
|
||||||
date_po: date|None
|
|
||||||
id_user: int
|
|
@ -1,23 +0,0 @@
|
|||||||
from sqlalchemy import text
|
|
||||||
from .database import async_session
|
|
||||||
|
|
||||||
async def exec_procedure(proc_name, params:dict = None, database: str = None):
|
|
||||||
async with async_session() as session:
|
|
||||||
sql_params = ""
|
|
||||||
if params:
|
|
||||||
sql_params = ",".join([f"@{key} = :{key}"
|
|
||||||
for key, value in params.items()])
|
|
||||||
dbstr = ""
|
|
||||||
if database:
|
|
||||||
dbstr = f"[{database}]."
|
|
||||||
sql_string = text(f'''
|
|
||||||
DECLARE @return_value int;
|
|
||||||
EXEC @return_value = {dbstr}[dbo].[{proc_name}] {sql_params};
|
|
||||||
SELECT 'Return Value' = @return_value;
|
|
||||||
''')
|
|
||||||
datas = await session.execute(
|
|
||||||
sql_string, params)
|
|
||||||
await session.commit()
|
|
||||||
return [dict(data._mapping) for data in datas.fetchall()]
|
|
||||||
|
|
||||||
|
|
14
client/.env.example
Normal file
14
client/.env.example
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# API авторизации
|
||||||
|
VITE_API_AUTH_URL=
|
||||||
|
|
||||||
|
# API info
|
||||||
|
VITE_API_INFO_URL=
|
||||||
|
|
||||||
|
# API fuel
|
||||||
|
VITE_API_FUEL_URL=
|
||||||
|
|
||||||
|
# API servers
|
||||||
|
VITE_API_SERVERS_URL=
|
||||||
|
|
||||||
|
# API EMS
|
||||||
|
VITE_API_EMS_URL=
|
@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
stats.html
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
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.
@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo2.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>ИС</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
12589
client/package-lock.json
generated
Normal file
12589
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
client/package.json
Normal file
83
client/package.json
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@fontsource/inter": "^5.0.19",
|
||||||
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
|
"@js-preview/docx": "^1.6.2",
|
||||||
|
"@js-preview/excel": "^1.7.8",
|
||||||
|
"@js-preview/pdf": "^2.0.2",
|
||||||
|
"@mantine/carousel": "^7.13.0",
|
||||||
|
"@mantine/charts": "^7.13.0",
|
||||||
|
"@mantine/code-highlight": "^7.13.0",
|
||||||
|
"@mantine/core": "^7.13.0",
|
||||||
|
"@mantine/dates": "^7.13.0",
|
||||||
|
"@mantine/dropzone": "^7.13.0",
|
||||||
|
"@mantine/form": "^7.13.0",
|
||||||
|
"@mantine/hooks": "^7.13.0",
|
||||||
|
"@mantine/modals": "^7.13.0",
|
||||||
|
"@mantine/notifications": "^7.13.0",
|
||||||
|
"@mantine/nprogress": "^7.13.0",
|
||||||
|
"@mantine/spotlight": "^7.13.0",
|
||||||
|
"@mantine/tiptap": "^7.13.0",
|
||||||
|
"@tabler/icons-react": "^3.17.0",
|
||||||
|
"@tanstack/react-table": "^8.20.5",
|
||||||
|
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||||
|
"@tiptap/extension-link": "^2.7.3",
|
||||||
|
"@tiptap/react": "^2.7.3",
|
||||||
|
"@tiptap/starter-kit": "^2.7.3",
|
||||||
|
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.5.0",
|
||||||
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"file-type": "^19.0.0",
|
||||||
|
"ol": "^10.0.0",
|
||||||
|
"ol-ext": "^4.0.23",
|
||||||
|
"proj4": "^2.12.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.52.0",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"swr": "^2.2.5",
|
||||||
|
"uuid": "^11.0.3",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/proj4": "^2.5.5",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
|
"sass-embedded": "^1.79.5",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
14
client/postcss.config.js
Normal file
14
client/postcss.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
34
client/public/logo1.svg
Normal file
34
client/public/logo1.svg
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<svg width="252" height="252" viewBox="0 0 252 252" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61 36C47.1929 36 36 47.1929 36 61V191C36 204.807 47.1929 216 61 216H191C204.807 216 216 204.807 216 191V61C216 47.1929 204.807 36 191 36H61ZM73 54C61.9543 54 53 62.9543 53 74V179C53 190.046 61.9543 199 73 199H178C189.046 199 198 190.046 198 179V74C198 62.9543 189.046 54 178 54H73Z" fill="#D9D9D9"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61 36C47.1929 36 36 47.1929 36 61V191C36 204.807 47.1929 216 61 216H191C204.807 216 216 204.807 216 191V61C216 47.1929 204.807 36 191 36H61ZM73 54C61.9543 54 53 62.9543 53 74V179C53 190.046 61.9543 199 73 199H178C189.046 199 198 190.046 198 179V74C198 62.9543 189.046 54 178 54H73Z" fill="url(#paint0_linear_2_2)"/>
|
||||||
|
<path d="M67.4996 81.0781L66.5525 172H115.5V126C115.5 119.096 121.096 113.5 128 113.5H236V80H67.434C67.481 80.3523 67.5034 80.7123 67.4996 81.0781Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M67.4996 81.0781L66.5525 172H115.5V126C115.5 119.096 121.096 113.5 128 113.5H236V80H67.434C67.481 80.3523 67.5034 80.7123 67.4996 81.0781Z" fill="url(#paint1_linear_2_2)"/>
|
||||||
|
<path d="M236 138.5H158.178L191.678 172H236V138.5Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M236 138.5H158.178L191.678 172H236V138.5Z" fill="url(#paint2_linear_2_2)"/>
|
||||||
|
<path d="M156.322 172L140.5 156.178V172H156.322Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M156.322 172L140.5 156.178V172H156.322Z" fill="url(#paint3_linear_2_2)"/>
|
||||||
|
<path d="M15 172H51.5517L52.5004 80.9219C52.5037 80.6096 52.526 80.3019 52.5662 80H15V172Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M15 172H51.5517L52.5004 80.9219C52.5037 80.6096 52.526 80.3019 52.5662 80H15V172Z" fill="url(#paint4_linear_2_2)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#007B91"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#007B91"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#007B91"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#007B91"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#007B91"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
34
client/public/logo2.svg
Normal file
34
client/public/logo2.svg
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<svg width="252" height="252" viewBox="0 0 252 252" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61 36C47.1929 36 36 47.1929 36 61V191C36 204.807 47.1929 216 61 216H191C204.807 216 216 204.807 216 191V61C216 47.1929 204.807 36 191 36H61ZM73 54C61.9543 54 53 62.9543 53 74V179C53 190.046 61.9543 199 73 199H178C189.046 199 198 190.046 198 179V74C198 62.9543 189.046 54 178 54H73Z" fill="#D9D9D9"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61 36C47.1929 36 36 47.1929 36 61V191C36 204.807 47.1929 216 61 216H191C204.807 216 216 204.807 216 191V61C216 47.1929 204.807 36 191 36H61ZM73 54C61.9543 54 53 62.9543 53 74V179C53 190.046 61.9543 199 73 199H178C189.046 199 198 190.046 198 179V74C198 62.9543 189.046 54 178 54H73Z" fill="url(#paint0_linear_2_2)"/>
|
||||||
|
<path d="M67.4996 81.0781L66.5525 172H115.5V126C115.5 119.096 121.096 113.5 128 113.5H236V80H67.434C67.481 80.3523 67.5034 80.7123 67.4996 81.0781Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M67.4996 81.0781L66.5525 172H115.5V126C115.5 119.096 121.096 113.5 128 113.5H236V80H67.434C67.481 80.3523 67.5034 80.7123 67.4996 81.0781Z" fill="url(#paint1_linear_2_2)"/>
|
||||||
|
<path d="M236 138.5H158.178L191.678 172H236V138.5Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M236 138.5H158.178L191.678 172H236V138.5Z" fill="url(#paint2_linear_2_2)"/>
|
||||||
|
<path d="M156.322 172L140.5 156.178V172H156.322Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M156.322 172L140.5 156.178V172H156.322Z" fill="url(#paint3_linear_2_2)"/>
|
||||||
|
<path d="M15 172H51.5517L52.5004 80.9219C52.5037 80.6096 52.526 80.3019 52.5662 80H15V172Z" fill="#D9D9D9"/>
|
||||||
|
<path d="M15 172H51.5517L52.5004 80.9219C52.5037 80.6096 52.526 80.3019 52.5662 80H15V172Z" fill="url(#paint4_linear_2_2)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#00D9FF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#00D9FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#00D9FF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#00D9FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#00D9FF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#00D9FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#00D9FF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#00D9FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2_2" x1="236" y1="36" x2="15" y2="216" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#00D9FF" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#00D9FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
2418903
client/public/sakha_republic.geojson
Normal file
2418903
client/public/sakha_republic.geojson
Normal file
File diff suppressed because it is too large
Load Diff
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
53
client/src/App.tsx
Normal file
53
client/src/App.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router-dom"
|
||||||
|
import NotFound from "./pages/NotFound"
|
||||||
|
import MainLayout from "./layouts/MainLayout"
|
||||||
|
import { initAuth, useAuthStore } from "./store/auth"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import DashboardLayout from "./layouts/DashboardLayout"
|
||||||
|
import { Box, Loader } from "@mantine/core"
|
||||||
|
import { pages } from "./constants/app"
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box w='100%' h='100vh'>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||||
|
<Route key={`ml-${index}`} path={page.path} element={page.component} />
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={auth.isAuthenticated ? <DashboardLayout></DashboardLayout> : <Navigate to={"/auth/signin"} />}>
|
||||||
|
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||||
|
<Route key={`dl-${index}`} path={page.path} element={page.component} />
|
||||||
|
))}
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
23
client/src/actions/map.ts
Normal file
23
client/src/actions/map.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Coordinate } from "ol/coordinate";
|
||||||
|
import { IGeometryType } from "../interfaces/map";
|
||||||
|
|
||||||
|
export const uploadCoordinates = async (coordinates: Coordinate[], type: IGeometryType) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_EMS_URL}/nodes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ coordinates, object_id: 1, type: type }) // Replace with actual object_id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Node created:', data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to upload coordinates');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
23
client/src/components/CardInfo/CardInfo.tsx
Normal file
23
client/src/components/CardInfo/CardInfo.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Divider, Flex, Text } from '@mantine/core';
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
interface CardInfoProps extends PropsWithChildren {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardInfo({
|
||||||
|
children,
|
||||||
|
label
|
||||||
|
}: CardInfoProps) {
|
||||||
|
return (
|
||||||
|
<Flex direction='column' gap='sm' p='sm'>
|
||||||
|
<Text fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
26
client/src/components/CardInfo/CardInfoChip.tsx
Normal file
26
client/src/components/CardInfo/CardInfoChip.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Chip } from '@mantine/core';
|
||||||
|
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}
|
||||||
|
color={status ? "success" : "error"}
|
||||||
|
variant='outline'
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal file
22
client/src/components/CardInfo/CardInfoLabel.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Flex, Text } from '@mantine/core';
|
||||||
|
interface CardInfoLabelProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardInfoLabel({
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: CardInfoLabelProps) {
|
||||||
|
return (
|
||||||
|
<Flex justify='space-between' align='center'>
|
||||||
|
<Text>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fw={600}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
51
client/src/components/CustomTable.module.scss
Normal file
51
client/src/components/CustomTable.module.scss
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
.resize_handler {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 5px;
|
||||||
|
background: #27bbff;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize_handler:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr {
|
||||||
|
display: flex;
|
||||||
|
//width: 100%;
|
||||||
|
//max-width: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.th,
|
||||||
|
.td {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thead {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
111
client/src/components/CustomTable.tsx
Normal file
111
client/src/components/CustomTable.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Input, Table } from '@mantine/core';
|
||||||
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import styles from './CustomTable.module.scss'
|
||||||
|
|
||||||
|
// Sample data
|
||||||
|
|
||||||
|
type DataType = {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
age: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define columns
|
||||||
|
const columns: ColumnDef<DataType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'age',
|
||||||
|
header: 'Age',
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CustomTable = () => {
|
||||||
|
const [data, setData] = useState<DataType[]>([
|
||||||
|
{ id: 1, name: 'John Doe', age: 25 },
|
||||||
|
{ id: 2, name: 'Jane Smith', age: 30 },
|
||||||
|
{ id: 3, name: 'Sam Green', age: 22 },
|
||||||
|
]);
|
||||||
|
const [editingCell, setEditingCell] = useState<{ rowIndex: string | number | null, columnId: string | number | null }>({ rowIndex: null, columnId: null });
|
||||||
|
|
||||||
|
const tableColumns = useMemo<ColumnDef<typeof data[0]>[]>(() => columns, []);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: tableColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
columnResizeMode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to handle cell edit
|
||||||
|
const handleEditCell = (
|
||||||
|
rowIndex: number,
|
||||||
|
columnId: keyof DataType,
|
||||||
|
value: DataType[keyof DataType]
|
||||||
|
) => {
|
||||||
|
const updatedData = [...data];
|
||||||
|
(updatedData[rowIndex][columnId] as DataType[keyof DataType]) = value;
|
||||||
|
setData(updatedData);
|
||||||
|
//setEditingCell({ rowIndex: null, columnId: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table striped withColumnBorders highlightOnHover className={styles.table}>
|
||||||
|
<Table.Thead className={styles.thead}>
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<Table.Tr key={headerGroup.id} className={styles.tr}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<Table.Th key={header.id} className={styles.th} w={header.getSize()}>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
<div
|
||||||
|
className={styles.resize_handler}
|
||||||
|
onMouseDown={header.getResizeHandler()} //for desktop
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody className={styles.tbody}>
|
||||||
|
{table.getRowModel().rows.map((row, rowIndex) => (
|
||||||
|
<Table.Tr key={row.id} className={styles.tr}>
|
||||||
|
{row.getVisibleCells().map(cell => {
|
||||||
|
const isEditing = editingCell.rowIndex === rowIndex && editingCell.columnId === cell.column.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Td
|
||||||
|
key={cell.id}
|
||||||
|
onDoubleClick={() => setEditingCell({ rowIndex, columnId: cell.column.id })}
|
||||||
|
style={{ width: cell.column.getSize() }}
|
||||||
|
className={styles.td}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data[rowIndex][cell.column.id as keyof DataType]}
|
||||||
|
onChange={(e) => handleEditCell(rowIndex, (cell.column.id as keyof DataType), e.target.value)}
|
||||||
|
onBlur={() => setEditingCell({ rowIndex: null, columnId: null })}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTable;
|
292
client/src/components/FolderViewer.tsx
Normal file
292
client/src/components/FolderViewer.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { useDocuments, useDownload, useFolders } from '../hooks/swrHooks'
|
||||||
|
import { IDocument, IDocumentFolder } from '../interfaces/documents'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import DocumentService from '../services/DocumentService'
|
||||||
|
import { mutate } from 'swr'
|
||||||
|
import FileViewer from './modals/FileViewer'
|
||||||
|
import { ActionIcon, Anchor, Breadcrumbs, Button, Divider, FileButton, Flex, Loader, MantineStyleProp, RingProgress, ScrollAreaAutosize, Stack, Table, Text } from '@mantine/core'
|
||||||
|
import { IconCancel, IconDownload, IconFile, IconFileFilled, IconFilePlus, IconFileUpload, IconFolderFilled, IconX } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
interface DocumentProps {
|
||||||
|
doc: IDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileItemStyle: MantineStyleProp = {
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: 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, doc.name])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!isLoading) {
|
||||||
|
setShouldFetch(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant='subtle'>
|
||||||
|
{isLoading ?
|
||||||
|
<Loader size='sm' />
|
||||||
|
:
|
||||||
|
<IconDownload />
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [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 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 = (files: File[] | null) => {
|
||||||
|
if (files !== null) {
|
||||||
|
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 (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p={'sm'}>
|
||||||
|
{fileViewerModal &&
|
||||||
|
<FileViewer
|
||||||
|
open={fileViewerModal}
|
||||||
|
setOpen={setFileViewerModal}
|
||||||
|
currentFileNo={currentFileNo}
|
||||||
|
setCurrentFileNo={setCurrentFileNo}
|
||||||
|
docs={documents}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<Breadcrumbs>
|
||||||
|
<Anchor
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentFolder(null)
|
||||||
|
setBreadcrumbs([])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Главная
|
||||||
|
</Anchor>
|
||||||
|
{breadcrumbs.map((breadcrumb, index) => (
|
||||||
|
<Anchor
|
||||||
|
key={breadcrumb.id}
|
||||||
|
onClick={() => handleBreadcrumbClick(index)}
|
||||||
|
>
|
||||||
|
{breadcrumb.name}
|
||||||
|
</Anchor>
|
||||||
|
))}
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
{currentFolder &&
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<Flex direction='column' gap='sm' p='sm' style={{
|
||||||
|
border: filesToUpload.length > 0 ? '1px dashed gray' : 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<Flex gap='sm'>
|
||||||
|
<FileButton multiple onChange={handleFileInput}>
|
||||||
|
{(props) => <Button variant='filled' leftSection={isUploading ? <Loader /> : <IconFilePlus />} {...props}>Добавить</Button>}
|
||||||
|
</FileButton>
|
||||||
|
|
||||||
|
{filesToUpload.length > 0 &&
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant='filled'
|
||||||
|
leftSection={isUploading ? <RingProgress sections={[{ value: uploadProgress, color: 'blue' }]} /> : <IconFileUpload />}
|
||||||
|
onClick={uploadFiles}
|
||||||
|
>
|
||||||
|
Загрузить все
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
leftSection={<IconCancel />}
|
||||||
|
onClick={() => {
|
||||||
|
setFilesToUpload([])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{filesToUpload.length > 0 &&
|
||||||
|
<Flex direction='column'>
|
||||||
|
{filesToUpload.map((file, index) => (
|
||||||
|
<Flex key={index} p='8px'>
|
||||||
|
<Flex gap='sm' direction='row' align='center'>
|
||||||
|
<IconFile />
|
||||||
|
<Text>{file.name}</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<ActionIcon onClick={() => {
|
||||||
|
setFilesToUpload(prev => {
|
||||||
|
return prev.filter((_, i) => i != index)
|
||||||
|
})
|
||||||
|
}} ml='auto' variant='subtle'>
|
||||||
|
<IconX />
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
bg={dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'}
|
||||||
|
highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Название</Table.Th>
|
||||||
|
<Table.Th p={0}>Дата создания</Table.Th>
|
||||||
|
<Table.Th p={0}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
|
||||||
|
<Table.Tbody>
|
||||||
|
{currentFolder ? (
|
||||||
|
documents?.map((doc: IDocument, index: number) => (
|
||||||
|
<Table.Tr key={doc.id} onClick={() => handleDocumentClick(index)} style={{ cursor: 'pointer' }}>
|
||||||
|
<Table.Td p={0}>
|
||||||
|
<Flex style={FileItemStyle}>
|
||||||
|
<IconFileFilled />
|
||||||
|
{doc.name}
|
||||||
|
</Flex>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td p={0}>
|
||||||
|
{new Date(doc.create_date).toLocaleDateString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td p={0}>
|
||||||
|
<ItemDocument
|
||||||
|
doc={doc}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
folders?.map((folder: IDocumentFolder) => (
|
||||||
|
<Table.Tr key={folder.id} onClick={() => handleFolderClick(folder)} style={{ cursor: 'pointer' }}>
|
||||||
|
<Table.Td p={0}>
|
||||||
|
<Flex style={FileItemStyle}>
|
||||||
|
<IconFolderFilled />
|
||||||
|
{folder.name}
|
||||||
|
</Flex>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td p={0} align='left'>
|
||||||
|
{new Date(folder.create_date).toLocaleDateString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td p={0}>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Stack>
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
94
client/src/components/FormFields.tsx
Normal file
94
client/src/components/FormFields.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||||
|
import { CreateField } from '../interfaces/create'
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Button, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
submitHandler?: (data: any) => Promise<AxiosResponse<any, any>>;
|
||||||
|
fields: CreateField[];
|
||||||
|
submitButtonText?: string;
|
||||||
|
mutateHandler?: any;
|
||||||
|
defaultValues?: {};
|
||||||
|
watchValues?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFields({
|
||||||
|
title = '',
|
||||||
|
submitHandler,
|
||||||
|
fields,
|
||||||
|
submitButtonText = 'Сохранить',
|
||||||
|
mutateHandler,
|
||||||
|
defaultValues
|
||||||
|
}: 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 gap='sm' w='100%'>
|
||||||
|
{title.length > 0 &&
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
{fields.map((field: CreateField) => {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
key={field.key}
|
||||||
|
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
|
||||||
|
//placeholder="Your name"
|
||||||
|
type={field.inputType ? field.inputType : 'text'}
|
||||||
|
{...register(field.key, {
|
||||||
|
required: field.required ? `${field.headerName} обязателен` : false,
|
||||||
|
validate: (val: string | boolean) => {
|
||||||
|
if (field.watch) {
|
||||||
|
if (watch(field.watch) != val) {
|
||||||
|
return field.watchMessage || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
radius="md"
|
||||||
|
required={field.required || false}
|
||||||
|
error={errors[field.key]?.message}
|
||||||
|
errorProps={errors[field.key]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type='submit'>
|
||||||
|
{isSubmitting ? <Loader size={16} /> : submitButtonText}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormFields
|
41
client/src/components/ServerData.tsx
Normal file
41
client/src/components/ServerData.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { IServer } from '../interfaces/servers'
|
||||||
|
import { useServerIps } from '../hooks/swrHooks'
|
||||||
|
import { Flex, Table } from '@mantine/core'
|
||||||
|
|
||||||
|
function ServerData({ id }: IServer) {
|
||||||
|
const { serverIps } = useServerIps(id, 0, 10)
|
||||||
|
|
||||||
|
const serverIpsColumns = [
|
||||||
|
{ 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 (
|
||||||
|
<Flex direction='column' p='sm'>
|
||||||
|
{serverIps &&
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{serverIpsColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
{serverIpsColumns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{serverIps ? serverIps[column.field] : ''}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerData
|
73
client/src/components/ServerHardware.tsx
Normal file
73
client/src/components/ServerHardware.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useHardwares, useServers } from '../hooks/swrHooks'
|
||||||
|
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
|
||||||
|
import { IServer } from '../interfaces/servers'
|
||||||
|
|
||||||
|
export default function ServerHardware() {
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | undefined>(undefined)
|
||||||
|
const { servers } = useServers()
|
||||||
|
|
||||||
|
const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption, 0, 10)
|
||||||
|
|
||||||
|
const hardwareColumns = [
|
||||||
|
{ 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 (
|
||||||
|
<>
|
||||||
|
<form>
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Сервер"
|
||||||
|
flex={'1'}
|
||||||
|
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||||
|
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||||
|
//onChange={(value) => setSearch(value)}
|
||||||
|
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||||
|
rightSection={
|
||||||
|
//search !== '' &&
|
||||||
|
(
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
//setSearch('')
|
||||||
|
setSelectedOption(undefined)
|
||||||
|
}}
|
||||||
|
aria-label="Clear value"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//value={search}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{serversLoading ?
|
||||||
|
<Loader />
|
||||||
|
:
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{hardwareColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
{hardwareColumns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{hardwares ? hardwares[column.field] : ''}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
77
client/src/components/ServerIpsView.tsx
Normal file
77
client/src/components/ServerIpsView.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useServerIps, useServers } from '../hooks/swrHooks'
|
||||||
|
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
|
||||||
|
import { IServer } from '../interfaces/servers'
|
||||||
|
|
||||||
|
export default function ServerIpsView() {
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||||
|
const { servers } = useServers()
|
||||||
|
|
||||||
|
const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption, 0, 10)
|
||||||
|
|
||||||
|
const serverIpsColumns = [
|
||||||
|
{ 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(serverIps)
|
||||||
|
}, [serverIps])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form>
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Сервер"
|
||||||
|
flex={'1'}
|
||||||
|
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||||
|
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||||
|
//onChange={(value) => setSearch(value)}
|
||||||
|
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||||
|
rightSection={
|
||||||
|
//search !== '' &&
|
||||||
|
(
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
//setSearch('')
|
||||||
|
setSelectedOption(null)
|
||||||
|
}}
|
||||||
|
aria-label="Clear value"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//value={search}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{serversLoading ?
|
||||||
|
<Loader />
|
||||||
|
:
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{serverIpsColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr
|
||||||
|
//bg={selectedRows.includes(element.position) ? 'var(--mantine-color-blue-light)' : undefined}
|
||||||
|
>
|
||||||
|
{serverIpsColumns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
43
client/src/components/ServerStorages.tsx
Normal file
43
client/src/components/ServerStorages.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { IRegion } from '../interfaces/fuel'
|
||||||
|
import { useStorages } from '../hooks/swrHooks'
|
||||||
|
import { Loader, Table } from '@mantine/core'
|
||||||
|
|
||||||
|
export default function ServerStorage() {
|
||||||
|
const [selectedOption] = useState<IRegion | null>(null)
|
||||||
|
|
||||||
|
const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10)
|
||||||
|
|
||||||
|
const storageColumns = [
|
||||||
|
{ 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 (
|
||||||
|
<>
|
||||||
|
{serversLoading ?
|
||||||
|
<Loader />
|
||||||
|
:
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{storageColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
{storageColumns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{storages ? storages[column.field] : ''}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
77
client/src/components/ServersView.tsx
Normal file
77
client/src/components/ServersView.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { IRegion } from '../interfaces/fuel'
|
||||||
|
import { useRegions, useServers } from '../hooks/swrHooks'
|
||||||
|
import { useDebounce } from '@uidotdev/usehooks'
|
||||||
|
import { Autocomplete, CloseButton, Table } from '@mantine/core'
|
||||||
|
|
||||||
|
export default function ServersView() {
|
||||||
|
const [search, setSearch] = useState<string | undefined>("")
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 500)
|
||||||
|
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const { regions } = useRegions(10, 1, debouncedSearch)
|
||||||
|
|
||||||
|
const { servers } = useServers(selectedOption, 0, 10)
|
||||||
|
|
||||||
|
const serversColumns = [
|
||||||
|
//{ field: 'id', headerName: 'ID', type: "number" },
|
||||||
|
{
|
||||||
|
field: 'name', headerName: 'Название', type: "string", editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'region_id',
|
||||||
|
editable: true,
|
||||||
|
headerName: 'region_id',
|
||||||
|
flex: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form>
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Район"
|
||||||
|
flex={'1'}
|
||||||
|
data={regions ? regions.map((item: IRegion) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||||
|
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||||
|
onChange={(value) => setSearch(value)}
|
||||||
|
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||||
|
rightSection={
|
||||||
|
search !== '' && (
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setSelectedOption(null)
|
||||||
|
}}
|
||||||
|
aria-label="Clear value"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{serversColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
<Table.Tr>
|
||||||
|
{serversColumns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
125
client/src/components/Tree/ObjectTree.tsx
Normal file
125
client/src/components/Tree/ObjectTree.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { fetcher } from '../../http/axiosInstance'
|
||||||
|
import { BASE_URL } from '../../constants'
|
||||||
|
import { Accordion, NavLink, Text } from '@mantine/core';
|
||||||
|
import { setCurrentObjectId, useObjectsStore } from '../../store/objects';
|
||||||
|
import { IconChevronDown } from '@tabler/icons-react';
|
||||||
|
import { setSelectedObjectType } from '../../store/map';
|
||||||
|
|
||||||
|
const ObjectTree = () => {
|
||||||
|
const { selectedDistrict, selectedYear } = useObjectsStore()
|
||||||
|
|
||||||
|
const [existingCount, setExistingCount] = useState(0)
|
||||||
|
const [planningCount, setPlanningCount] = useState(0)
|
||||||
|
|
||||||
|
const { data: existingObjectsList } = useSWR(
|
||||||
|
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=0` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
let count = 0
|
||||||
|
res.forEach(el => {
|
||||||
|
count = count + el.count
|
||||||
|
})
|
||||||
|
setExistingCount(count)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: planningObjectsList } = useSWR(
|
||||||
|
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=1` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
let count = 0
|
||||||
|
res.forEach(el => {
|
||||||
|
count = count + el.count
|
||||||
|
})
|
||||||
|
setPlanningCount(count)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectedDistrict) {
|
||||||
|
return (
|
||||||
|
<Accordion multiple chevronPosition='left'>
|
||||||
|
<TypeTree label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} />
|
||||||
|
<TypeTree label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} />
|
||||||
|
</Accordion>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Text size='xs'>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeTreeProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
objectList: unknown;
|
||||||
|
planning: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TypeTree = ({
|
||||||
|
label,
|
||||||
|
objectList,
|
||||||
|
count,
|
||||||
|
planning
|
||||||
|
}: TypeTreeProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink p={0} label={`${label} ${count ? `(${count})` : ''}`}>
|
||||||
|
{Array.isArray(objectList) && objectList.map(list => (
|
||||||
|
<ObjectList key={list.id} label={list.name} id={list.id} planning={planning} count={list.count} />
|
||||||
|
))}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IObjectList {
|
||||||
|
label: string;
|
||||||
|
id: number;
|
||||||
|
planning: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectList = ({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
planning,
|
||||||
|
count
|
||||||
|
}: IObjectList) => {
|
||||||
|
const { selectedDistrict, selectedYear } = useObjectsStore()
|
||||||
|
|
||||||
|
const { data } = useSWR(
|
||||||
|
selectedDistrict && selectedYear ? `/general/objects/list?type=${id}&city_id=${selectedDistrict}&year=${selectedYear}&planning=${planning}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink onClick={() => {
|
||||||
|
setSelectedObjectType(id)
|
||||||
|
}} rightSection={<IconChevronDown size={14} />} p={0} label={`${label} ${count ? `(${count})` : ''}`}>
|
||||||
|
{Array.isArray(data) && data.map((type) => (
|
||||||
|
<NavLink key={type.object_id} label={type.caption ? type.caption : 'Без названия'} p={0} onClick={() => setCurrentObjectId(type.object_id)} />
|
||||||
|
))}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectTree
|
1208
client/src/components/map/MapComponent.tsx
Normal file
1208
client/src/components/map/MapComponent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
client/src/components/map/MapConstants.ts
Normal file
9
client/src/components/map/MapConstants.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { transform } from "ol/proj"
|
||||||
|
|
||||||
|
const mapExtent = [11388546.533293726, 7061866.113051185, 18924313.434856508, 13932243.11199202]
|
||||||
|
const mapCenter = transform([129.7578941, 62.030804], 'EPSG:4326', 'EPSG:3857')
|
||||||
|
|
||||||
|
export {
|
||||||
|
mapExtent,
|
||||||
|
mapCenter
|
||||||
|
}
|
61
client/src/components/map/MapLayers/MapLayers.tsx
Normal file
61
client/src/components/map/MapLayers/MapLayers.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Checkbox, Flex, NavLink, Slider, Stack } from '@mantine/core'
|
||||||
|
import BaseLayer from 'ol/layer/Base'
|
||||||
|
import Map from 'ol/Map'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface MapLayersProps {
|
||||||
|
map: React.MutableRefObject<Map | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MapLayers = ({
|
||||||
|
map
|
||||||
|
}: MapLayersProps) => {
|
||||||
|
return (
|
||||||
|
<Stack gap='0'>
|
||||||
|
{map.current?.getLayers().getArray() && map.current?.getLayers().getArray().map((layer, index) => (
|
||||||
|
<LayerSetting key={index} index={index} layer={layer} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerSettingProps {
|
||||||
|
index: number;
|
||||||
|
layer: BaseLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayerSetting = ({
|
||||||
|
index,
|
||||||
|
layer
|
||||||
|
}: LayerSettingProps) => {
|
||||||
|
const [opacity, setOpacity] = useState(layer.getOpacity())
|
||||||
|
const [visible, setVisible] = useState(layer.getLayerState().visible)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
layer.setVisible(visible)
|
||||||
|
}, [visible, layer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
layer.setOpacity(opacity)
|
||||||
|
}, [opacity, layer])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex key={`layer-${index}`} gap='xs' align='center'>
|
||||||
|
<Checkbox
|
||||||
|
checked={visible}
|
||||||
|
onChange={(e) => setVisible(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
w='100%'
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.001}
|
||||||
|
value={opacity}
|
||||||
|
onChange={(value) => setOpacity(value)}
|
||||||
|
/>
|
||||||
|
<NavLink p={0} label={layer.get('name')} onClick={() => { console.log(layer.getLayerState()) }} />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapLayers
|
38
client/src/components/map/MapSources.ts
Normal file
38
client/src/components/map/MapSources.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
|
import { get } from "ol/proj";
|
||||||
|
import { register } from "ol/proj/proj4";
|
||||||
|
import { XYZ } from "ol/source";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import proj4 from "proj4";
|
||||||
|
proj4.defs('EPSG:3395', '+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs')
|
||||||
|
register(proj4);
|
||||||
|
|
||||||
|
const yandexProjection = get('EPSG:3395')?.setExtent([-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244]) || 'EPSG:3395'
|
||||||
|
|
||||||
|
const googleMapsSatelliteSource = new XYZ({
|
||||||
|
url: `${import.meta.env.VITE_API_EMS_URL}/tiles/tile/google/{z}/{x}/{y}`,
|
||||||
|
attributions: 'Map data © Google'
|
||||||
|
})
|
||||||
|
|
||||||
|
const yandexMapsSatelliteSource = new XYZ({
|
||||||
|
url: `${import.meta.env.VITE_API_EMS_URL}/tiles/tile/yandex/{z}/{x}/{y}`,
|
||||||
|
attributions: 'Map data © Yandex',
|
||||||
|
projection: yandexProjection,
|
||||||
|
})
|
||||||
|
|
||||||
|
const customMapSource = new XYZ({
|
||||||
|
url: `${import.meta.env.VITE_API_EMS_URL}/tiles/tile/custom/{z}/{x}/{y}`,
|
||||||
|
attributions: 'Custom map data'
|
||||||
|
})
|
||||||
|
|
||||||
|
const regionsLayerSource = new VectorSource({
|
||||||
|
url: 'sakha_republic.geojson',
|
||||||
|
format: new GeoJSON(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export {
|
||||||
|
googleMapsSatelliteSource,
|
||||||
|
yandexMapsSatelliteSource,
|
||||||
|
regionsLayerSource,
|
||||||
|
customMapSource
|
||||||
|
}
|
45
client/src/components/map/MapStatusbar/MapStatusbar.tsx
Normal file
45
client/src/components/map/MapStatusbar/MapStatusbar.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Divider, Flex, rem, Text } from '@mantine/core'
|
||||||
|
import { CSSProperties } from 'react'
|
||||||
|
import { useMapStore } from '../../../store/map';
|
||||||
|
|
||||||
|
interface IMapStatusbarProps {
|
||||||
|
mapControlsStyle: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MapStatusbar = ({
|
||||||
|
mapControlsStyle,
|
||||||
|
}: IMapStatusbarProps) => {
|
||||||
|
const mapState = useMapStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap='sm' p={'4px'} miw={'100%'} fz={'xs'} pos='absolute' bottom='0px' left='0px' style={{ ...mapControlsStyle, borderRadius: 0 }}>
|
||||||
|
<Text fz='xs' w={rem(130)}>
|
||||||
|
x: {mapState.currentCoordinate?.[0]}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz='xs' w={rem(130)}>
|
||||||
|
y: {mapState.currentCoordinate?.[1]}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider orientation='vertical' />
|
||||||
|
|
||||||
|
<Text fz='xs'>
|
||||||
|
Z={mapState.currentZ}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz='xs'>
|
||||||
|
X={mapState.currentX}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz='xs'>
|
||||||
|
Y={mapState.currentY}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fz='xs' ml='auto'>
|
||||||
|
{mapState.statusText}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapStatusbar
|
165
client/src/components/map/MapStyles.ts
Normal file
165
client/src/components/map/MapStyles.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { FeatureLike } from "ol/Feature";
|
||||||
|
import { Text } from "ol/style";
|
||||||
|
import Fill from "ol/style/Fill";
|
||||||
|
import { FlatStyleLike } from "ol/style/flat";
|
||||||
|
import Stroke from "ol/style/Stroke";
|
||||||
|
import Style from "ol/style/Style";
|
||||||
|
import { calculateCenter } from "./mapUtils";
|
||||||
|
import CircleStyle from "ol/style/Circle";
|
||||||
|
import { MultiPoint, Point } from "ol/geom";
|
||||||
|
|
||||||
|
export const highlightStyleYellow = new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'yellow',
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 0, 0.3)',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const highlightStyleRed = new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'red',
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 0, 0.3)',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function overlayStyle(feature: FeatureLike) {
|
||||||
|
const styles = [new Style({
|
||||||
|
geometry: function (feature) {
|
||||||
|
const modifyGeometry = feature.get('modifyGeometry');
|
||||||
|
return modifyGeometry ? modifyGeometry.geometry : feature.getGeometry();
|
||||||
|
},
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#ffcc33',
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 7,
|
||||||
|
fill: new Fill({
|
||||||
|
color: '#ffcc33',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})]
|
||||||
|
const modifyGeometry = feature.get('modifyGeometry')
|
||||||
|
const geometry = modifyGeometry ? modifyGeometry.geometry : feature.getGeometry()
|
||||||
|
const result = calculateCenter(geometry)
|
||||||
|
const center = result.center
|
||||||
|
if (center) {
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
geometry: new Point(center),
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 4,
|
||||||
|
fill: new Fill({
|
||||||
|
color: '#ff3333'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const coordinates = result.coordinates
|
||||||
|
if (coordinates) {
|
||||||
|
const minRadius = result.minRadius
|
||||||
|
const sqDistances = result.sqDistances
|
||||||
|
const rsq = minRadius * minRadius
|
||||||
|
if (Array.isArray(sqDistances)) {
|
||||||
|
const points = coordinates.filter(function (_coordinate, index) {
|
||||||
|
return sqDistances[index] > rsq
|
||||||
|
})
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
geometry: new MultiPoint(points),
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 4,
|
||||||
|
fill: new Fill({
|
||||||
|
color: '#33cc33'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return styles
|
||||||
|
}
|
||||||
|
|
||||||
|
const figureStyle = new Style({
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255,255,255,0.4)'
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#3399CC',
|
||||||
|
width: 1.25
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
font: '12px Calibri,sans-serif',
|
||||||
|
fill: new Fill({ color: '#000' }),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#fff', width: 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineStyle = new Style({
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255,255,255,0.4)'
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#3399CC',
|
||||||
|
width: 1.25
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
font: '12px Calibri,sans-serif',
|
||||||
|
fill: new Fill({ color: '#000' }),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#fff', width: 2
|
||||||
|
}),
|
||||||
|
placement: 'line',
|
||||||
|
overflow: true,
|
||||||
|
//declutterMode: 'obstacle'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawingLayerStyle: FlatStyleLike = {
|
||||||
|
'fill-color': 'rgba(255, 255, 255, 0.2)',
|
||||||
|
//'stroke-color': '#ffcc33',
|
||||||
|
'stroke-color': '#000000',
|
||||||
|
'stroke-width': 2,
|
||||||
|
'circle-radius': 7,
|
||||||
|
'circle-fill-color': '#ffcc33',
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStyle = new Style({
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 255, 0.3)',
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const regionsLayerStyle = new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'blue',
|
||||||
|
width: 1,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 255, 0.1)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export {
|
||||||
|
drawingLayerStyle,
|
||||||
|
selectStyle,
|
||||||
|
regionsLayerStyle,
|
||||||
|
lineStyle,
|
||||||
|
figureStyle
|
||||||
|
}
|
94
client/src/components/map/MapToolbar/MapToolbar.tsx
Normal file
94
client/src/components/map/MapToolbar/MapToolbar.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { ActionIcon, useMantineColorScheme } from '@mantine/core'
|
||||||
|
import { IconArrowBackUp, IconArrowsMove, IconCircle, IconExclamationCircle, IconLine, IconPoint, IconPolygon, IconRuler, IconTransformPoint } from '@tabler/icons-react'
|
||||||
|
import { setCurrentTool, useMapStore } from '../../../store/map';
|
||||||
|
|
||||||
|
interface IToolbarProps {
|
||||||
|
onSave: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MapToolbar = ({
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
}: IToolbarProps) => {
|
||||||
|
const mapState = useMapStore()
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon.Group orientation='vertical' pos='absolute' top='8px' right='8px' style={{ zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
|
||||||
|
<ActionIcon size='lg' variant='transparent' onClick={onSave}>
|
||||||
|
<IconExclamationCircle />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon size='lg' variant='transparent' onClick={onRemove}>
|
||||||
|
<IconArrowBackUp />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Edit' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Edit')
|
||||||
|
}}>
|
||||||
|
<IconTransformPoint />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Point' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Point')
|
||||||
|
}}>
|
||||||
|
<IconPoint />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'LineString' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('LineString')
|
||||||
|
}}>
|
||||||
|
<IconLine />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Polygon' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Polygon')
|
||||||
|
}}>
|
||||||
|
<IconPolygon />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Circle' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Circle')
|
||||||
|
}}>
|
||||||
|
<IconCircle />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Mover' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Mover')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconArrowsMove />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
size='lg'
|
||||||
|
variant={mapState.currentTool === 'Measure' ? 'filled' : 'transparent'}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentTool('Measure')
|
||||||
|
}}>
|
||||||
|
<IconRuler />
|
||||||
|
</ActionIcon>
|
||||||
|
</ActionIcon.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapToolbar
|
34
client/src/components/map/MapTree/MapTreeCheckbox.tsx
Normal file
34
client/src/components/map/MapTree/MapTreeCheckbox.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Checkbox, Group, RenderTreeNodePayload, Text } from "@mantine/core";
|
||||||
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export const MapTreeCheckbox = ({
|
||||||
|
node,
|
||||||
|
expanded,
|
||||||
|
hasChildren,
|
||||||
|
elementProps,
|
||||||
|
tree,
|
||||||
|
}: RenderTreeNodePayload) => {
|
||||||
|
const checked = tree.isNodeChecked(node.value);
|
||||||
|
const indeterminate = tree.isNodeIndeterminate(node.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs" {...elementProps}>
|
||||||
|
<Checkbox.Indicator
|
||||||
|
checked={checked}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
onClick={() => (!checked ? tree.checkNode(node.value) : tree.uncheckNode(node.value))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap={5} onClick={() => tree.toggleExpanded(node.value)}>
|
||||||
|
<Text size="xs">{node.label}</Text>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<IconChevronDown
|
||||||
|
size={14}
|
||||||
|
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
199
client/src/components/map/Measure/MeasureStyles.ts
Normal file
199
client/src/components/map/Measure/MeasureStyles.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { FeatureLike } from "ol/Feature";
|
||||||
|
import { LineString, Point, Polygon } from "ol/geom";
|
||||||
|
import Geometry, { Type } from "ol/geom/Geometry";
|
||||||
|
import { Fill, RegularShape, Stroke, Style, Text } from "ol/style";
|
||||||
|
import CircleStyle from "ol/style/Circle";
|
||||||
|
import { getArea, getLength } from 'ol/sphere'
|
||||||
|
import { Modify } from "ol/interaction";
|
||||||
|
import { getMeasureShowSegments } from "../../../store/map";
|
||||||
|
|
||||||
|
export const style = new Style({
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
lineDash: [10, 10],
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 5,
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const labelStyle = new Style({
|
||||||
|
text: new Text({
|
||||||
|
font: '14px Calibri,sans-serif',
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 1)',
|
||||||
|
}),
|
||||||
|
backgroundFill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}),
|
||||||
|
padding: [3, 3, 3, 3],
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
offsetY: -15,
|
||||||
|
}),
|
||||||
|
image: new RegularShape({
|
||||||
|
radius: 8,
|
||||||
|
points: 3,
|
||||||
|
angle: Math.PI,
|
||||||
|
displacement: [0, 10],
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tipStyle = new Style({
|
||||||
|
text: new Text({
|
||||||
|
font: '12px Calibri,sans-serif',
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 1)',
|
||||||
|
}),
|
||||||
|
backgroundFill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
}),
|
||||||
|
padding: [2, 2, 2, 2],
|
||||||
|
textAlign: 'left',
|
||||||
|
offsetX: 15,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const modifyStyle = new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 5,
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
text: 'Drag to modify',
|
||||||
|
font: '12px Calibri,sans-serif',
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 1)',
|
||||||
|
}),
|
||||||
|
backgroundFill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}),
|
||||||
|
padding: [2, 2, 2, 2],
|
||||||
|
textAlign: 'left',
|
||||||
|
offsetX: 15,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const segmentStyle = new Style({
|
||||||
|
text: new Text({
|
||||||
|
font: '12px Calibri,sans-serif',
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(255, 255, 255, 1)',
|
||||||
|
}),
|
||||||
|
backgroundFill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
}),
|
||||||
|
padding: [2, 2, 2, 2],
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
offsetY: -12,
|
||||||
|
}),
|
||||||
|
image: new RegularShape({
|
||||||
|
radius: 6,
|
||||||
|
points: 3,
|
||||||
|
angle: Math.PI,
|
||||||
|
displacement: [0, 8],
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatLength = function (line: Geometry) {
|
||||||
|
const length = getLength(line);
|
||||||
|
let output;
|
||||||
|
if (length > 100) {
|
||||||
|
output = Math.round((length / 1000) * 100) / 100 + ' km';
|
||||||
|
} else {
|
||||||
|
output = Math.round(length * 100) / 100 + ' m';
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatArea = function (polygon: Geometry) {
|
||||||
|
const area = getArea(polygon);
|
||||||
|
let output;
|
||||||
|
if (area > 10000) {
|
||||||
|
output = Math.round((area / 1000000) * 100) / 100 + ' km\xB2';
|
||||||
|
} else {
|
||||||
|
output = Math.round(area * 100) / 100 + ' m\xB2';
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function measureStyleFunction(
|
||||||
|
feature: FeatureLike,
|
||||||
|
drawType?: Type,
|
||||||
|
tip?: string,
|
||||||
|
setTipPoint?: React.Dispatch<React.SetStateAction<Point | null>>,
|
||||||
|
modify?: React.MutableRefObject<Modify>
|
||||||
|
) {
|
||||||
|
const styles = [];
|
||||||
|
const geometry = feature.getGeometry();
|
||||||
|
const type = geometry?.getType();
|
||||||
|
const segmentStyles = [segmentStyle];
|
||||||
|
|
||||||
|
const segments = getMeasureShowSegments()
|
||||||
|
|
||||||
|
if (!geometry) return
|
||||||
|
|
||||||
|
let point, label, line;
|
||||||
|
if (!drawType || drawType === type || type === 'Point') {
|
||||||
|
styles.push(style);
|
||||||
|
if (type === 'Polygon') {
|
||||||
|
point = (geometry as Polygon).getInteriorPoint();
|
||||||
|
label = formatArea(geometry as Polygon);
|
||||||
|
line = new LineString((geometry as Polygon).getCoordinates()[0]);
|
||||||
|
} else if (type === 'LineString') {
|
||||||
|
point = new Point((geometry as Polygon).getLastCoordinate());
|
||||||
|
label = formatLength(geometry as LineString);
|
||||||
|
line = geometry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (segments && line) {
|
||||||
|
let count = 0;
|
||||||
|
(line as LineString).forEachSegment(function (a, b) {
|
||||||
|
const segment = new LineString([a, b]);
|
||||||
|
const label = formatLength(segment);
|
||||||
|
if (segmentStyles.length - 1 < count) {
|
||||||
|
segmentStyles.push(segmentStyle.clone());
|
||||||
|
}
|
||||||
|
const segmentPoint = new Point(segment.getCoordinateAt(0.5));
|
||||||
|
segmentStyles[count].setGeometry(segmentPoint);
|
||||||
|
segmentStyles[count].getText()?.setText(label);
|
||||||
|
styles.push(segmentStyles[count]);
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
labelStyle.setGeometry(point as Geometry);
|
||||||
|
labelStyle.getText()?.setText(label);
|
||||||
|
styles.push(labelStyle);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
tip &&
|
||||||
|
type === 'Point' &&
|
||||||
|
!modify?.current.getOverlay()?.getSource()?.getFeatures().length
|
||||||
|
) {
|
||||||
|
setTipPoint?.(geometry as Point);
|
||||||
|
tipStyle.getText()?.setText(tip);
|
||||||
|
styles.push(tipStyle);
|
||||||
|
}
|
||||||
|
return styles;
|
||||||
|
}
|
25
client/src/components/map/ObjectData.tsx
Normal file
25
client/src/components/map/ObjectData.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Flex } from '@mantine/core'
|
||||||
|
import { IObjectData, IObjectType } from '../../interfaces/objects'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { fetcher } from '../../http/axiosInstance'
|
||||||
|
import { BASE_URL } from '../../constants'
|
||||||
|
|
||||||
|
const ObjectData = (object_data: IObjectData) => {
|
||||||
|
const { data: typeData } = useSWR(
|
||||||
|
object_data.type ? `/general/types/all` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap='sm' direction='column'>
|
||||||
|
{Array.isArray(typeData) && (typeData.find(type => Number(type.id) === Number(object_data.type)) as IObjectType).name}
|
||||||
|
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectData
|
87
client/src/components/map/ObjectParameter.tsx
Normal file
87
client/src/components/map/ObjectParameter.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import useSWR from 'swr'
|
||||||
|
import { fetcher } from '../../http/axiosInstance'
|
||||||
|
import { BASE_URL } from '../../constants'
|
||||||
|
import { IObjectParam, IParam } from '../../interfaces/objects'
|
||||||
|
import TCBParameter from './TCBParameter'
|
||||||
|
import TableValue from './TableValue'
|
||||||
|
|
||||||
|
interface ObjectParameterProps {
|
||||||
|
showLabel?: boolean,
|
||||||
|
param: IObjectParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectParameter = ({
|
||||||
|
param
|
||||||
|
}: ObjectParameterProps) => {
|
||||||
|
const { data: paramData } = useSWR(
|
||||||
|
`/general/params/all?param_id=${param.id_param}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems).then(res => res[0] as IParam),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Parameter = (type: string, name: string, value: unknown, vtable: string, unit: string | null) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'bit':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='boolean' />
|
||||||
|
)
|
||||||
|
case 'bigint':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='number' />
|
||||||
|
)
|
||||||
|
case 'tinyint':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='number' />
|
||||||
|
)
|
||||||
|
// TODO: Calculate from calc procedures
|
||||||
|
case 'calculate':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='value' />
|
||||||
|
)
|
||||||
|
case 'GTCB':
|
||||||
|
return (
|
||||||
|
<TCBParameter value={value as string} vtable={vtable} name={name} />
|
||||||
|
)
|
||||||
|
case 'TCB':
|
||||||
|
return (
|
||||||
|
<TCBParameter value={value as string} vtable={vtable} name={name} />
|
||||||
|
)
|
||||||
|
case type.match(/varchar\((\d+)\)/)?.input:
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='string' />
|
||||||
|
)
|
||||||
|
case type.match(/numeric\((\d+),(\d+)\)/)?.input:
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='number' unit={unit} />
|
||||||
|
)
|
||||||
|
case 'year':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='number' />
|
||||||
|
)
|
||||||
|
case 'uniqueidentifier':
|
||||||
|
return (
|
||||||
|
<TableValue value={value} name={name} type='value'/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{name}
|
||||||
|
{value as string}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{paramData &&
|
||||||
|
Parameter(paramData.format, paramData.name, param.value, paramData.vtable, paramData.unit)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectParameter
|
@ -0,0 +1,59 @@
|
|||||||
|
import { Flex, LoadingOverlay } from '@mantine/core';
|
||||||
|
import { IObjectParam } from '../../../interfaces/objects';
|
||||||
|
import ObjectParameter from '../ObjectParameter';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { BASE_URL } from '../../../constants';
|
||||||
|
import { fetcher } from '../../../http/axiosInstance';
|
||||||
|
import { useObjectsStore } from '../../../store/objects';
|
||||||
|
|
||||||
|
const ObjectParameters = () => {
|
||||||
|
const { currentObjectId } = useObjectsStore()
|
||||||
|
|
||||||
|
const { data: valuesData, isValidating: valuesValidating } = useSWR(
|
||||||
|
currentObjectId ? `/general/values/all?object_id=${currentObjectId}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap={'sm'} direction={'column'} pos='relative'>
|
||||||
|
<LoadingOverlay visible={valuesValidating} />
|
||||||
|
{Array.isArray(valuesData) &&
|
||||||
|
Object.entries(
|
||||||
|
valuesData.reduce((acc, param) => {
|
||||||
|
if (!acc[param.id_param]) {
|
||||||
|
acc[param.id_param] = [];
|
||||||
|
}
|
||||||
|
acc[param.id_param].push(param);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, IObjectParam[]>)
|
||||||
|
).map(([id_param, params]) => {
|
||||||
|
// Step 1: Sort the parameters by date_s (start date) and date_po (end date)
|
||||||
|
const sortedParams = (params as IObjectParam[]).sort((b, a) => {
|
||||||
|
const dateA = new Date(a.date_s || 0);
|
||||||
|
const dateB = new Date(b.date_s || 0);
|
||||||
|
return dateA.getTime() - dateB.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedParams.length > 1 ? (
|
||||||
|
sortedParams.map((param: IObjectParam) => {
|
||||||
|
if (param.date_po == null) {
|
||||||
|
return (
|
||||||
|
<ObjectParameter key={id_param} param={param} showLabel={false} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ObjectParameter key={id_param} param={sortedParams[0]} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectParameters
|
25
client/src/components/map/RegionSelect.tsx
Normal file
25
client/src/components/map/RegionSelect.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import useSWR from 'swr'
|
||||||
|
import { BASE_URL } from '../../constants'
|
||||||
|
import { fetcher } from '../../http/axiosInstance'
|
||||||
|
import { Flex } from '@mantine/core'
|
||||||
|
|
||||||
|
const RegionSelect = () => {
|
||||||
|
const { data } = useSWR(`/gis/regions/borders`, (url) => fetcher(url, BASE_URL.ems), {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align='center' justify='center'>
|
||||||
|
{Array.isArray(data) &&
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width='100%' height='100vh' transform='scale(1, -1)'>
|
||||||
|
{data.map((el, index) => (
|
||||||
|
<path key={`path-${index}`} d={el.path} fill="white" stroke="black" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegionSelect
|
100
client/src/components/map/TCBParameter.tsx
Normal file
100
client/src/components/map/TCBParameter.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import useSWR from 'swr'
|
||||||
|
import { fetcher } from '../../http/axiosInstance'
|
||||||
|
import { BASE_URL } from '../../constants'
|
||||||
|
import { Text } from '@mantine/core'
|
||||||
|
import TableValue from './TableValue'
|
||||||
|
|
||||||
|
interface ITCBParameterProps {
|
||||||
|
value: string;
|
||||||
|
vtable: string;
|
||||||
|
inactive?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCBParameter = ({
|
||||||
|
value,
|
||||||
|
vtable,
|
||||||
|
name
|
||||||
|
}: ITCBParameterProps) => {
|
||||||
|
|
||||||
|
//Get value
|
||||||
|
const { data: tcbValue } = useSWR(
|
||||||
|
`/general/params/tcb?id=${value}&vtable=${vtable}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems).then(res => res[0]),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
'PipesTypes',
|
||||||
|
'vAddRepairEvent',
|
||||||
|
'vBoilers',
|
||||||
|
'vBoilersAppointment',
|
||||||
|
'vBoilersBalance',
|
||||||
|
'vBoilersCondition',
|
||||||
|
'vBoilersFuels',
|
||||||
|
'vBoilersHotWater',
|
||||||
|
'vBoilersPerimeter',
|
||||||
|
'vBoilersPeriods',
|
||||||
|
'vBoilersScheme',
|
||||||
|
'vBoilersState',
|
||||||
|
'vBoilersTypes',
|
||||||
|
'vBuildingManagement',
|
||||||
|
'vBuildingOwner',
|
||||||
|
'vCanalization',
|
||||||
|
'vColdWaterTypes',
|
||||||
|
'vConditionEquipment',
|
||||||
|
'vCovering',
|
||||||
|
'vDryer',
|
||||||
|
'vElectroSupplyTypes',
|
||||||
|
'vEquipmentsTypes',
|
||||||
|
'vFoundation',
|
||||||
|
'vFuelsFeed',
|
||||||
|
'vGasSupplyTypes',
|
||||||
|
'vHeatingTypes',
|
||||||
|
'vHeatTransfer',
|
||||||
|
'vHotWaterTypes',
|
||||||
|
'vMaterialsWall',
|
||||||
|
'vNormative',
|
||||||
|
'vPipeDiameters',
|
||||||
|
'vPipeOutDiameters',
|
||||||
|
'vPipesBearingType',
|
||||||
|
'vPipesCovering',
|
||||||
|
'vPipesGround',
|
||||||
|
'vPipesIsolation',
|
||||||
|
'vPipesLayer',
|
||||||
|
'vPipesMaterial',
|
||||||
|
'vRepairEvent',
|
||||||
|
'vRoof',
|
||||||
|
'vRPSType',
|
||||||
|
'vStreets',
|
||||||
|
'vTechStatus',
|
||||||
|
'vTrash',
|
||||||
|
'vVentilation',
|
||||||
|
'vWallingEquipment',
|
||||||
|
'tTypes',
|
||||||
|
]
|
||||||
|
|
||||||
|
const TCBValue = (vtable: string) => {
|
||||||
|
if (tables.includes(vtable)) {
|
||||||
|
return (
|
||||||
|
<TableValue value={tcbValue?.id} name={name} type='select' vtable={vtable} />
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{JSON.stringify(name)}
|
||||||
|
{JSON.stringify(tcbValue)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
TCBValue(vtable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TCBParameter
|
71
client/src/components/map/TableValue.tsx
Normal file
71
client/src/components/map/TableValue.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Checkbox, ComboboxData, Grid, NumberInput, Select, Text, Textarea } from '@mantine/core';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { fetcher } from '../../http/axiosInstance';
|
||||||
|
import { BASE_URL } from '../../constants';
|
||||||
|
import { useObjectsStore } from '../../store/objects';
|
||||||
|
|
||||||
|
interface TableValueProps {
|
||||||
|
name: string;
|
||||||
|
value: unknown;
|
||||||
|
type: 'value' | 'boolean' | 'number' | 'select' | 'string';
|
||||||
|
unit?: string | null | undefined;
|
||||||
|
vtable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableValue = ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
unit,
|
||||||
|
vtable
|
||||||
|
}: TableValueProps) => {
|
||||||
|
const { selectedDistrict } = useObjectsStore()
|
||||||
|
|
||||||
|
//Get available values
|
||||||
|
const { data: tcbAll, isValidating } = useSWR(
|
||||||
|
type === 'select' && selectedDistrict ? `/general/params/tcb?vtable=${vtable}&id_city=${selectedDistrict}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
return res.map((el) => ({
|
||||||
|
label: el.name || "",
|
||||||
|
value: JSON.stringify(el.id)
|
||||||
|
})) as ComboboxData
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={4} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Text size='xs' style={{ textWrap: 'wrap' }}>{name as string}</Text>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={8}>
|
||||||
|
{type === 'boolean' ?
|
||||||
|
<Checkbox defaultChecked={value as boolean} />
|
||||||
|
:
|
||||||
|
type === 'number' ?
|
||||||
|
<NumberInput
|
||||||
|
size='xs'
|
||||||
|
value={value as number}
|
||||||
|
onChange={() => { }}
|
||||||
|
suffix={unit ? ` ${unit}` : ''}
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
type === 'select' && !isValidating && tcbAll ?
|
||||||
|
<Select size='xs' data={tcbAll} defaultValue={JSON.stringify(value)} />
|
||||||
|
:
|
||||||
|
type === 'string' ?
|
||||||
|
<Textarea size='xs' defaultValue={value as string} autosize minRows={1} />
|
||||||
|
:
|
||||||
|
<Text size='xs'>{value as string}</Text>
|
||||||
|
}
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableValue
|
45
client/src/components/map/TabsPane/TabsPane.tsx
Normal file
45
client/src/components/map/TabsPane/TabsPane.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ScrollAreaAutosize, Tabs } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface ITabsPane {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
view: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsPaneProps {
|
||||||
|
defaultTab: string;
|
||||||
|
tabs: ITabsPane[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsPane = ({
|
||||||
|
defaultTab,
|
||||||
|
tabs
|
||||||
|
}: TabsPaneProps) => {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue={defaultTab} mah='50%' h={'100%'} style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'min-content auto'
|
||||||
|
}}>
|
||||||
|
<ScrollAreaAutosize>
|
||||||
|
<Tabs.List>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tabs.Tab key={tab.value} value={tab.value}>
|
||||||
|
{tab.title}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
|
||||||
|
|
||||||
|
<ScrollAreaAutosize h='100%' offsetScrollbars p='xs'>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||||
|
{tab.view}
|
||||||
|
</Tabs.Panel>
|
||||||
|
))}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabsPane
|
590
client/src/components/map/mapUtils.ts
Normal file
590
client/src/components/map/mapUtils.ts
Normal file
@ -0,0 +1,590 @@
|
|||||||
|
import { Coordinate, distance, rotate } from "ol/coordinate";
|
||||||
|
import { containsExtent, Extent, getCenter, getHeight, getWidth } from "ol/extent";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
|
import { Circle, Geometry, LineString, Polygon, SimpleGeometry } from "ol/geom";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorImageLayer from "ol/layer/VectorImage";
|
||||||
|
import Map from "ol/Map";
|
||||||
|
import { addCoordinateTransforms, addProjection, get, getTransform, Projection, ProjectionLike, transform } from "ol/proj";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import proj4 from "proj4";
|
||||||
|
import { selectStyle } from "./MapStyles";
|
||||||
|
import { Type } from "ol/geom/Geometry";
|
||||||
|
import { Draw, Modify, Snap, Translate } from "ol/interaction";
|
||||||
|
import { noModifierKeys } from "ol/events/condition";
|
||||||
|
import { IGeometryType, IRectCoords } from "../../interfaces/map";
|
||||||
|
import { uploadCoordinates } from "../../actions/map";
|
||||||
|
import { ImageStatic } from "ol/source";
|
||||||
|
import ImageLayer from "ol/layer/Image";
|
||||||
|
import { IFigure, ILine } from "../../interfaces/gis";
|
||||||
|
import { fromCircle, fromExtent } from "ol/geom/Polygon";
|
||||||
|
import { measureStyleFunction, modifyStyle } from "./Measure/MeasureStyles";
|
||||||
|
import { getCurrentTool, getMeasureClearPrevious, getMeasureType, getTipPoint, setStatusText } from "../../store/map";
|
||||||
|
import { MutableRefObject } from "react";
|
||||||
|
import { getSelectedCity, getSelectedYear, setSelectedRegion } from "../../store/objects";
|
||||||
|
|
||||||
|
const calculateAngle = (coords: [number, number][]) => {
|
||||||
|
const [start, end] = coords;
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
return Math.atan2(dy, dx); // Angle in radians
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processLine(
|
||||||
|
line: ILine,
|
||||||
|
scaling: number,
|
||||||
|
mapCenter: Coordinate,
|
||||||
|
linesLayer: MutableRefObject<VectorLayer<VectorSource>>
|
||||||
|
) {
|
||||||
|
const x1 = line.x1 * scaling
|
||||||
|
const y1 = line.y1 * scaling
|
||||||
|
const x2 = line.x2 * scaling
|
||||||
|
const y2 = line.y2 * scaling
|
||||||
|
|
||||||
|
const center = [mapCenter[0], mapCenter[1]]
|
||||||
|
|
||||||
|
const testCoords: [number, number][] = [
|
||||||
|
[center[0] + x1, center[1] - y1],
|
||||||
|
[center[0] + x2, center[1] - y2],
|
||||||
|
]
|
||||||
|
|
||||||
|
const feature = new Feature(new LineString(testCoords))
|
||||||
|
|
||||||
|
feature.set('type', line.type)
|
||||||
|
feature.set('geometry_type', 'line')
|
||||||
|
feature.set('planning', line.planning)
|
||||||
|
feature.set('object_id', line.object_id)
|
||||||
|
feature.set('rotation', calculateAngle(testCoords))
|
||||||
|
|
||||||
|
linesLayer.current?.getSource()?.addFeature(feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processFigure(
|
||||||
|
figure: IFigure,
|
||||||
|
scaling: number,
|
||||||
|
mapCenter: Coordinate,
|
||||||
|
figuresLayer: MutableRefObject<VectorLayer<VectorSource>>
|
||||||
|
) {
|
||||||
|
if (figure.figure_type_id == 1) {
|
||||||
|
const width = figure.width * scaling
|
||||||
|
const height = figure.height * scaling
|
||||||
|
|
||||||
|
const left = figure.left * scaling
|
||||||
|
const top = figure.top * scaling
|
||||||
|
|
||||||
|
const centerX = mapCenter[0] + left + (width / 2)
|
||||||
|
const centerY = mapCenter[1] - top - (height / 2)
|
||||||
|
|
||||||
|
const radius = width / 2;
|
||||||
|
const circleGeom = new Circle([centerX, centerY], radius)
|
||||||
|
|
||||||
|
const ellipseGeom = fromCircle(circleGeom, 64)
|
||||||
|
ellipseGeom.scale(1, height / width)
|
||||||
|
|
||||||
|
const feature = new Feature(ellipseGeom)
|
||||||
|
|
||||||
|
feature.set('type', figure.type)
|
||||||
|
feature.set('object_id', figure.object_id)
|
||||||
|
feature.set('planning', figure.planning)
|
||||||
|
figuresLayer.current?.getSource()?.addFeature(feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (figure.figure_type_id == 3) {
|
||||||
|
const x = figure.left * scaling
|
||||||
|
const y = figure.top * scaling
|
||||||
|
|
||||||
|
const center = [mapCenter[0] + x, mapCenter[1] - y]
|
||||||
|
|
||||||
|
const coords = figure.points?.split(' ').map(pair => {
|
||||||
|
const [x, y] = pair.split(';').map(Number)
|
||||||
|
return [
|
||||||
|
center[0] + (x * scaling),
|
||||||
|
center[1] - (y * scaling)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coords) {
|
||||||
|
const polygon = new Polygon([coords])
|
||||||
|
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: polygon
|
||||||
|
})
|
||||||
|
|
||||||
|
feature.set('object_id', figure.object_id)
|
||||||
|
feature.set('planning', figure.planning)
|
||||||
|
feature.set('type', figure.type)
|
||||||
|
figuresLayer.current?.getSource()?.addFeature(feature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (figure.figure_type_id == 4) {
|
||||||
|
const width = figure.width * scaling
|
||||||
|
const height = figure.height * scaling
|
||||||
|
const left = figure.left * scaling
|
||||||
|
const top = figure.top * scaling
|
||||||
|
|
||||||
|
const halfWidth = width / 2
|
||||||
|
const halfHeight = height / 2
|
||||||
|
|
||||||
|
const center = [mapCenter[0] + left + halfWidth, mapCenter[1] - top - halfHeight]
|
||||||
|
|
||||||
|
const testCoords = [
|
||||||
|
[center[0] - halfWidth, center[1] - halfHeight],
|
||||||
|
[center[0] - halfWidth, center[1] + halfHeight],
|
||||||
|
[center[0] + halfWidth, center[1] + halfHeight],
|
||||||
|
[center[0] + halfWidth, center[1] - halfHeight],
|
||||||
|
[center[0] - halfWidth, center[1] - halfHeight]
|
||||||
|
]
|
||||||
|
|
||||||
|
const geometry1 = new Polygon([testCoords])
|
||||||
|
const anchor1 = center
|
||||||
|
geometry1.rotate(-figure.angle * Math.PI / 180, anchor1)
|
||||||
|
const feature1 = new Feature(geometry1)
|
||||||
|
feature1.set('object_id', figure.object_id)
|
||||||
|
feature1.set('planning', figure.planning)
|
||||||
|
feature1.set('type', figure.type)
|
||||||
|
feature1.set('angle', figure.angle)
|
||||||
|
figuresLayer.current?.getSource()?.addFeature(feature1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update the image layer with a new source when extent changes
|
||||||
|
export const updateImageSource = (
|
||||||
|
imageUrl: string,
|
||||||
|
imageLayer: React.MutableRefObject<ImageLayer<ImageStatic>>,
|
||||||
|
polygonFeature: Feature<Polygon>,
|
||||||
|
setPolygonExtent: (value: React.SetStateAction<Extent | undefined>) => void,
|
||||||
|
setRectCoords: React.Dispatch<React.SetStateAction<IRectCoords | undefined>>
|
||||||
|
) => {
|
||||||
|
const newExtent = polygonFeature.getGeometry()?.getExtent();
|
||||||
|
|
||||||
|
const bottomLeft = polygonFeature.getGeometry()?.getCoordinates()[0][0]
|
||||||
|
const topLeft = polygonFeature.getGeometry()?.getCoordinates()[0][1]
|
||||||
|
const topRight = polygonFeature.getGeometry()?.getCoordinates()[0][2]
|
||||||
|
const bottomRight = polygonFeature.getGeometry()?.getCoordinates()[0][3]
|
||||||
|
|
||||||
|
setRectCoords({
|
||||||
|
bl: bottomLeft,
|
||||||
|
tl: topLeft,
|
||||||
|
tr: topRight,
|
||||||
|
br: bottomRight
|
||||||
|
})
|
||||||
|
|
||||||
|
setPolygonExtent(newExtent)
|
||||||
|
|
||||||
|
if (newExtent && bottomLeft && bottomRight && topRight && topLeft) {
|
||||||
|
const originalExtent = calculateExtent(bottomLeft, topLeft, topRight, bottomRight)
|
||||||
|
const newImageSource = new ImageStatic({
|
||||||
|
url: imageUrl,
|
||||||
|
imageExtent: originalExtent,
|
||||||
|
projection: rotateProjection('EPSG:3857', calculateRotationAngle(bottomLeft, bottomRight), originalExtent)
|
||||||
|
});
|
||||||
|
imageLayer.current.setSource(newImageSource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addInteractions = (
|
||||||
|
drawingLayerSource: React.MutableRefObject<VectorSource<Feature<Geometry>>>,
|
||||||
|
translate: React.MutableRefObject<Translate | null>,
|
||||||
|
draw: React.MutableRefObject<Draw | null>,
|
||||||
|
map: React.MutableRefObject<Map | null>,
|
||||||
|
snap: React.MutableRefObject<Snap | null>,
|
||||||
|
measureDraw: React.MutableRefObject<Draw | null>,
|
||||||
|
measureSource: React.MutableRefObject<VectorSource<Feature<Geometry>>>,
|
||||||
|
measureModify: React.MutableRefObject<Modify>,
|
||||||
|
) => {
|
||||||
|
const currentTool = getCurrentTool()
|
||||||
|
const clearPrevious = getMeasureClearPrevious()
|
||||||
|
const measureType = getMeasureType()
|
||||||
|
const tipPoint = getTipPoint()
|
||||||
|
|
||||||
|
if (currentTool !== 'Measure' && currentTool !== 'Mover' && currentTool !== 'Edit') {
|
||||||
|
draw.current = new Draw({
|
||||||
|
source: drawingLayerSource.current,
|
||||||
|
type: currentTool as Type,
|
||||||
|
condition: noModifierKeys
|
||||||
|
})
|
||||||
|
|
||||||
|
draw.current.on('drawend', function (s) {
|
||||||
|
console.log(s.feature.getGeometry()?.getType())
|
||||||
|
let type: IGeometryType = 'POLYGON'
|
||||||
|
|
||||||
|
switch (s.feature.getGeometry()?.getType()) {
|
||||||
|
case 'LineString':
|
||||||
|
type = 'LINE'
|
||||||
|
break
|
||||||
|
case 'Polygon':
|
||||||
|
type = 'POLYGON'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
type = 'POLYGON'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const coordinates = (s.feature.getGeometry() as SimpleGeometry).getCoordinates() as Coordinate[]
|
||||||
|
uploadCoordinates(coordinates, type)
|
||||||
|
})
|
||||||
|
|
||||||
|
map?.current?.addInteraction(draw.current)
|
||||||
|
snap.current = new Snap({ source: drawingLayerSource.current })
|
||||||
|
map?.current?.addInteraction(snap.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTool == 'Measure') {
|
||||||
|
const drawType = measureType;
|
||||||
|
const activeTip =
|
||||||
|
'Кликните, чтобы продолжить рисовать ' +
|
||||||
|
(drawType === 'Polygon' ? 'многоугольник' : 'линию');
|
||||||
|
const idleTip = 'Кликните, чтобы начать измерение';
|
||||||
|
let tip = idleTip;
|
||||||
|
|
||||||
|
measureDraw.current = new Draw({
|
||||||
|
source: measureSource.current,
|
||||||
|
type: drawType,
|
||||||
|
style: function (feature) {
|
||||||
|
return measureStyleFunction(feature, drawType, tip);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
measureDraw.current.on('drawstart', function () {
|
||||||
|
if (clearPrevious) {
|
||||||
|
measureSource.current.clear();
|
||||||
|
}
|
||||||
|
measureModify.current.setActive(false);
|
||||||
|
tip = activeTip;
|
||||||
|
});
|
||||||
|
measureDraw.current.on('drawend', function () {
|
||||||
|
modifyStyle.setGeometry(tipPoint as Geometry);
|
||||||
|
measureModify.current.setActive(true);
|
||||||
|
map.current?.once('pointermove', function () {
|
||||||
|
modifyStyle.setGeometry('');
|
||||||
|
});
|
||||||
|
tip = idleTip;
|
||||||
|
});
|
||||||
|
measureModify.current.setActive(true);
|
||||||
|
map.current?.addInteraction(measureDraw.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTool == 'Mover') {
|
||||||
|
translate.current = new Translate()
|
||||||
|
map?.current?.addInteraction(translate.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTool == 'Edit') {
|
||||||
|
//const modify = new Modify()
|
||||||
|
//map?.current?.addInteraction(translate.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionsInit(
|
||||||
|
map: React.MutableRefObject<Map | null>,
|
||||||
|
selectedRegion: React.MutableRefObject<Feature<Geometry> | null>,
|
||||||
|
regionsLayer: React.MutableRefObject<VectorImageLayer<Feature<Geometry>, VectorSource<Feature<Geometry>>>>,
|
||||||
|
) {
|
||||||
|
regionsLayer.current.once('change', function () {
|
||||||
|
if (getSelectedCity() || getSelectedYear()) return
|
||||||
|
const extent = regionsLayer.current.getSource()?.getExtent()
|
||||||
|
|
||||||
|
if (extent && !extent?.every(val => Math.abs(val) === Infinity)) {
|
||||||
|
map.current?.getView().fit(fromExtent(extent) as SimpleGeometry, { duration: 500, maxZoom: 18, padding: [60, 60, 60, 60] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current?.on('click', function (e) {
|
||||||
|
if (selectedRegion.current !== null) {
|
||||||
|
selectedRegion.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.current) {
|
||||||
|
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
|
||||||
|
if (layer === regionsLayer.current) {
|
||||||
|
selectedRegion.current = feature as Feature
|
||||||
|
// Zoom to the selected feature
|
||||||
|
zoomToFeature(map, selectedRegion.current)
|
||||||
|
|
||||||
|
if (feature.get('id')) {
|
||||||
|
setSelectedRegion(feature.get('id'))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else return false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show current selected region
|
||||||
|
map.current?.on('pointermove', function (e) {
|
||||||
|
if (selectedRegion.current !== null) {
|
||||||
|
selectedRegion.current.setStyle(undefined)
|
||||||
|
selectedRegion.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.current) {
|
||||||
|
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
|
||||||
|
if (layer === regionsLayer.current) {
|
||||||
|
selectedRegion.current = feature as Feature
|
||||||
|
selectedRegion.current.setStyle(selectStyle)
|
||||||
|
|
||||||
|
if (feature.get('district')) {
|
||||||
|
setStatusText(feature.get('district'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} else return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hide regions layer when fully visible
|
||||||
|
map.current?.on('moveend', function () {
|
||||||
|
const viewExtent = map.current?.getView().calculateExtent(map.current.getSize())
|
||||||
|
const features = regionsLayer.current.getSource()?.getFeatures()
|
||||||
|
|
||||||
|
let isViewCovered = false
|
||||||
|
|
||||||
|
features?.forEach((feature: Feature) => {
|
||||||
|
const featureExtent = feature?.getGeometry()?.getExtent()
|
||||||
|
if (viewExtent && featureExtent) {
|
||||||
|
if (containsExtent(featureExtent, viewExtent)) {
|
||||||
|
isViewCovered = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
regionsLayer.current.setVisible(!isViewCovered)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomToFeature = (map: React.MutableRefObject<Map | null>, feature: Feature) => {
|
||||||
|
const geometry = feature.getGeometry()
|
||||||
|
const extent = geometry?.getExtent()
|
||||||
|
|
||||||
|
if (map.current && extent) {
|
||||||
|
map.current.getView().fit(extent, {
|
||||||
|
duration: 300,
|
||||||
|
maxZoom: 19,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to save features to localStorage
|
||||||
|
export const saveFeatures = (layerRef: MutableRefObject<VectorLayer<VectorSource> | null>) => {
|
||||||
|
const features = layerRef.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
|
||||||
|
export const loadFeatures = (layerSource: React.MutableRefObject<VectorSource<Feature<Geometry>>>) => {
|
||||||
|
const savedFeatures = localStorage.getItem('savedFeatures')
|
||||||
|
if (savedFeatures) {
|
||||||
|
const geoJSON = new GeoJSON()
|
||||||
|
const features = geoJSON.readFeatures(savedFeatures, {
|
||||||
|
featureProjection: 'EPSG:4326', // Ensure the projection is correct
|
||||||
|
})
|
||||||
|
layerSource.current?.addFeatures(features) // Add features to the vector source
|
||||||
|
//drawingLayer.current?.getSource()?.changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateProjection(projection: ProjectionLike, angle: number, extent: Extent) {
|
||||||
|
function rotateCoordinate(coordinate: Coordinate, angle: number, anchor: Coordinate) {
|
||||||
|
const coord = rotate(
|
||||||
|
[coordinate[0] - anchor[0], coordinate[1] - anchor[1]],
|
||||||
|
angle
|
||||||
|
);
|
||||||
|
return [coord[0] + anchor[0], coord[1] + anchor[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateTransform(coordinate: Coordinate) {
|
||||||
|
return rotateCoordinate(coordinate, angle, getCenter(extent));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalTransform(coordinate: Coordinate) {
|
||||||
|
return rotateCoordinate(coordinate, -angle, getCenter(extent));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalProjection = get(projection);
|
||||||
|
|
||||||
|
if (normalProjection) {
|
||||||
|
const rotatedProjection = new Projection({
|
||||||
|
code: normalProjection.getCode() + ":" + angle.toString() + ":" + extent.toString(),
|
||||||
|
units: normalProjection.getUnits(),
|
||||||
|
extent: extent
|
||||||
|
});
|
||||||
|
addProjection(rotatedProjection);
|
||||||
|
|
||||||
|
addCoordinateTransforms(
|
||||||
|
"EPSG:4326",
|
||||||
|
rotatedProjection,
|
||||||
|
function (coordinate) {
|
||||||
|
return rotateTransform(transform(coordinate, "EPSG:4326", projection));
|
||||||
|
},
|
||||||
|
function (coordinate) {
|
||||||
|
return transform(normalTransform(coordinate), projection, "EPSG:4326");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
addCoordinateTransforms(
|
||||||
|
"EPSG:3857",
|
||||||
|
rotatedProjection,
|
||||||
|
function (coordinate) {
|
||||||
|
return rotateTransform(transform(coordinate, "EPSG:3857", projection));
|
||||||
|
},
|
||||||
|
function (coordinate) {
|
||||||
|
return transform(normalTransform(coordinate), projection, "EPSG:3857");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// also set up transforms with any projections defined using proj4
|
||||||
|
if (typeof proj4 !== "undefined") {
|
||||||
|
const projCodes = Object.keys(proj4.defs);
|
||||||
|
projCodes.forEach(function (code) {
|
||||||
|
const proj4Projection = get(code) as Projection;
|
||||||
|
if (proj4Projection) {
|
||||||
|
if (!getTransform(proj4Projection, rotatedProjection)) {
|
||||||
|
addCoordinateTransforms(
|
||||||
|
proj4Projection,
|
||||||
|
rotatedProjection,
|
||||||
|
function (coordinate) {
|
||||||
|
return rotateTransform(
|
||||||
|
transform(coordinate, proj4Projection, projection)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (coordinate) {
|
||||||
|
return transform(
|
||||||
|
normalTransform(coordinate),
|
||||||
|
projection,
|
||||||
|
proj4Projection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotatedProjection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateCentroid = (bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) => {
|
||||||
|
const x = (bottomLeft[0] + topLeft[0] + topRight[0] + bottomRight[0]) / 4;
|
||||||
|
const y = (bottomLeft[1] + topLeft[1] + topRight[1] + bottomRight[1]) / 4;
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRotationAngle(bottomLeft: Coordinate, bottomRight: Coordinate) {
|
||||||
|
// Calculate the difference in x and y coordinates between bottom right and bottom left
|
||||||
|
const deltaX = bottomRight[0] - bottomLeft[0];
|
||||||
|
const deltaY = bottomRight[1] - bottomLeft[1];
|
||||||
|
|
||||||
|
// Calculate the angle using atan2
|
||||||
|
const angle = -Math.atan2(deltaY, deltaX);
|
||||||
|
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateExtent(bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) {
|
||||||
|
const width = distance(bottomLeft, bottomRight);
|
||||||
|
const height = distance(bottomLeft, topLeft);
|
||||||
|
|
||||||
|
// Calculate the centroid of the polygon
|
||||||
|
const [centerX, centerY] = calculateCentroid(bottomLeft, topLeft, topRight, bottomRight);
|
||||||
|
|
||||||
|
// Define the extent based on the center and dimensions
|
||||||
|
const extent = [
|
||||||
|
centerX - width / 2, // minX
|
||||||
|
centerY - height / 2, // minY
|
||||||
|
centerX + width / 2, // maxX
|
||||||
|
centerY + height / 2 // maxY
|
||||||
|
];
|
||||||
|
|
||||||
|
return extent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTilesPerSide(zoom: number) {
|
||||||
|
return Math.pow(2, zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: number, min: number, max: number) {
|
||||||
|
return (value - min) / (max - min)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTileIndex(normalized: number, tilesPerSide: number) {
|
||||||
|
return Math.floor(normalized * tilesPerSide)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGridCellPosition(x: number, y: number, extent: Extent, zoom: number) {
|
||||||
|
const tilesPerSide = getTilesPerSide(zoom);
|
||||||
|
const minX = extent[0]
|
||||||
|
const minY = extent[1]
|
||||||
|
const maxX = extent[2]
|
||||||
|
const maxY = extent[3]
|
||||||
|
|
||||||
|
// Normalize the coordinates
|
||||||
|
const xNormalized = normalize(x, minX, maxX);
|
||||||
|
const yNormalized = normalize(y, minY, maxY);
|
||||||
|
|
||||||
|
// Get tile indices
|
||||||
|
const tileX = getTileIndex(xNormalized, tilesPerSide);
|
||||||
|
const tileY = getTileIndex(1 - yNormalized, tilesPerSide);
|
||||||
|
|
||||||
|
return { tileX, tileY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCenter(geometry: SimpleGeometry) {
|
||||||
|
let center, coordinates, minRadius;
|
||||||
|
const type = geometry.getType();
|
||||||
|
if (type === 'Polygon') {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let i = 0;
|
||||||
|
coordinates = (geometry as Polygon).getCoordinates()[0].slice(1);
|
||||||
|
coordinates.forEach(function (coordinate) {
|
||||||
|
x += coordinate[0];
|
||||||
|
y += coordinate[1];
|
||||||
|
i++;
|
||||||
|
});
|
||||||
|
center = [x / i, y / i];
|
||||||
|
} else if (type === 'LineString') {
|
||||||
|
center = (geometry as LineString).getCoordinateAt(0.5);
|
||||||
|
coordinates = geometry.getCoordinates();
|
||||||
|
} else {
|
||||||
|
center = getCenter(geometry.getExtent());
|
||||||
|
}
|
||||||
|
let sqDistances;
|
||||||
|
if (coordinates) {
|
||||||
|
sqDistances = coordinates.map(function (coordinate: Coordinate) {
|
||||||
|
const dx = coordinate[0] - center[0];
|
||||||
|
const dy = coordinate[1] - center[1];
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
});
|
||||||
|
minRadius = Math.sqrt(Math.max(...sqDistances)) / 3;
|
||||||
|
} else {
|
||||||
|
minRadius =
|
||||||
|
Math.max(
|
||||||
|
getWidth(geometry.getExtent()),
|
||||||
|
getHeight(geometry.getExtent()),
|
||||||
|
) / 3;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
center: center,
|
||||||
|
coordinates: coordinates,
|
||||||
|
minRadius: minRadius,
|
||||||
|
sqDistances: sqDistances,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
rotateProjection,
|
||||||
|
calculateRotationAngle,
|
||||||
|
calculateExtent,
|
||||||
|
calculateCentroid,
|
||||||
|
getTilesPerSide,
|
||||||
|
normalize,
|
||||||
|
getTileIndex,
|
||||||
|
getGridCellPosition,
|
||||||
|
calculateCenter
|
||||||
|
}
|
256
client/src/components/modals/FileViewer.tsx
Normal file
256
client/src/components/modals/FileViewer.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
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';
|
||||||
|
import { IconAlertTriangle, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||||
|
import { Button, Flex, Grid, Loader, Modal, ScrollAreaAutosize, Text } from '@mantine/core';
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div ref={previewContainerRef} style={{
|
||||||
|
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 (
|
||||||
|
<div ref={previewContainerRef} style={{
|
||||||
|
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 (
|
||||||
|
<div ref={previewContainerRef} style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageViewer({
|
||||||
|
url
|
||||||
|
}: ViewerProps) {
|
||||||
|
return (
|
||||||
|
<Flex style={{
|
||||||
|
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%'
|
||||||
|
}} />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal.Root fullScreen opened={open} onClose={() => setOpen(false)} scrollAreaComponent={ScrollAreaAutosize.Autosize}>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'min-content auto',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh'
|
||||||
|
}}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title component='div' w='100%'>
|
||||||
|
<Flex align='center'>
|
||||||
|
<Text mr='auto'>{currentFileNo != -1 && docs[currentFileNo].name}</Text>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span='auto'>
|
||||||
|
<Button
|
||||||
|
variant='transparent'
|
||||||
|
onClick={() => {
|
||||||
|
if (currentFileNo >= 0 && currentFileNo > 0) {
|
||||||
|
setCurrentFileNo(currentFileNo - 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={currentFileNo >= 0 && currentFileNo === 0}
|
||||||
|
>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</Button>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span='auto'>
|
||||||
|
<Button
|
||||||
|
variant='transparent'
|
||||||
|
onClick={() => {
|
||||||
|
if (currentFileNo >= 0 && currentFileNo < docs.length) {
|
||||||
|
setCurrentFileNo(currentFileNo + 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
|
||||||
|
>
|
||||||
|
<IconChevronRight />
|
||||||
|
</Button>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
autoFocus
|
||||||
|
variant='subtle'
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Modal.Title>
|
||||||
|
<Modal.CloseButton ml='xl' />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body style={{ display: 'flex', flexGrow: 1, height: '100%', width: '100vw' }}>
|
||||||
|
{fileIsLoading || fileTypeIsLoading ?
|
||||||
|
<Flex style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
:
|
||||||
|
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 ?
|
||||||
|
<Flex style={{ display: 'flex', gap: '16px', flexDirection: 'column', p: '16px' }}>
|
||||||
|
<Flex style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
<IconAlertTriangle />
|
||||||
|
<Text>
|
||||||
|
Предпросмотр данного файла невозможен.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex>
|
||||||
|
<Button variant='contained' onClick={() => {
|
||||||
|
handleSave()
|
||||||
|
}}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
)
|
||||||
|
}
|
160
client/src/constants/app.tsx
Normal file
160
client/src/constants/app.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { IconBuildingFactory2, IconComponents, IconDeviceDesktopAnalytics, IconFiles, IconHome, IconLogin, IconLogin2, IconMap, IconPassword, IconReport, IconServer, IconSettings, IconShield, IconTable, IconUsers } from "@tabler/icons-react";
|
||||||
|
import SignIn from "../pages/auth/SignIn";
|
||||||
|
import SignUp from "../pages/auth/SignUp";
|
||||||
|
import PasswordReset from "../pages/auth/PasswordReset";
|
||||||
|
import TableTest from "../pages/TableTest";
|
||||||
|
import ComponentTest from "../pages/ComponentTest";
|
||||||
|
import MonitorPage from "../pages/MonitorPage";
|
||||||
|
import Settings from "../pages/Settings";
|
||||||
|
import Main from "../pages/Main";
|
||||||
|
import Users from "../pages/Users";
|
||||||
|
import Roles from "../pages/Roles";
|
||||||
|
import Documents from "../pages/Documents";
|
||||||
|
import Reports from "../pages/Reports";
|
||||||
|
import Servers from "../pages/Servers";
|
||||||
|
import Boilers from "../pages/Boilers";
|
||||||
|
import MapTest from "../pages/MapTest";
|
||||||
|
|
||||||
|
// Определение страниц с путями и компонентом для рендера
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
path: "/auth/signin",
|
||||||
|
icon: <IconLogin2 />,
|
||||||
|
component: <SignIn />,
|
||||||
|
drawer: false,
|
||||||
|
dashboard: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
path: "/auth/signup",
|
||||||
|
icon: <IconLogin />,
|
||||||
|
component: <SignUp />,
|
||||||
|
drawer: false,
|
||||||
|
dashboard: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
path: "/auth/password-reset",
|
||||||
|
icon: <IconPassword />,
|
||||||
|
component: <PasswordReset />,
|
||||||
|
drawer: false,
|
||||||
|
dashboard: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Настройки",
|
||||||
|
path: "/settings",
|
||||||
|
icon: <IconSettings />,
|
||||||
|
component: <Settings />,
|
||||||
|
drawer: false,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Главная",
|
||||||
|
path: "/",
|
||||||
|
icon: <IconHome />,
|
||||||
|
component: <Main />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Пользователи",
|
||||||
|
path: "/user",
|
||||||
|
icon: <IconUsers />,
|
||||||
|
component: <Users />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Роли",
|
||||||
|
path: "/role",
|
||||||
|
icon: <IconShield />,
|
||||||
|
component: <Roles />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Документы",
|
||||||
|
path: "/documents",
|
||||||
|
icon: <IconFiles />,
|
||||||
|
component: <Documents />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Отчеты",
|
||||||
|
path: "/reports",
|
||||||
|
icon: <IconReport />,
|
||||||
|
component: <Reports />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Серверы",
|
||||||
|
path: "/servers",
|
||||||
|
icon: <IconServer />,
|
||||||
|
component: <Servers />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Котельные",
|
||||||
|
path: "/boilers",
|
||||||
|
icon: <IconBuildingFactory2 />,
|
||||||
|
component: <Boilers />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "ИКС",
|
||||||
|
path: "/map-test",
|
||||||
|
icon: <IconMap />,
|
||||||
|
component: <MapTest />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Монитор",
|
||||||
|
path: "/monitor",
|
||||||
|
icon: <IconDeviceDesktopAnalytics />,
|
||||||
|
component: <MonitorPage />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Table test",
|
||||||
|
path: "/table-test",
|
||||||
|
icon: <IconTable />,
|
||||||
|
component: <TableTest />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Component test",
|
||||||
|
path: "/component-test",
|
||||||
|
icon: <IconComponents />,
|
||||||
|
component: <ComponentTest />,
|
||||||
|
drawer: true,
|
||||||
|
dashboard: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export {
|
||||||
|
pages
|
||||||
|
}
|
12
client/src/constants/index.ts
Normal file
12
client/src/constants/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const USER_DATA_KEY = 'userData';
|
||||||
|
export const TOKEN_AUTH_KEY = 'authToken'
|
||||||
|
export const TOKEN_ISSUED_DATE_KEY = 'tokenIssuedDate';
|
||||||
|
export const TOKEN_EXPIRY_DURATION = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const BASE_URL = {
|
||||||
|
auth: import.meta.env.VITE_API_AUTH_URL,
|
||||||
|
info: import.meta.env.VITE_API_INFO_URL,
|
||||||
|
fuel: import.meta.env.VITE_API_FUEL_URL,
|
||||||
|
servers: import.meta.env.VITE_API_SERVERS_URL,
|
||||||
|
ems: import.meta.env.VITE_API_EMS_URL,
|
||||||
|
}
|
302
client/src/hooks/swrHooks.ts
Normal file
302
client/src/hooks/swrHooks.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import useSWR, { SWRConfiguration } from "swr";
|
||||||
|
import RoleService from "../services/RoleService";
|
||||||
|
import UserService from "../services/UserService";
|
||||||
|
import { fetcher } from "../http/axiosInstance";
|
||||||
|
import { fileTypeFromBlob } from "file-type/core";
|
||||||
|
import { BASE_URL } from "../constants";
|
||||||
|
|
||||||
|
const swrOptions: SWRConfiguration = {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
const { data, error, isLoading } = useSWR(`/auth/roles`, RoleService.getRoles, swrOptions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: data?.data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
const { data, error, isLoading } = useSWR(`/auth/user`, UserService.getUsers, swrOptions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: data?.data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompanies(limit?: number, offset?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(`/info/companies?limit=${limit || 10}&offset=${offset || 0}`, fetcher, swrOptions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
companies: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolders(limit?: number, offset?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
`/info/document_folder?limit=${limit || 10}&offset=${offset || 0}`,
|
||||||
|
fetcher,
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocuments(folder_id?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
folder_id ? `/info/documents/${folder_id}` : null,
|
||||||
|
fetcher,
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownload(folder_id?: number | null, id?: number | null) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
folder_id && id ? `/info/document/${folder_id}&${id}` : null,
|
||||||
|
folder_id && id ? (url) => fetcher(url, BASE_URL.info, "blob") : null,
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileType(fileName?: string | null, file?: Blob | null) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
fileName && file ? `/filetype/${fileName}` : null,
|
||||||
|
file ? () => fileTypeFromBlob(file) : null,
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileType: data?.mime,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReport(city_id?: number | null) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
city_id ? `/info/reports/${city_id}?to_export=false` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.info),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
report: data ? JSON.parse(data) : [],
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportExport(city_id?: number | null, to_export?: boolean) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
city_id && to_export ? `/info/reports/${city_id}?to_export=${to_export}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.info, 'blob'),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportExported: data ? data : null,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// API general (fuel)
|
||||||
|
|
||||||
|
export function useAddress(limit?: number, page?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
`/general/address?limit=${limit || 10}&page=${page || 1}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.fuel),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
address: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegions(limit?: number, page?: number, search?: string | null) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
`/general/regions?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.fuel),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCities(limit?: number, page?: number, search?: string | null) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
`/general/cities?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.fuel),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cities: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBoilers(limit?: number, page?: number, search?: string) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
`/general/boilers?limit=${limit || 10}&page=${page || 1}${search ? `&search=${search}` : ''}`,
|
||||||
|
(url) => fetcher(url, BASE_URL.fuel),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
boilers: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
|
||||||
|
export function useServers(region_id?: number | null, offset?: number, limit?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
region_id ? `/api/servers?region_id=${region_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/servers?offset=${offset || 0}&limit=${limit || 10}`,
|
||||||
|
(url: string) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServersInfo(region_id?: number | null, offset?: number, limit?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
region_id ? `/api/servers_info?region_id=${region_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/servers_info?offset=${offset || 0}&limit=${limit || 10}`,
|
||||||
|
(url: string) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
serversInfo: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServer(server_id?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
server_id ? `/api/server/${server_id}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerIps(server_id?: number | null, offset?: number, limit?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
server_id ? `/api/server_ips?server_id=${server_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/server_ips?offset=${offset || 0}&limit=${limit || 10}`,
|
||||||
|
(url: string) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverIps: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware
|
||||||
|
|
||||||
|
export function useHardwares(server_id?: number, offset?: number, limit?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
server_id ? `/api/hardwares?server_id=${server_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/hardwares?offset=${offset || 0}&limit=${limit || 10}`,
|
||||||
|
(url: string) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hardwares: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useHardware(hardware_id?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
hardware_id ? `/api/hardware/${hardware_id}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hardware: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
|
||||||
|
export function useStorages(hardware_id?: number, offset?: number, limit?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
hardware_id ? `/api/storages?hardware_id=${hardware_id}&offset=${offset || 0}&limit=${limit || 10}` : `/api/storages?offset=${offset || 0}&limit=${limit || 10}`,
|
||||||
|
(url: string) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
storages: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStorage(storage_id?: number) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
storage_id ? `/api/storage/${storage_id}` : null,
|
||||||
|
(url) => fetcher(url, BASE_URL.servers),
|
||||||
|
swrOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage: data,
|
||||||
|
isLoading,
|
||||||
|
isError: error
|
||||||
|
}
|
||||||
|
}
|
27
client/src/http/axiosInstance.ts
Normal file
27
client/src/http/axiosInstance.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import axios, { ResponseType } from 'axios';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
const axiosInstance = axios.create();
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetcher = (url: string, baseURL?: string, responseType?: ResponseType) => axiosInstance.get(url, {
|
||||||
|
baseURL: baseURL || import.meta.env.VITE_API_INFO_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
responseType: responseType ? responseType : "json"
|
||||||
|
}).then(res => res.data)
|
||||||
|
|
||||||
|
export default axiosInstance;
|
39
client/src/interfaces/auth.ts
Normal file
39
client/src/interfaces/auth.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData extends User {
|
||||||
|
email: string;
|
||||||
|
login: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
is_active: boolean;
|
||||||
|
role_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreds extends User {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
token: string | null;
|
||||||
|
userData: UserData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginFormData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
grant_type: string;
|
||||||
|
scope?: string;
|
||||||
|
client_id?: string;
|
||||||
|
client_secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
access_token: string;
|
||||||
|
data: JSON;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
}
|
20
client/src/interfaces/create.ts
Normal file
20
client/src/interfaces/create.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Validate } from "react-hook-form";
|
||||||
|
|
||||||
|
export type CreateFieldType = 'string' | 'number' | 'date' | 'dateTime' | 'boolean' | 'singleSelect' | 'actions' | 'custom'
|
||||||
|
export type InputType = 'password'
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
17
client/src/interfaces/fuel.ts
Normal file
17
client/src/interfaces/fuel.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface IRegion {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBoiler {
|
||||||
|
id_object: string;
|
||||||
|
boiler_name: string;
|
||||||
|
boiler_code: string;
|
||||||
|
id_city: number;
|
||||||
|
activity: boolean;
|
||||||
|
}
|
36
client/src/interfaces/gis.ts
Normal file
36
client/src/interfaces/gis.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export interface IFigure {
|
||||||
|
object_id: string,
|
||||||
|
figure_type_id: number,
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
angle: number,
|
||||||
|
points: string | null,
|
||||||
|
label_left: number | null,
|
||||||
|
label_top: number | null,
|
||||||
|
label_angle: number | null,
|
||||||
|
label_size: number | null,
|
||||||
|
year: number,
|
||||||
|
type: number,
|
||||||
|
planning: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILine {
|
||||||
|
object_id: string,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
points: string | null,
|
||||||
|
label_offset: number,
|
||||||
|
group_id: string,
|
||||||
|
show_label: boolean,
|
||||||
|
forced_lengths: string,
|
||||||
|
label_sizes: string | null,
|
||||||
|
label_angels: string | null,
|
||||||
|
label_positions: string | null,
|
||||||
|
year: number,
|
||||||
|
type: number,
|
||||||
|
planning: boolean
|
||||||
|
}
|
12
client/src/interfaces/map.ts
Normal file
12
client/src/interfaces/map.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Coordinate } from "ol/coordinate";
|
||||||
|
|
||||||
|
export type SatelliteMapsProvider = 'google' | 'yandex' | 'custom' | 'static'
|
||||||
|
|
||||||
|
export type IGeometryType = 'LINE' | 'POLYGON'
|
||||||
|
|
||||||
|
export interface IRectCoords {
|
||||||
|
bl: Coordinate | undefined,
|
||||||
|
tl: Coordinate | undefined,
|
||||||
|
tr: Coordinate | undefined,
|
||||||
|
br: Coordinate | undefined
|
||||||
|
}
|
44
client/src/interfaces/objects.ts
Normal file
44
client/src/interfaces/objects.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export interface IObjectList {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectData {
|
||||||
|
object_id: string,
|
||||||
|
id_city: number,
|
||||||
|
year: number,
|
||||||
|
id_parent: number | null,
|
||||||
|
type: number,
|
||||||
|
planning: boolean,
|
||||||
|
activity: boolean,
|
||||||
|
kvr: string | null,
|
||||||
|
jur: string | null,
|
||||||
|
fuel: string | null,
|
||||||
|
boiler_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectParam {
|
||||||
|
id_object: string,
|
||||||
|
id_param: number,
|
||||||
|
value: string,
|
||||||
|
date_s: string | null,
|
||||||
|
date_po: string | null,
|
||||||
|
id_user: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IParam {
|
||||||
|
id: number,
|
||||||
|
id_group: number | null,
|
||||||
|
name: string,
|
||||||
|
format: string,
|
||||||
|
vtable: string,
|
||||||
|
unit: string | null,
|
||||||
|
exact_format: string | null,
|
||||||
|
inHistory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectType {
|
||||||
|
id: number,
|
||||||
|
name: string
|
||||||
|
}
|
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;
|
||||||
|
}
|
11
client/src/interfaces/user.ts
Normal file
11
client/src/interfaces/user.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface IUser {
|
||||||
|
id: number;
|
||||||
|
password: string;
|
||||||
|
email: string;
|
||||||
|
login: string;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
is_active: boolean;
|
||||||
|
role_id: number;
|
||||||
|
}
|
141
client/src/layouts/DashboardLayout.tsx
Normal file
141
client/src/layouts/DashboardLayout.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { AppShell, Avatar, Burger, Button, Flex, Group, Image, Menu, NavLink, rem, Text, useMantineColorScheme } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { IconChevronDown, IconLogout, IconSettings, IconMoon, IconSun } from '@tabler/icons-react';
|
||||||
|
import { getUserData, logout, useAuthStore } from '../store/auth';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { UserData } from '../interfaces/auth';
|
||||||
|
import { pages } from '../constants/app';
|
||||||
|
|
||||||
|
function DashboardLayout() {
|
||||||
|
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
|
||||||
|
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const getPageTitle = () => {
|
||||||
|
const currentPath = location.pathname
|
||||||
|
const allPages = [...pages]
|
||||||
|
const currentPage = allPages.find(page => page.path === currentPath)
|
||||||
|
return currentPage ? currentPage.label : "Панель управления"
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const [userData, setUserData] = useState<UserData>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStore) {
|
||||||
|
const stored = getUserData()
|
||||||
|
if (stored) {
|
||||||
|
setUserData(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [authStore])
|
||||||
|
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={{ height: 60 }}
|
||||||
|
navbar={{
|
||||||
|
width: desktopOpened ? 200 : 50,
|
||||||
|
breakpoint: 'sm',
|
||||||
|
collapsed: { mobile: !mobileOpened },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Flex h="100%" px="md" w='100%' align='center' gap='sm'>
|
||||||
|
<Group>
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group w='auto'>
|
||||||
|
{getPageTitle()}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group id='header-portal' w='auto' ml='auto'>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group style={{ flexShrink: 0 }}>
|
||||||
|
<Menu
|
||||||
|
width={260}
|
||||||
|
position="bottom-end"
|
||||||
|
transitionProps={{ transition: 'pop-top-right' }}
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<Button variant='transparent'>
|
||||||
|
<Group gap={7}>
|
||||||
|
<Avatar name={`${userData?.name} ${userData?.surname}`} radius="xl" size={30} />
|
||||||
|
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||||
|
{`${userData?.name} ${userData?.surname}`}
|
||||||
|
</Text>
|
||||||
|
<IconChevronDown style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>{userData?.login}</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
colorScheme === 'dark' ? <IconMoon style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> : <IconSun style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
|
||||||
|
}
|
||||||
|
onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}
|
||||||
|
>
|
||||||
|
Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
<IconSettings style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
|
||||||
|
}
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
>
|
||||||
|
Настройки профиля
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
navigate("/auth/signin")
|
||||||
|
}}
|
||||||
|
leftSection={<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />}
|
||||||
|
>
|
||||||
|
Выход
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item>
|
||||||
|
<Flex gap='sm' align='center'>
|
||||||
|
<Image src={'/logo2.svg'} w={32} />
|
||||||
|
<Text>0.1.0</Text>
|
||||||
|
</Flex>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Flex>
|
||||||
|
</AppShell.Header>
|
||||||
|
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
|
||||||
|
{pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
label={item.label}
|
||||||
|
leftSection={item.icon}
|
||||||
|
active={location.pathname === item.path}
|
||||||
|
style={{ textWrap: 'nowrap' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AppShell.Navbar>
|
||||||
|
<AppShell.Main>
|
||||||
|
<Flex w={{
|
||||||
|
sm: desktopOpened ? 'calc(100% - 200px)' : 'calc(100% - 50px)',
|
||||||
|
base: '100%'
|
||||||
|
}} h={'calc(100% - 60px)'} style={{ transition: "width 0.2s ease" }} pos={'fixed'}>
|
||||||
|
<Outlet />
|
||||||
|
</Flex>
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardLayout
|
12
client/src/layouts/MainLayout.tsx
Normal file
12
client/src/layouts/MainLayout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Layout for fullscreen pages
|
||||||
|
|
||||||
|
import { Flex } from "@mantine/core";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
return (
|
||||||
|
<Flex align='center' justify='center' h='100%' w='100%'>
|
||||||
|
<Outlet />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
25
client/src/main.tsx
Normal file
25
client/src/main.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import "@fontsource/inter";
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
import { createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme } from '@mantine/core';
|
||||||
|
|
||||||
|
const overrides = createTheme({
|
||||||
|
// Set this color to `--mantine-color-body` CSS variable
|
||||||
|
white: '#F0F0F0',
|
||||||
|
colors: {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const theme = mergeMantineTheme(DEFAULT_THEME, overrides);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
<App />
|
||||||
|
</MantineProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
85
client/src/pages/Boilers.tsx
Normal file
85
client/src/pages/Boilers.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useBoilers } from '../hooks/swrHooks'
|
||||||
|
import { Badge, ScrollAreaAutosize, Table, Text } from '@mantine/core'
|
||||||
|
import { IBoiler } from '../interfaces/fuel'
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
{ field: 'id_object', headerName: 'ID', type: "number" },
|
||||||
|
{ field: 'boiler_name', headerName: 'Название', type: "string", flex: 1 },
|
||||||
|
{ field: 'boiler_code', headerName: 'Код', type: "string", flex: 1 },
|
||||||
|
{ field: 'id_city', headerName: 'Город', type: "string", flex: 1 },
|
||||||
|
{ field: 'activity', headerName: 'Активен', type: "boolean", flex: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||||
|
<Text size="xl" fw={600}>
|
||||||
|
Котельные
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{boilers &&
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{boilersColumns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{boilers.map((boiler: IBoiler) => (
|
||||||
|
<Table.Tr key={boiler.id_object}>
|
||||||
|
{boilersColumns.map(column => {
|
||||||
|
if (column.field === 'activity') {
|
||||||
|
return (
|
||||||
|
boiler.activity ? (
|
||||||
|
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
|
||||||
|
<Badge fullWidth variant="light">
|
||||||
|
Активен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
|
||||||
|
<Badge color="gray" fullWidth variant="light">
|
||||||
|
Отключен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else return (
|
||||||
|
<Table.Td key={`${boiler.id_object}-${column.field}`}>{boiler[column.field as keyof IBoiler]}</Table.Td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Boilers
|
12
client/src/pages/ComponentTest.tsx
Normal file
12
client/src/pages/ComponentTest.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Flex } from '@mantine/core'
|
||||||
|
import ServerHardware from '../components/ServerHardware'
|
||||||
|
|
||||||
|
const ComponentTest = () => {
|
||||||
|
return (
|
||||||
|
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||||
|
<ServerHardware />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComponentTest
|
7
client/src/pages/Documents.tsx
Normal file
7
client/src/pages/Documents.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import FolderViewer from '../components/FolderViewer'
|
||||||
|
|
||||||
|
export default function Documents() {
|
||||||
|
return (
|
||||||
|
<FolderViewer />
|
||||||
|
)
|
||||||
|
}
|
54
client/src/pages/Main.tsx
Normal file
54
client/src/pages/Main.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Card, Flex, SimpleGrid, Text } from "@mantine/core";
|
||||||
|
import { IconBuildingFactory2, IconFiles, IconMap, IconReport, IconServer, IconShield, IconUsers } from "@tabler/icons-react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
interface CustomCardProps {
|
||||||
|
link: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
const CustomCard = ({
|
||||||
|
link,
|
||||||
|
icon,
|
||||||
|
label
|
||||||
|
}: CustomCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={() => navigate(link)}
|
||||||
|
withBorder
|
||||||
|
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
<Flex mih='50'>
|
||||||
|
{icon}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Text fw={500} size="lg" mt="md">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex w={'100%'} h={'100%'} direction='column' gap='sm' p='sm'>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
Главная
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ xs: 1, md: 3 }}>
|
||||||
|
<CustomCard link="/user" icon={<IconUsers size='50' color="#6495ED" />} label="Пользователи" />
|
||||||
|
<CustomCard link="/role" icon={<IconShield size='50' color="#6495ED" />} label="Роли" />
|
||||||
|
<CustomCard link="/documents" icon={<IconFiles size='50' color="#6495ED" />} label="Документы" />
|
||||||
|
<CustomCard link="/reports" icon={<IconReport size='50' color="#6495ED" />} label="Отчеты" />
|
||||||
|
<CustomCard link="/servers" icon={<IconServer size='50' color="#6495ED" />} label="Серверы" />
|
||||||
|
<CustomCard link="/boilers" icon={<IconBuildingFactory2 size='50' color="#6495ED" />} label="Котельные" />
|
||||||
|
<CustomCard link="/map-test" icon={<IconMap size='50' color="#6495ED" />} label="ИКС" />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
9
client/src/pages/MapTest.tsx
Normal file
9
client/src/pages/MapTest.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import MapComponent from '../components/map/MapComponent'
|
||||||
|
|
||||||
|
function MapTest() {
|
||||||
|
return (
|
||||||
|
<MapComponent />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapTest
|
48
client/src/pages/MonitorPage.tsx
Normal file
48
client/src/pages/MonitorPage.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Card, Flex } from '@mantine/core';
|
||||||
|
|
||||||
|
function CardComponent({
|
||||||
|
url,
|
||||||
|
is_alive
|
||||||
|
}: { url: string, is_alive: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Flex p='sm' direction='column'>
|
||||||
|
<p>{url}</p>
|
||||||
|
<p>{JSON.stringify(is_alive)}</p>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitorPage() {
|
||||||
|
const [servers, setServers] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`${import.meta.env.VITE_API_MONITOR_URL}/watch`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
setServers(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Error with SSE connection:', error)
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close()
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
{servers.length > 0 && servers.map((server: { name: string, status: boolean }) => (
|
||||||
|
<CardComponent url={server.name} is_alive={server.status} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
15
client/src/pages/NotFound.tsx
Normal file
15
client/src/pages/NotFound.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Flex, Text } from "@mantine/core";
|
||||||
|
import { IconError404 } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<Flex w={'100%'} h={'100%'} p='sm' gap='sm' align='center' justify='center'>
|
||||||
|
<Flex direction='column' gap='sm' align='center'>
|
||||||
|
<IconError404 size={100} />
|
||||||
|
<Text size="xl" fw={500} ta='center'>
|
||||||
|
Запрашиваемая страница не найдена.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
138
client/src/pages/Reports.tsx
Normal file
138
client/src/pages/Reports.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useCities, useReport, useReportExport } from "../hooks/swrHooks"
|
||||||
|
import { useDebounce } from "@uidotdev/usehooks"
|
||||||
|
import { ICity } from "../interfaces/fuel"
|
||||||
|
import { mutate } from "swr"
|
||||||
|
import { ActionIcon, Autocomplete, Badge, Button, CloseButton, Flex, ScrollAreaAutosize, Table } from "@mantine/core"
|
||||||
|
import { IconRefresh } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
export default function Reports() {
|
||||||
|
const [download, setDownload] = useState(false)
|
||||||
|
|
||||||
|
const [search, setSearch] = useState<string | undefined>("")
|
||||||
|
const debouncedSearch = useDebounce(search, 500)
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||||
|
const { cities } = useCities(10, 1, debouncedSearch)
|
||||||
|
|
||||||
|
const { report } = useReport(selectedOption)
|
||||||
|
|
||||||
|
const { reportExported } = useReportExport(selectedOption, download)
|
||||||
|
|
||||||
|
const refreshReport = async () => {
|
||||||
|
mutate(`/info/reports/${selectedOption}?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 (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||||
|
<Flex component="form" gap={'sm'}>
|
||||||
|
{/* <SearchableSelect /> */}
|
||||||
|
<Autocomplete
|
||||||
|
placeholder="Населенный пункт"
|
||||||
|
flex={'1'}
|
||||||
|
data={cities ? cities.map((item: ICity) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||||
|
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||||
|
onChange={(value) => setSearch(value)}
|
||||||
|
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||||
|
rightSection={
|
||||||
|
search !== '' && (
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setSelectedOption(null)
|
||||||
|
}}
|
||||||
|
aria-label="Clear value"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionIcon size='auto' variant='transparent' onClick={() => refreshReport()}>
|
||||||
|
<IconRefresh />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Button disabled={!selectedOption} onClick={() => exportReport()}>
|
||||||
|
Экспорт
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{report &&
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{[
|
||||||
|
{ field: 'id', headerName: '№', width: 70 },
|
||||||
|
...Object.keys(report).map(key => ({
|
||||||
|
field: key,
|
||||||
|
headerName: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
width: 150
|
||||||
|
}))
|
||||||
|
].map(column => (
|
||||||
|
<Table.Th key={column.headerName}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{[...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 (<Table.Tr key={row.id}>
|
||||||
|
{[
|
||||||
|
{ field: 'id', headerName: '№', width: 70 },
|
||||||
|
...Object.keys(report).map(key => ({
|
||||||
|
field: key,
|
||||||
|
headerName: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
width: 150
|
||||||
|
}))
|
||||||
|
].map(column => {
|
||||||
|
if (column.field === 'Активность') {
|
||||||
|
return (
|
||||||
|
row['Активность'] ? (
|
||||||
|
<Table.Td key={`${row.id}-${column.headerName}`}>
|
||||||
|
<Badge fullWidth variant="light">
|
||||||
|
Активен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<Table.Td key={`${row.id}-${column.headerName}`}>
|
||||||
|
<Badge color="gray" fullWidth variant="light">
|
||||||
|
Отключен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table.Td key={`${row.id}-${column.headerName}`}>{row[column.field]}</Table.Td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Tr>)
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
63
client/src/pages/Roles.tsx
Normal file
63
client/src/pages/Roles.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useRoles } from '../hooks/swrHooks'
|
||||||
|
import { CreateField } from '../interfaces/create'
|
||||||
|
import RoleService from '../services/RoleService'
|
||||||
|
import FormFields from '../components/FormFields'
|
||||||
|
import { Button, Loader, Modal, ScrollAreaAutosize, Table } from '@mantine/core'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { IRole } from '../interfaces/role'
|
||||||
|
|
||||||
|
export default function Roles() {
|
||||||
|
const { roles, isError, isLoading } = useRoles()
|
||||||
|
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const createFields: CreateField[] = [
|
||||||
|
{ key: 'name', headerName: 'Название', type: 'string', required: true, defaultValue: '' },
|
||||||
|
{ key: 'description', headerName: 'Описание', type: 'string', required: false, defaultValue: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ field: 'id', headerName: 'ID', type: "number" },
|
||||||
|
{ field: 'name', headerName: 'Название', flex: 1, editable: true },
|
||||||
|
{ field: 'description', headerName: 'Описание', flex: 1, editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) return <div>Произошла ошибка при получении данных.</div>
|
||||||
|
if (isLoading) return <Loader />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||||
|
<Button onClick={open}>
|
||||||
|
Добавить роль
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal opened={opened} onClose={close} title="Создание роли" centered>
|
||||||
|
<FormFields
|
||||||
|
fields={createFields}
|
||||||
|
submitHandler={RoleService.createRole}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{columns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{roles.map((role: IRole) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={role.id}
|
||||||
|
>
|
||||||
|
{columns.map(column => (
|
||||||
|
<Table.Td key={column.field}>{role[column.field as keyof IRole]}</Table.Td>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
48
client/src/pages/Servers.tsx
Normal file
48
client/src/pages/Servers.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import ServersView from "../components/ServersView"
|
||||||
|
import ServerIpsView from "../components/ServerIpsView"
|
||||||
|
import ServerHardware from "../components/ServerHardware"
|
||||||
|
import ServerStorage from "../components/ServerStorages"
|
||||||
|
import { Flex, ScrollAreaAutosize, Tabs } from "@mantine/core"
|
||||||
|
|
||||||
|
export default function Servers() {
|
||||||
|
const [currentTab, setCurrentTab] = useState<string | null>('0')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||||
|
<Flex gap='sm' direction='column'>
|
||||||
|
<Tabs value={currentTab} onChange={setCurrentTab}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="0">Серверы</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="1">IP-адреса</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="3">Hardware</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="4">Storages</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="0" pt='sm'>
|
||||||
|
<ServersView />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="1" pt='sm'>
|
||||||
|
<ServerIpsView />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="2" pt='sm'>
|
||||||
|
<ServerHardware />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="3" pt='sm'>
|
||||||
|
<ServerStorage />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* <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}
|
||||||
|
/> */}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
68
client/src/pages/Settings.tsx
Normal file
68
client/src/pages/Settings.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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"
|
||||||
|
import { Flex, ScrollAreaAutosize } from "@mantine/core"
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollAreaAutosize
|
||||||
|
w={'100%'}
|
||||||
|
h={'100%'}
|
||||||
|
p='sm'
|
||||||
|
>
|
||||||
|
{currentUser &&
|
||||||
|
<Flex direction='column' gap='sm' w='100%'>
|
||||||
|
<FormFields
|
||||||
|
fields={profileFields}
|
||||||
|
defaultValues={currentUser}
|
||||||
|
mutateHandler={(data: any) => {
|
||||||
|
setUserData(data)
|
||||||
|
}}
|
||||||
|
submitHandler={(data) => UserService.updateUser({ id: currentUser.id, ...data })}
|
||||||
|
title="Пользователь"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFields
|
||||||
|
fields={passwordFields}
|
||||||
|
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
|
||||||
|
title="Смена пароля"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
13
client/src/pages/TableTest.tsx
Normal file
13
client/src/pages/TableTest.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Flex } from '@mantine/core';
|
||||||
|
import CustomTable from '../components/CustomTable';
|
||||||
|
|
||||||
|
function TableTest() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||||
|
<CustomTable />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableTest
|
161
client/src/pages/Users.tsx
Normal file
161
client/src/pages/Users.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useRoles, useUsers } from "../hooks/swrHooks"
|
||||||
|
import { IRole } from "../interfaces/role"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { CreateField } from "../interfaces/create"
|
||||||
|
import UserService from "../services/UserService"
|
||||||
|
import FormFields from "../components/FormFields"
|
||||||
|
import { Badge, Button, Flex, Loader, Modal, Pagination, ScrollAreaAutosize, Select, Table } from "@mantine/core"
|
||||||
|
import { useDisclosure } from "@mantine/hooks"
|
||||||
|
import { IUser } from "../interfaces/user"
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const { users, isError, isLoading } = useUsers()
|
||||||
|
|
||||||
|
const { roles } = useRoles()
|
||||||
|
|
||||||
|
const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() })))
|
||||||
|
}
|
||||||
|
}, [roles])
|
||||||
|
|
||||||
|
const [opened, { open, close }] = useDisclosure(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 = [
|
||||||
|
{ field: 'id', headerName: 'ID', type: "number", flex: 1 },
|
||||||
|
{ field: 'email', headerName: 'Email', flex: 1, editable: true },
|
||||||
|
{ field: 'login', headerName: 'Логин', flex: 1, editable: true },
|
||||||
|
{ field: 'phone', headerName: 'Телефон', flex: 1, editable: true },
|
||||||
|
{ field: 'name', headerName: 'Имя', flex: 1, editable: true },
|
||||||
|
{ field: 'surname', headerName: 'Фамилия', flex: 1, editable: true },
|
||||||
|
{ field: 'is_active', headerName: 'Статус', type: "boolean", flex: 1, editable: true },
|
||||||
|
{
|
||||||
|
field: 'role_id',
|
||||||
|
headerName: 'Роль',
|
||||||
|
valueOptions: roles ? roles.map((role: IRole) => ({ label: role.name, value: role.id })) : [],
|
||||||
|
type: 'singleSelect',
|
||||||
|
flex: 1,
|
||||||
|
editable: true
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) return (
|
||||||
|
<div>
|
||||||
|
Произошла ошибка при получении данных.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||||
|
<Button onClick={open}>
|
||||||
|
Добавить пользователя
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal opened={opened} onClose={close} title="Регистрация пользователя" centered>
|
||||||
|
<FormFields
|
||||||
|
fields={createFields}
|
||||||
|
submitHandler={UserService.createUser}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{Array.isArray(roleOptions) &&
|
||||||
|
<Table highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{columns.map(column => (
|
||||||
|
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{users.map((user: IUser) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={user.id}
|
||||||
|
//bg={selectedRows.includes(element.position) ? 'var(--mantine-color-blue-light)' : undefined}
|
||||||
|
>
|
||||||
|
{columns.map(column => {
|
||||||
|
if (column.field === 'is_active') {
|
||||||
|
return (
|
||||||
|
user.is_active ? (
|
||||||
|
<Table.Td key={column.field}>
|
||||||
|
<Badge fullWidth variant="light">
|
||||||
|
Активен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
) : (
|
||||||
|
<Table.Td key={column.field}>
|
||||||
|
<Badge color="gray" fullWidth variant="light">
|
||||||
|
Отключен
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (column.field === 'role_id') {
|
||||||
|
return (
|
||||||
|
<Table.Td key={column.field}>
|
||||||
|
<Select
|
||||||
|
data={roleOptions}
|
||||||
|
defaultValue={user.role_id.toString()}
|
||||||
|
variant="unstyled"
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else return (
|
||||||
|
<Table.Td key={column.field}>{user[column.field as keyof IUser]}</Table.Td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Pagination total={10} />
|
||||||
|
|
||||||
|
{/* <DataGrid
|
||||||
|
density="compact"
|
||||||
|
autoHeight
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
rows={users}
|
||||||
|
columns={columns}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { page: 0, pageSize: 10 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
checkboxSelection
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
|
||||||
|
processRowUpdate={(updatedRow) => {
|
||||||
|
return updatedRow
|
||||||
|
}}
|
||||||
|
|
||||||
|
onProcessRowUpdateError={() => {
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</ScrollAreaAutosize>
|
||||||
|
)
|
||||||
|
}
|
96
client/src/pages/auth/PasswordReset.tsx
Normal file
96
client/src/pages/auth/PasswordReset.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||||
|
import AuthService from '../../services/AuthService';
|
||||||
|
import { Button, Flex, Loader, Paper, Text, TextInput, Transition } from '@mantine/core';
|
||||||
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
Восстановление пароля
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
{!success &&
|
||||||
|
<Transition mounted={!success} transition='fade'>
|
||||||
|
{(styles) =>
|
||||||
|
<Flex style={styles} direction='column' gap={'md'}>
|
||||||
|
<Text>
|
||||||
|
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='E-mail'
|
||||||
|
required
|
||||||
|
{...register('email', { required: 'Введите E-mail' })}
|
||||||
|
error={errors.email?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex gap='sm'>
|
||||||
|
<Button flex={1} type="submit" disabled={isSubmitting || watch('email').length == 0} variant='filled'>
|
||||||
|
{isSubmitting ? <Loader size={16} /> : 'Восстановить пароль'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button flex={1} component='a' href="/auth/signin" type="button" variant='light'>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
|
||||||
|
</Transition>
|
||||||
|
}
|
||||||
|
{success &&
|
||||||
|
<Transition mounted={!success} transition='scale'>
|
||||||
|
{(styles) =>
|
||||||
|
<Flex style={styles} direction='column' gap='sm'>
|
||||||
|
<Flex align='center' gap='sm'>
|
||||||
|
<IconCheck />
|
||||||
|
<Text>
|
||||||
|
На указанный адрес было отправлено письмо с новыми данными для авторизации.
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap='sm'>
|
||||||
|
<Button component='a' href="/auth/signin" type="button">
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</Transition>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PasswordReset
|
99
client/src/pages/auth/SignIn.tsx
Normal file
99
client/src/pages/auth/SignIn.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||||
|
import { AxiosError, 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';
|
||||||
|
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
const SignIn = () => {
|
||||||
|
const { register, handleSubmit, setError, formState: { errors, isSubmitting, isValid } } = 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: unknown) {
|
||||||
|
if ((error as AxiosError).response?.data) {
|
||||||
|
const err = (error as AxiosError).response?.data
|
||||||
|
setError('password', {
|
||||||
|
message: (err as { detail: string })?.detail
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
Вход
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<TextInput
|
||||||
|
label='Логин'
|
||||||
|
required
|
||||||
|
{...register('username', { required: 'Введите логин' })}
|
||||||
|
error={errors.username?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Пароль'
|
||||||
|
type='password'
|
||||||
|
required
|
||||||
|
{...register('password', { required: 'Введите пароль' })}
|
||||||
|
error={errors.password?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex justify='flex-end' gap='sm'>
|
||||||
|
<Button component='a' href='/auth/password-reset' variant='transparent'>
|
||||||
|
Восстановить пароль
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap='sm'>
|
||||||
|
<Button disabled={!isValid} type="submit" flex={1} variant='filled'>
|
||||||
|
{isSubmitting ? <Loader size={16} /> : 'Вход'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* <Button component='a' flex={1} href='/auth/signup' type="button" variant='light'>
|
||||||
|
Регистрация
|
||||||
|
</Button> */}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignIn;
|
92
client/src/pages/auth/SignUp.tsx
Normal file
92
client/src/pages/auth/SignUp.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||||
|
import UserService from '../../services/UserService';
|
||||||
|
import { IUser } from '../../interfaces/user';
|
||||||
|
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
const SignUp = () => {
|
||||||
|
const { register, handleSubmit, formState: { errors, isValid, isSubmitting } } = 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 (
|
||||||
|
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
Регистрация
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Flex direction='column' gap='sm'>
|
||||||
|
<TextInput
|
||||||
|
label='Email'
|
||||||
|
required
|
||||||
|
{...register('email', { required: 'Email обязателен' })}
|
||||||
|
error={errors.email?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Логин'
|
||||||
|
required
|
||||||
|
{...register('login', { required: 'Логин обязателен' })}
|
||||||
|
error={errors.login?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Телефон'
|
||||||
|
required
|
||||||
|
{...register('phone')}
|
||||||
|
error={errors.phone?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Имя'
|
||||||
|
required
|
||||||
|
{...register('name')}
|
||||||
|
error={errors.name?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Фамилия'
|
||||||
|
required
|
||||||
|
{...register('surname')}
|
||||||
|
error={errors.surname?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Пароль'
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
{...register('password', { required: 'Пароль обязателен' })}
|
||||||
|
error={errors.password?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex gap='sm'>
|
||||||
|
<Button disabled={!isValid} type="submit" flex={1} variant='filled'>
|
||||||
|
{isSubmitting ? <Loader size={16} /> : 'Зарегистрироваться'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignUp;
|
10
client/src/pages/dark.scss
Normal file
10
client/src/pages/dark.scss
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
$ka-background-color: #2c2c2c;
|
||||||
|
$ka-border-color: #4d4d4d;
|
||||||
|
$ka-cell-hover-background-color: adjust(#fff, 0.8);
|
||||||
|
$ka-color-base: #fefefe;
|
||||||
|
$ka-input-background-color: $ka-background-color;
|
||||||
|
$ka-input-border-color: $ka-border-color;
|
||||||
|
$ka-input-color: $ka-color-base;
|
||||||
|
$ka-row-hover-background-color: adjust(#fff, 0.9);
|
||||||
|
$ka-thead-background-color: #1b1b1b;
|
||||||
|
$ka-thead-color: #c5c5c5;
|
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)
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user