chore: вычищены остатки доменного кода, каркас проходит tsc/biome/rsbuild
This commit is contained in:
parent
29e5cc5bf4
commit
1bd97a3672
@ -6,7 +6,7 @@ from sqlalchemy import pool
|
|||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.models import *
|
from src.models import Base # noqa: F401 — модели регистрируются в src/models/__init__.py
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
185
api/src/app.py
185
api/src/app.py
@ -1,65 +1,53 @@
|
|||||||
import os
|
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
|
||||||
|
|
||||||
import smbclient
|
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование),
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA.
|
||||||
from fastapi import FastAPI
|
Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
(docs/context/03-architecture.md), зависимость require(permission).
|
||||||
from fastapi.staticfiles import StaticFiles
|
"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from src.core.PDFWorker.PDFTempPoller import PDFPollWorker
|
|
||||||
from .core.config import get_settings
|
|
||||||
# from .api.router import files_router
|
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from src.core.nats_callback import build_file_handler
|
|
||||||
from fastapi.exceptions import HTTPException, StarletteHTTPException
|
|
||||||
from src.api.router import le_router, files_router, cg_router, rf_office_router, osv_router, fsl_router
|
|
||||||
from miniopy_async import Minio
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.core.clients import S3Storage, PollWorker
|
|
||||||
from src.core.async_cache import AsyncInMemoryCache
|
|
||||||
# from src.core.logger import setup_logger
|
|
||||||
from nats.aio.client import Client as NATS
|
|
||||||
import nkeys
|
import nkeys
|
||||||
import base64
|
import smbclient
|
||||||
from loguru import logger
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import HTTPException, StarletteHTTPException
|
||||||
import os
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from loguru import logger
|
||||||
|
from miniopy_async import Minio
|
||||||
|
from nats.aio.client import Client as NATS
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
|
||||||
|
from src.api.router import router
|
||||||
|
from src.core.clients import S3Storage
|
||||||
|
from src.core.config import get_settings
|
||||||
from src.core.exception_handlers import register_exception_handlers
|
from src.core.exception_handlers import register_exception_handlers
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
VERSION = "1.0.0"
|
VERSION = settings.VERSION
|
||||||
UP_TIME = datetime.now(timezone.utc)
|
UP_TIME = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8'))
|
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
|
||||||
|
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def jwt_cb():
|
def jwt_cb():
|
||||||
return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8')
|
return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def sig_cb(nonce):
|
def sig_cb(nonce):
|
||||||
if (isinstance(nonce, str)):
|
if isinstance(nonce, str):
|
||||||
nonce = nonce.encode('utf-8')
|
nonce = nonce.encode("utf-8")
|
||||||
signed = kp.sign(nonce)
|
return base64.b64encode(kp.sign(nonce))
|
||||||
return base64.b64encode(signed)
|
|
||||||
|
|
||||||
|
|
||||||
async def sub_cb(msg):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
|
||||||
protected_middleware_available = False
|
protected_middleware_available = False
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
try:
|
try:
|
||||||
@ -90,120 +78,81 @@ _APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app):
|
async def lifespan(app: FastAPI):
|
||||||
app.state.nats_subs = []
|
app.state.nats_subs = []
|
||||||
app.state.storage = None
|
app.state.storage = None
|
||||||
app.state.nats = None
|
app.state.nats = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD)
|
smbclient.register_session(
|
||||||
except:
|
settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
logger.exception("Ошибка при регистрации сессии SMB")
|
logger.exception("Ошибка при регистрации сессии SMB")
|
||||||
s3_client = Minio(settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, cert_check=False)
|
|
||||||
|
s3_client = Minio(
|
||||||
|
settings.OPENBAO.MINIO.MINIO_URL,
|
||||||
|
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
|
||||||
|
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
|
||||||
|
cert_check=False,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
|
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||||
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
|
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Не удалось проверить наличие бакетa. error_category=s3_error, error_type={error_type}, error={error}",
|
"Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}",
|
||||||
error_type=type(exc).__name__,
|
error_type=type(exc).__name__,
|
||||||
error=repr(exc),
|
error=repr(exc),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
|
app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||||
app.state.storage = storage
|
|
||||||
cache = AsyncInMemoryCache()
|
|
||||||
nc = NATS()
|
nc = NATS()
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
try:
|
try:
|
||||||
await nc.connect(settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb,
|
await nc.connect(
|
||||||
max_reconnect_attempts=2)
|
settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2
|
||||||
|
)
|
||||||
logger.info("Подключение к NATS произошло")
|
logger.info("Подключение к NATS произошло")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}",
|
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
|
||||||
error_type=type(exc).__name__,
|
error_type=type(exc).__name__,
|
||||||
error=repr(exc),
|
error=repr(exc),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
poller = PollWorker(nc, cache, storage)
|
|
||||||
await poller.start()
|
|
||||||
pdf_poller = PDFPollWorker(cache, storage)
|
|
||||||
await pdf_poller.start()
|
|
||||||
app.state.nats = nc
|
app.state.nats = nc
|
||||||
app.state.poller = poller
|
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
|
||||||
app.state.pdf_poller = pdf_poller
|
|
||||||
if not settings.DEBUG:
|
|
||||||
handler_cb = build_file_handler(nc, storage)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# sub_xls = await nc.subscribe('files.tabular_parsed', cb=handler_cb)
|
|
||||||
# sub_img = await nc.subscribe('files.img_parsed', cb=handler_cb)
|
|
||||||
#
|
|
||||||
# app.state.nats_subs = [sub_xls, sub_img]
|
|
||||||
logger.info("Подписка на топики завершена")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Не удалось подписаться на тему. error_category=nats_error, error_type={error_type}, error={error}",
|
|
||||||
error_type=type(exc).__name__,
|
|
||||||
error=repr(exc),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
logger.info("Приложение запущено")
|
logger.info("Приложение запущено")
|
||||||
yield
|
yield
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc))
|
|
||||||
finally:
|
finally:
|
||||||
logger.info("Приложение закрывается")
|
logger.info("Приложение закрывается")
|
||||||
smbclient.reset_connection_cache()
|
smbclient.reset_connection_cache()
|
||||||
if not settings.DEBUG:
|
for sub in app.state.nats_subs:
|
||||||
for sub in app.state.nats_subs:
|
await sub.unsubscribe()
|
||||||
logger.info("Отписываемся от топика")
|
if app.state.nats and not settings.DEBUG:
|
||||||
await sub.unsubscribe()
|
await app.state.nats.close()
|
||||||
if app.state.nats:
|
|
||||||
await app.state.nats.close()
|
|
||||||
if app.state.poller:
|
|
||||||
await app.state.poller.stop()
|
|
||||||
if app.state.storage:
|
if app.state.storage:
|
||||||
await app.state.storage.client.close_session()
|
await app.state.storage.client.close_session()
|
||||||
if app.state.pdf_poller:
|
|
||||||
await app.state.pdf_poller.stop()
|
|
||||||
logger.info("CLOSING APP AT ALL")
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="Fast API",
|
|
||||||
version=VERSION,
|
|
||||||
root_path=settings.ROOT_PATH,
|
|
||||||
lifespan=lifespan
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get(
|
app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
||||||
"/healthcheck",
|
|
||||||
status_code=200
|
|
||||||
)
|
@app.get("/healthcheck", status_code=200)
|
||||||
async def healthcheck():
|
async def healthcheck():
|
||||||
return {
|
return {"uptime": UP_TIME, "version": VERSION}
|
||||||
"uptime": UP_TIME,
|
|
||||||
"version": VERSION
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(le_router)
|
app.include_router(router)
|
||||||
app.include_router(files_router)
|
|
||||||
app.include_router(cg_router)
|
|
||||||
app.include_router(rf_office_router)
|
|
||||||
app.include_router(osv_router)
|
|
||||||
app.include_router(fsl_router)
|
|
||||||
|
|
||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@ -213,17 +162,11 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Раздача собранного SPA (web/) ---------------------------------------------------------------
|
||||||
#Создание каталога для web приложения
|
os.makedirs("web", exist_ok=True)
|
||||||
os.makedirs(os.path.join('web'), exist_ok=True)
|
|
||||||
|
|
||||||
#Подключаем все ендпоинты и роуты до маунта статических файлов
|
|
||||||
# app.include_router(files_router)
|
|
||||||
|
|
||||||
#Подключение собранного web приложения
|
|
||||||
|
|
||||||
|
|
||||||
def sep_static(path, stat_dir):
|
def sep_static(path: str, stat_dir: str) -> str:
|
||||||
sep_path = path.split("/")
|
sep_path = path.split("/")
|
||||||
index = sep_path.index(stat_dir)
|
index = sep_path.index(stat_dir)
|
||||||
return "/".join(sep_path[index:])
|
return "/".join(sep_path[index:])
|
||||||
@ -240,11 +183,7 @@ class SPAStaticFiles(StaticFiles):
|
|||||||
if "static" in path:
|
if "static" in path:
|
||||||
return await super().get_response(sep_static(path, "static"), scope)
|
return await super().get_response(sep_static(path, "static"), scope)
|
||||||
return await super().get_response("index.html", scope)
|
return await super().get_response("index.html", scope)
|
||||||
else:
|
raise
|
||||||
raise ex
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FileDictItem:
|
|
||||||
started_at: datetime
|
|
||||||
expires_at: datetime
|
|
||||||
file_id: str
|
|
||||||
job_id: str
|
|
||||||
s3_key: str
|
|
||||||
legal_entity_id: str
|
|
||||||
|
|
||||||
class AsyncInMemoryCache:
|
|
||||||
def __init__(self):
|
|
||||||
self.mapping: dict[str, FileDictItem] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def add(self, job_id: str, file_id: str, s3_key: str, legal_entity_id: str) -> FileDictItem:
|
|
||||||
now = datetime.now()
|
|
||||||
expiration_date = datetime.now() + timedelta(minutes=5)
|
|
||||||
|
|
||||||
new_item = FileDictItem(
|
|
||||||
started_at= now,
|
|
||||||
expires_at= expiration_date,
|
|
||||||
file_id= str(file_id),
|
|
||||||
s3_key=s3_key,
|
|
||||||
job_id= str(job_id),
|
|
||||||
legal_entity_id = legal_entity_id
|
|
||||||
)
|
|
||||||
async with self._lock:
|
|
||||||
self.mapping[str(job_id)] = new_item
|
|
||||||
return new_item
|
|
||||||
|
|
||||||
async def get(self, job_id: uuid.UUID):
|
|
||||||
async with self._lock:
|
|
||||||
return self.mapping.get(str(job_id))
|
|
||||||
|
|
||||||
async def remove(self, source_link: str) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
self.mapping.pop(source_link, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_items(self) -> list[FileDictItem]:
|
|
||||||
async with self._lock:
|
|
||||||
return [
|
|
||||||
item
|
|
||||||
for item in self.mapping.values()
|
|
||||||
]
|
|
||||||
@ -1,29 +1,10 @@
|
|||||||
from datetime import datetime
|
"""Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0
|
||||||
import json
|
по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py).
|
||||||
|
"""
|
||||||
import smbclient
|
|
||||||
from miniopy_async import Minio
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import asyncio
|
|
||||||
from nats.aio.client import Client as NATS
|
|
||||||
|
|
||||||
from sqlalchemy import or_, select, update, and_
|
from miniopy_async import Minio
|
||||||
|
|
||||||
from src.core.async_cache import AsyncInMemoryCache
|
|
||||||
from src.models import RecognitionJob, StoredFile
|
|
||||||
from src.schemas.enums import RecognitionStatus, FileStatus
|
|
||||||
from src.db.db import async_session_maker
|
|
||||||
from src.core.config import settings
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
ext_to_subject = {
|
|
||||||
"XLS": "file.tabular_uploaded",
|
|
||||||
"XLSX": "file.tabular_uploaded",
|
|
||||||
"PNG": "file.img_uploaded",
|
|
||||||
"PDF": "file.img_uploaded",
|
|
||||||
"JPG": "file.img_uploaded",
|
|
||||||
}
|
|
||||||
|
|
||||||
class S3Storage:
|
class S3Storage:
|
||||||
def __init__(self, client: Minio, bucket_name: str):
|
def __init__(self, client: Minio, bucket_name: str):
|
||||||
@ -44,137 +25,3 @@ class S3Storage:
|
|||||||
return response
|
return response
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PollWorker:
|
|
||||||
def __init__(self, nats_client: NATS, cache: AsyncInMemoryCache, s3_client: S3Storage):
|
|
||||||
self.task: asyncio.Task | None = None
|
|
||||||
self.stop_event = asyncio.Event()
|
|
||||||
self.nats_client = nats_client
|
|
||||||
self.cache = cache
|
|
||||||
self.storage = s3_client
|
|
||||||
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
self.stop_event.clear()
|
|
||||||
self.task = asyncio.create_task(self.run())
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
self.stop_event.set()
|
|
||||||
if self.task:
|
|
||||||
self.task.cancel()
|
|
||||||
try:
|
|
||||||
await self.task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
logger.info("Начат процесс опроса БД по RecognitionJob")
|
|
||||||
while not self.stop_event.is_set():
|
|
||||||
try:
|
|
||||||
await self.poll_once()
|
|
||||||
except Exception:
|
|
||||||
# logger.exception("Poll iteration failed")
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
self.stop_event.wait(),
|
|
||||||
timeout=7,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info("PollWorker остановлен")
|
|
||||||
|
|
||||||
|
|
||||||
async def poll_once(self):
|
|
||||||
try:
|
|
||||||
rows = await self.claim_pending_rows()
|
|
||||||
logger.info("Получил rows. Количество: {rlen}", rlen=len(rows))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"Проблема при получении строк из БД по RecognitionJob. error_category=pollworker, error_type={error_type}, error={error}",
|
|
||||||
error_type=type(exc).__name__,
|
|
||||||
error=repr(exc),
|
|
||||||
)
|
|
||||||
rows=[]
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
row_id = row["id"]
|
|
||||||
try:
|
|
||||||
# await self.publish_to_nats(row["subject"], row["row_payload"])
|
|
||||||
|
|
||||||
if row['subject'] == 'file.img_uploaded':
|
|
||||||
last_part = row['row_payload']['s3_key'].split('/')[-1]
|
|
||||||
with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb') as f:
|
|
||||||
data = await self.storage.get_object(row['row_payload']['s3_key'])
|
|
||||||
content = await data.read()
|
|
||||||
f.write(content)
|
|
||||||
await self.cache.add(row['row_payload']['job_id'], row['row_payload']['file_id'],
|
|
||||||
row['row_payload']['s3_key'], row['row_payload']['legal_entity_id'])
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
# logger.exception(
|
|
||||||
# "Failed to publish row id={row_id}. error_category=pollworker, error_type={error_type}, error={error}",
|
|
||||||
# row_id=row_id,
|
|
||||||
# error_type=type(exc).__name__,
|
|
||||||
# error=repr(exc),
|
|
||||||
# )
|
|
||||||
logger.exception(
|
|
||||||
"Ошибка при попытке положить сообщение в кэш. error_category=pdfpollworker, error_type={error_type}, error={error}",
|
|
||||||
row_id=row_id,
|
|
||||||
error_type=type(exc).__name__,
|
|
||||||
error=repr(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.mark_queued(row_id)
|
|
||||||
|
|
||||||
async def claim_pending_rows(self):
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
stmt = (
|
|
||||||
select(RecognitionJob, StoredFile)
|
|
||||||
.join(StoredFile, StoredFile.id == RecognitionJob.file_id)
|
|
||||||
.where(and_(RecognitionJob.status == RecognitionStatus.QUEUED, StoredFile.status.is_(None)))
|
|
||||||
.with_for_update(skip_locked=True, read=True)
|
|
||||||
)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
result_scalars = result.all()
|
|
||||||
|
|
||||||
for (job, file) in result_scalars:
|
|
||||||
job.status = RecognitionStatus.PROCESSING
|
|
||||||
file.status = FileStatus.PROCESSING
|
|
||||||
job.requested_at = datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return [{
|
|
||||||
"id": job.id,
|
|
||||||
"subject": ext_to_subject[file.file_type],
|
|
||||||
"row_payload": {
|
|
||||||
"legal_entity_id": str(job.legal_entity_id),
|
|
||||||
"file_id": str(file.id),
|
|
||||||
"s3_key": str(file.s3_key),
|
|
||||||
"job_id": str(job.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
} for (job, file) in result_scalars]
|
|
||||||
|
|
||||||
async def publish_to_nats(self, subject, payload):
|
|
||||||
if not settings.DEBUG:
|
|
||||||
# logger.info("Попытка отправить сообщение в subject={subject}", subject=subject)
|
|
||||||
await self.nats_client.publish(subject, json.dumps(payload).encode("utf-8"))
|
|
||||||
else:
|
|
||||||
print("PUBLISHING TO NATS", subject, payload) #print достаточен, поскеольку работает локально
|
|
||||||
print()
|
|
||||||
|
|
||||||
async def mark_queued(self, row_id):
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
stmt = update(RecognitionJob).where(RecognitionJob.id == row_id).values(status=RecognitionStatus.QUEUED)
|
|
||||||
file_stmt = update(StoredFile).where(StoredFile.current_recognition_job_id == row_id).values(status=None)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.execute(file_stmt)
|
|
||||||
await session.commit()
|
|
||||||
@ -1,123 +1,18 @@
|
|||||||
from .clients import S3Storage
|
"""Обработчики сообщений NATS.
|
||||||
|
|
||||||
|
Каркас: доменный обработчик удалён. Consumer `recognition.result` (дедуп по recognition_job.result_msg_id,
|
||||||
|
маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по
|
||||||
|
docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py.
|
||||||
|
"""
|
||||||
|
from loguru import logger
|
||||||
from nats.aio.client import Client as NATS
|
from nats.aio.client import Client as NATS
|
||||||
from src.db.db import async_session_maker
|
|
||||||
from sqlalchemy import select, update, insert
|
from src.core.clients import S3Storage
|
||||||
from src.models import StoredFile, RecognitionJob, ChartOfAccount, OsvEntry
|
|
||||||
from src.schemas.enums import RecognitionStatus, FileStatus, DocumentType
|
|
||||||
import json
|
|
||||||
import copy
|
|
||||||
|
|
||||||
|
|
||||||
def json_to_db_rows(json_dict: dict, codes_mapping: dict) -> list:
|
def build_result_handler(nc: NATS, storage: S3Storage):
|
||||||
# {
|
|
||||||
# recognition_job_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("recognition_job.id"), nullable=False)
|
|
||||||
# legal_entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"))
|
|
||||||
# account_code: Mapped[str] = mapped_column(String)
|
|
||||||
# account_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('chart_of_account.id'))
|
|
||||||
# account_root: Mapped[str] = mapped_column(String)
|
|
||||||
# counterparty_name: Mapped[str] = mapped_column(String)
|
|
||||||
# counterparty_inn: Mapped[str] = mapped_column(String)
|
|
||||||
# counterparty_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"), nullable=True)
|
|
||||||
# opening_debit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# opening_credit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# turnover_debit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# turnover_credit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# closing_debit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# closing_credit: Mapped[Decimal] = mapped_column(Numeric)
|
|
||||||
# }
|
|
||||||
rows = copy.deepcopy(json_dict['rows'])
|
|
||||||
for row in rows:
|
|
||||||
row['recognition_job_id'] = json_dict['recognition_job_id']
|
|
||||||
row['legal_entity_id'] = json_dict['legal_entity_id']
|
|
||||||
normalized_account_code = normalize_account_code(row['account_code'])
|
|
||||||
row['account_id'] = codes_mapping[normalized_account_code]
|
|
||||||
row['stored_file_id'] = json_dict['stored_file_id']
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_account_code(acc_code: str):
|
|
||||||
split_parts = acc_code.split('.')
|
|
||||||
for i in range(len(split_parts)):
|
|
||||||
if len(split_parts[i]) == 1:
|
|
||||||
split_parts[i] = '0' + split_parts[i]
|
|
||||||
|
|
||||||
return '.'.join(split_parts)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_account_codes_mapping(session):
|
|
||||||
stmt = select(ChartOfAccount)
|
|
||||||
scalars: list[ChartOfAccount] = (await session.execute(stmt)).scalars().all()
|
|
||||||
result = {}
|
|
||||||
for i in range(len(scalars)):
|
|
||||||
result[scalars[i].code] = scalars[i].id
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_file_handler(nc: NATS, storage: S3Storage):
|
|
||||||
session_factory = async_session_maker
|
|
||||||
|
|
||||||
async def handler(msg):
|
async def handler(msg):
|
||||||
msg_json = json.loads(msg.data.decode('utf-8'))
|
logger.info("NATS message received. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data))
|
||||||
payload = msg_json['data']
|
await msg.ack()
|
||||||
job_id = payload['job_id']
|
|
||||||
file_id = payload['file_id']
|
|
||||||
legal_entity_id = payload['legal_entity_id']
|
|
||||||
status = msg_json['status']
|
|
||||||
if status != 'success':
|
|
||||||
async with session_factory() as session:
|
|
||||||
stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status = RecognitionStatus.ERROR)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
result_s3_path = payload.get('result_s3_path')
|
|
||||||
source_file_ext = payload['s3_key'].split(".")[-1].lower()
|
|
||||||
|
|
||||||
|
return handler
|
||||||
async with session_factory() as session:
|
|
||||||
file_upd_values = {}
|
|
||||||
if result_s3_path is None:
|
|
||||||
file_upd_values = {
|
|
||||||
"status": FileStatus.UPLOADED,
|
|
||||||
"document_type": DocumentType.OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
file_response = await storage.get_object(result_s3_path)
|
|
||||||
file = await file_response.content.read()
|
|
||||||
|
|
||||||
json_data = json.loads(file)
|
|
||||||
period_year = json_data.get('period_year')
|
|
||||||
period_quarter = json_data.get('period_quarter')
|
|
||||||
file_upd_values = {
|
|
||||||
"status": FileStatus.UPLOADED,
|
|
||||||
"period_quarter": period_quarter,
|
|
||||||
"period_year": period_year,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source_file_ext in ["xlsx", "xls"]):
|
|
||||||
codes_mapping = await get_account_codes_mapping(session)
|
|
||||||
db_rows = json_to_db_rows(json_data, codes_mapping)
|
|
||||||
await session.execute(insert(OsvEntry).values(db_rows))
|
|
||||||
|
|
||||||
file_upd_values['document_type'] = DocumentType.OSV
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
file_upd_values['document_type'] = DocumentType.BUH_OT
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
stmt = update(StoredFile).where(StoredFile.id == file_id).values(**file_upd_values).returning(StoredFile)
|
|
||||||
job_stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status=RecognitionStatus.DONE, raw_result_s3_key = result_s3_path).returning(RecognitionJob)
|
|
||||||
|
|
||||||
first_res = await session.execute(stmt)
|
|
||||||
sec_res = await session.execute(job_stmt)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return handler
|
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0."""
|
||||||
|
from .base import Base, UUIDPrimaryMixin
|
||||||
|
|
||||||
|
__all__ = ["Base", "UUIDPrimaryMixin"]
|
||||||
14
api/src/models/base.py
Normal file
14
api/src/models/base.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(schema="dkb")
|
||||||
|
|
||||||
|
|
||||||
|
@declarative_mixin
|
||||||
|
class UUIDPrimaryMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@ -1,60 +1,28 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import './App.css';
|
||||||
import "./App.css";
|
import { Layout } from 'antd';
|
||||||
import RfView from "./components/RfView";
|
import { Provider } from 'react-redux';
|
||||||
import { Button, Layout } from "antd";
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
// import FileUploaderModal from "./components/FileUploaderModel";
|
import { Config } from './conf/config';
|
||||||
// import { FileItem } from "./components/FilesList";
|
import { Main } from './layouts/Main';
|
||||||
import { Sidebar } from "./widgets/Sidebar/Sidebar";
|
import { store } from './store/store';
|
||||||
import { Main } from "./layouts/Main";
|
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|
||||||
import { Mono } from "./layouts/Monozaemchiki";
|
|
||||||
import { Catalogue } from "./layouts/Catalogue";
|
|
||||||
import { FileViewer } from "./layouts/FileViewer";
|
|
||||||
import { Provider } from "react-redux";
|
|
||||||
import { store } from "./store/store";
|
|
||||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
|
||||||
import { AuditLogging } from "./layouts/AuditLogging";
|
|
||||||
import { CatalogueGroup } from "./layouts/CatalogueGroups";
|
|
||||||
import { DocStorage } from "./layouts/DocStorage";
|
|
||||||
import { CatalogueJur } from "./layouts/CatalogueJur";
|
|
||||||
import { Config } from "./conf/config";
|
|
||||||
|
|
||||||
|
|
||||||
|
// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами.
|
||||||
function App() {
|
function App() {
|
||||||
const [companyGroups, setCompanyGroups] = useState([]);
|
return (
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
<Provider store={store}>
|
||||||
|
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||||
useEffect(() => {
|
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||||
async function fetchGroups() {
|
<Sidebar />
|
||||||
const res = await fetch(`${Config.API_URL}/company-groups`);
|
<Layout.Content>
|
||||||
const json = await res.json();
|
<Routes>
|
||||||
setCompanyGroups(() =>
|
<Route path='/' element={<Main />} />
|
||||||
json.map((c) => ({ id: c.id, name: c.name, isActive: false })),
|
</Routes>
|
||||||
);
|
</Layout.Content>
|
||||||
}
|
</Layout>
|
||||||
fetchGroups();
|
</BrowserRouter>
|
||||||
}, []);
|
</Provider>
|
||||||
|
);
|
||||||
return (
|
|
||||||
<Provider store={store}>
|
|
||||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
|
||||||
<Layout hasSider={true} style={{ height: "100vh" }}>
|
|
||||||
<Sidebar companyGroups={companyGroups} />
|
|
||||||
<Layout.Content>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/groups/:group_id" element={<Main />} />
|
|
||||||
<Route path="/groups/monozaemchiki" element={<Mono />} />
|
|
||||||
<Route path="/catalogue/groups" element={<CatalogueGroup />} />
|
|
||||||
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
|
|
||||||
<Route path="/files/:file_id" element={<FileViewer />} />
|
|
||||||
<Route path="/docstorage" element={<DocStorage />} />
|
|
||||||
<Route path="/logging" element={<AuditLogging />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout.Content>
|
|
||||||
</Layout>
|
|
||||||
</BrowserRouter>
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//@ts-ignore
|
// biome-ignore lint/suspicious/noExplicitAny: process.env подставляется rsbuild на этапе сборки
|
||||||
const environment = process.env;
|
const environment = (process as any).env;
|
||||||
|
|
||||||
export class Config {
|
export const Config = {
|
||||||
static API_URL: string = environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '';
|
API_URL: (environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '') as string,
|
||||||
static APP_ADDRESS: string = `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}`
|
APP_ADDRESS: `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}` as string,
|
||||||
}
|
};
|
||||||
|
|||||||
1
web/src/env.d.ts
vendored
Normal file
1
web/src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@rsbuild/core/types" />
|
||||||
@ -111,6 +111,6 @@ code {
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
|
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,37 +1,41 @@
|
|||||||
import { StrictMode } from "react";
|
import { createRoot } from 'react-dom/client';
|
||||||
import { createRoot } from "react-dom/client";
|
import './index.css';
|
||||||
import "./index.css";
|
import { ConfigProvider } from 'antd';
|
||||||
import App from "./App";
|
import App from './App';
|
||||||
import { ConfigProvider } from "antd";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
const rootElement = document.getElementById('root');
|
||||||
// <StrictMode>
|
if (!rootElement) {
|
||||||
<ConfigProvider
|
throw new Error('Элемент #root не найден');
|
||||||
theme={{
|
}
|
||||||
token: {
|
|
||||||
colorPrimary: "rgba(0, 255, 0, 0.5)",
|
createRoot(rootElement).render(
|
||||||
colorPrimaryHover: "rgba(0, 255, 0, 1)",
|
// <StrictMode>
|
||||||
colorBgLayout: "rgb(247, 248, 250)",
|
<ConfigProvider
|
||||||
},
|
theme={{
|
||||||
components: {
|
token: {
|
||||||
Layout: {
|
colorPrimary: 'rgba(0, 255, 0, 0.5)',
|
||||||
siderBg: "#fff",
|
colorPrimaryHover: 'rgba(0, 255, 0, 1)',
|
||||||
},
|
colorBgLayout: 'rgb(247, 248, 250)',
|
||||||
Button: {
|
},
|
||||||
// textHoverBg: "green",
|
components: {
|
||||||
colorBorder: "rgba(0,0,0,0.5)",
|
Layout: {
|
||||||
defaultActiveBorderColor: "rgba(0,0,0,0.5)",
|
siderBg: '#fff',
|
||||||
defaultBorderColor: "rgba(0,0,0,0.5)",
|
},
|
||||||
},
|
Button: {
|
||||||
Input: {
|
// textHoverBg: "green",
|
||||||
activeBorderColor: "rgba(46, 166, 75, 0.35)",
|
colorBorder: 'rgba(0,0,0,0.5)',
|
||||||
activeShadow: "0 0 0 1px #2ea64b59,0 1px 2px #0000000f",
|
defaultActiveBorderColor: 'rgba(0,0,0,0.5)',
|
||||||
hoverBorderColor: "rgba(46, 166, 75, 0.35)",
|
defaultBorderColor: 'rgba(0,0,0,0.5)',
|
||||||
},
|
},
|
||||||
},
|
Input: {
|
||||||
}}
|
activeBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||||
>
|
activeShadow: '0 0 0 1px #2ea64b59,0 1px 2px #0000000f',
|
||||||
<App />
|
hoverBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||||
</ConfigProvider>,
|
},
|
||||||
// </StrictMode>,
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>,
|
||||||
|
// </StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,131 +1,15 @@
|
|||||||
import {
|
// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md.
|
||||||
Button,
|
|
||||||
Drawer,
|
|
||||||
Splitter,
|
|
||||||
Table,
|
|
||||||
Tooltip,
|
|
||||||
type TableProps,
|
|
||||||
} from "antd";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { FileItem } from "../components/FilesList";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import FileUploaderModal from "../components/FileUploaderModel";
|
|
||||||
import { useAppDispatch, useAppSelector } from "../store/store";
|
|
||||||
import { getFilesThunk } from "../store/slices/fileSlice";
|
|
||||||
import { FileOutlined } from "@ant-design/icons";
|
|
||||||
import { ConsoleLegalEntities } from "../widgets/ConsoleLegalEntities/ConsoleLegalEntities";
|
|
||||||
import { Config } from "../conf/config";
|
|
||||||
|
|
||||||
async function getByLEId(legalEntityId: string) {
|
|
||||||
const filesRes = await fetch(`${Config.API_URL}/files/${legalEntityId}`);
|
|
||||||
const json = await filesRes.json();
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerContent({ drawerContent, leid, onReloadCall }) {
|
|
||||||
// if (!drawerContent.length) return;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FileUploaderModal legal_entity_id={leid} trigger_sse_cb={() => {}} />
|
|
||||||
{drawerContent.map((f) => (
|
|
||||||
<FileItem key={f.id} file={f} />
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onReloadCall(leid);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Перезагрузить список файлов
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Main() {
|
export function Main() {
|
||||||
const [legalEntities, setLegalEntities] = useState([]);
|
return (
|
||||||
const [legEntId, setLegEntId] = useState(null);
|
<div
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
style={{
|
||||||
// const [drawerContent, setDrawerContent] = useState([]);
|
margin: 10,
|
||||||
const { group_id } = useParams();
|
padding: 16,
|
||||||
const dispatch = useAppDispatch();
|
background: 'white',
|
||||||
const drawerContent = useAppSelector((state) => state.files.currentOpenFiles);
|
borderRadius: 10,
|
||||||
|
height: 'calc(100% - 20px)',
|
||||||
useEffect(() => {
|
}}>
|
||||||
async function getLegEntitites() {
|
<h3>АРМ КА</h3>
|
||||||
const res = await fetch(
|
</div>
|
||||||
`${Config.API_URL}/legal-entities/by-company-group?company_group_id=${group_id}`,
|
);
|
||||||
);
|
|
||||||
let data;
|
|
||||||
if (!res.ok) {
|
|
||||||
data = [];
|
|
||||||
} else {
|
|
||||||
data = await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
setLegalEntities(data);
|
|
||||||
}
|
|
||||||
getLegEntitites();
|
|
||||||
}, [group_id]);
|
|
||||||
|
|
||||||
const onReloadCall = useCallback(
|
|
||||||
async (id) => {
|
|
||||||
// const files = await getByLEId(id);
|
|
||||||
|
|
||||||
// setDrawerContent(files);
|
|
||||||
await dispatch(getFilesThunk(id));
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
const handleLEChoice = useCallback(
|
|
||||||
async (id) => {
|
|
||||||
await dispatch(getFilesThunk(id));
|
|
||||||
|
|
||||||
setDrawerOpen(true);
|
|
||||||
setLegEntId(id);
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "10px",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
height: "calc(100% - 20px)",
|
|
||||||
alignItems: "center",
|
|
||||||
margin: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "white",
|
|
||||||
borderRadius: "10px",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3>Консолидации</h3>
|
|
||||||
</div>
|
|
||||||
<ConsoleLegalEntities
|
|
||||||
handleLEChoice={handleLEChoice}
|
|
||||||
legalEntities={legalEntities}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Drawer
|
|
||||||
size="large"
|
|
||||||
open={drawerOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerContent
|
|
||||||
drawerContent={drawerContent}
|
|
||||||
leid={legEntId}
|
|
||||||
onReloadCall={onReloadCall}
|
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import filesReducer from "./slices/fileSlice";
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
|
// Каркас: слайсы добавляются срезами в ./slices.
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {},
|
||||||
files: filesReducer,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
|||||||
@ -1,325 +1,90 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { HomeOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import styles from "./styles.module.css";
|
import { Divider, Layout, Tooltip } from 'antd';
|
||||||
import {
|
import { type ReactNode, memo, useState } from 'react';
|
||||||
Divider,
|
import { NavLink } from 'react-router-dom';
|
||||||
Empty,
|
import styles from './styles.module.css';
|
||||||
Input,
|
|
||||||
Layout,
|
|
||||||
Tooltip,
|
|
||||||
type MenuProps,
|
|
||||||
Modal,
|
|
||||||
} from "antd";
|
|
||||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
BankOutlined,
|
|
||||||
ContainerOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
LeftOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
SolutionOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
|
|
||||||
type CompanyGroupType = {
|
|
||||||
id: str;
|
|
||||||
name: str;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MenuItem = Required<MenuProps>["items"][number];
|
|
||||||
|
|
||||||
function CollapsedIcon({ icon, onClick, title, to, cn = "" }) {
|
|
||||||
if (to === null) {
|
|
||||||
return (
|
|
||||||
<Tooltip placement="right" title={title}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn || `${styles.iconBtn} ${styles.iconBtnNav}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip placement="right" title={title}>
|
|
||||||
<NavLink
|
|
||||||
to={to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn ||
|
|
||||||
`${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</NavLink>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
link: string;
|
link: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: any;
|
icon: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
title: string;
|
title: string;
|
||||||
key: string;
|
key: string;
|
||||||
children: NavItem[];
|
children: NavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Каркас: разделы меню — по карте экранов docs/context/10-user-journey.md, добавляются срезами.
|
||||||
const config: Group[] = [
|
const config: Group[] = [
|
||||||
{
|
{
|
||||||
title: "Справочники",
|
title: 'Главная',
|
||||||
key: "catalogues",
|
key: 'home',
|
||||||
children: [
|
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
|
||||||
{
|
},
|
||||||
link: "/catalogue/groups",
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
label: "ГК и организации",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
link: "/catalogue/jur_l",
|
|
||||||
icon: <SolutionOutlined />,
|
|
||||||
label: "ЮЛ вне ГК",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Документы",
|
|
||||||
key: "docs",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
link: "/docstorage",
|
|
||||||
icon: <InboxOutlined />,
|
|
||||||
label: "Единое хранилище",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Система",
|
|
||||||
key: "system",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
link: "/logging",
|
|
||||||
icon: <ContainerOutlined />,
|
|
||||||
label: "Журнал действий",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function NavLinkStyled({ to, children }) {
|
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Tooltip placement='right' title={title}>
|
||||||
className={({ isActive }) =>
|
<NavLink to={to} className={({ isActive }) => `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}>
|
||||||
`${styles.linkBase} ${isActive ? styles.linkActive : ""}`
|
{icon}
|
||||||
}
|
</NavLink>
|
||||||
to={to}
|
</Tooltip>
|
||||||
>
|
);
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Section = memo(function Section({
|
function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
||||||
isCollapsed,
|
return (
|
||||||
group,
|
<NavLink className={({ isActive }) => `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}>
|
||||||
}: {
|
{children}
|
||||||
isCollapsed: boolean;
|
</NavLink>
|
||||||
group: Group;
|
);
|
||||||
}) {
|
}
|
||||||
return !isCollapsed ? (
|
|
||||||
<div className={styles.section}>
|
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||||
<div className={styles.sectionTitle}>{group.title}</div>
|
return !isCollapsed ? (
|
||||||
{group.children.map((i) => {
|
<div className={styles.section}>
|
||||||
return (
|
<div className={styles.sectionTitle}>{group.title}</div>
|
||||||
<NavLinkStyled key={i.link} to={i.link}>
|
{group.children.map((i) => (
|
||||||
{i.icon}
|
<NavLinkStyled key={i.link} to={i.link}>
|
||||||
{i.label}
|
{i.icon}
|
||||||
</NavLinkStyled>
|
{i.label}
|
||||||
);
|
</NavLinkStyled>
|
||||||
})}
|
))}
|
||||||
<Divider size="small" />
|
<Divider size='small' />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{group.children.map((i) => (
|
{group.children.map((i) => (
|
||||||
<CollapsedIcon
|
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
|
||||||
key={i.link}
|
))}
|
||||||
icon={i.icon}
|
</>
|
||||||
// onClick={() => navigate(i.link)}
|
);
|
||||||
onClick={() => {}}
|
|
||||||
// isActive={isGroupActive(i.link)}
|
|
||||||
// isActive={false}
|
|
||||||
to={i.link}
|
|
||||||
title="Единое хранилище"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar() {
|
||||||
companyGroups,
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
}: {
|
|
||||||
companyGroups: CompanyGroupType[];
|
|
||||||
}) {
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [subgroupOpen, setSubgroupOpen] = useState(true);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const location = useLocation();
|
return (
|
||||||
const navigate = useNavigate();
|
<Layout.Sider
|
||||||
const onCollapsedSearchClick = useCallback(() => {
|
collapsed={isCollapsed}
|
||||||
setIsCollapsed(false);
|
style={{ height: 'auto', margin: '10px', borderRadius: 10 }}
|
||||||
const cb = requestIdleCallback(() => {
|
width={280}
|
||||||
if (inputRef.current) {
|
collapsedWidth={64}
|
||||||
inputRef.current.focus();
|
collapsible
|
||||||
} else {
|
trigger={null}>
|
||||||
console.log("NO INPUT REF");
|
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
||||||
}
|
<div className={styles.topbar}>
|
||||||
});
|
<button type='button' className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||||
}, []);
|
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
|
</button>
|
||||||
const filteredSubgroups = useMemo(() => {
|
</div>
|
||||||
const query = search.toLowerCase().trim();
|
{config.map((g) => (
|
||||||
if (!query) return companyGroups;
|
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||||
return companyGroups.filter((cg) => {
|
))}
|
||||||
return cg.name.toLowerCase().includes(query);
|
</div>
|
||||||
});
|
</Layout.Sider>
|
||||||
}, [search, companyGroups]);
|
);
|
||||||
|
|
||||||
const isGroupCompanyGroup = useCallback(() => {
|
|
||||||
if (
|
|
||||||
location.pathname.startsWith("/groups/") &&
|
|
||||||
!location.pathname.endsWith("monozaemchiki")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
const groupsBaseRef =
|
|
||||||
companyGroups.length > 0 ? `/groups/${companyGroups[0].id}` : "/groups";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout.Sider
|
|
||||||
collapsed={isCollapsed}
|
|
||||||
style={{ height: "auto", margin: "10px", borderRadius: 10 }}
|
|
||||||
width={280}
|
|
||||||
collapsedWidth={64}
|
|
||||||
collapsible
|
|
||||||
trigger={null}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ""}`}
|
|
||||||
>
|
|
||||||
<div className={styles.topbar}>
|
|
||||||
<button
|
|
||||||
className={styles.collapseBtn}
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<div>
|
|
||||||
<CollapsedIcon
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
cn={`${styles.iconBtn} ${styles.iconBtnAction}`}
|
|
||||||
to={null}
|
|
||||||
// isActive={false}
|
|
||||||
title="Новая консолидация"
|
|
||||||
onClick={() => {
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollapsedIcon
|
|
||||||
icon={<SearchOutlined />}
|
|
||||||
to={null}
|
|
||||||
title="Поиск ГК"
|
|
||||||
onClick={onCollapsedSearchClick}
|
|
||||||
/>
|
|
||||||
<CollapsedIcon
|
|
||||||
icon={<BankOutlined />}
|
|
||||||
onClick={() => navigate(groupsBaseRef)}
|
|
||||||
to={groupsBaseRef}
|
|
||||||
title="Группы компаний"
|
|
||||||
/>
|
|
||||||
<CollapsedIcon
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
onClick={() => navigate("/groups/monozaemchiki")}
|
|
||||||
to="/groups/monozaemchiki"
|
|
||||||
title="Монозаёмщики"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.section}>
|
|
||||||
<div className={styles.sectionTitle}>Отчётность</div>
|
|
||||||
<Input
|
|
||||||
allowClear
|
|
||||||
ref={inputRef}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
|
||||||
placeholder="Поиск ГК"
|
|
||||||
className={styles.search}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSubgroupOpen((v) => !v)}
|
|
||||||
className={styles.subgroupToggle}
|
|
||||||
>
|
|
||||||
<span>Группы компаний</span>
|
|
||||||
{subgroupOpen ? <DownOutlined /> : <RightOutlined />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{subgroupOpen && (
|
|
||||||
<div className={styles.subgroup}>
|
|
||||||
{filteredSubgroups.length === 0 ? (
|
|
||||||
<div className={styles.emptyWrap}>
|
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description="Не найдено"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredSubgroups.map((item) => (
|
|
||||||
<NavLinkStyled key={item.id} to={`/groups/${item.id}`}>
|
|
||||||
{item.name}
|
|
||||||
</NavLinkStyled>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Divider size="small" style={{ margin: 10 }} />
|
|
||||||
<NavLinkStyled to="/groups/monozaemchiki">
|
|
||||||
Монозаёмщики
|
|
||||||
</NavLinkStyled>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{config.map((g) => (
|
|
||||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Modal
|
|
||||||
open={modalOpen}
|
|
||||||
title="Форма создания консолидации"
|
|
||||||
onCancel={() => {
|
|
||||||
setModalOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Layout.Sider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +1,86 @@
|
|||||||
.company_group_item {
|
.company_group_item {
|
||||||
border: 2px solid grey;
|
border: 2px solid grey;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: lightgray;
|
background-color: lightgray;
|
||||||
}
|
}
|
||||||
.active_cg {
|
.active_cg {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsedList {
|
.collapsedList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #8c8c8c;
|
color: #8c8c8c;
|
||||||
text-transform: uppercase ;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subgroupToggle {
|
.subgroupToggle {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: rgba(0, 0, 0, 0.88);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subgroup {
|
.subgroup {
|
||||||
/* padding-left: 8px; */
|
/* padding-left: 8px; */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* gap: 10px; */
|
/* gap: 10px; */
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkBase {
|
.linkBase {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: rgba(55, 65, 81, 0.88);
|
color: rgba(55, 65, 81, 0.88);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .linkActive:hover {
|
/* .linkActive:hover {
|
||||||
@ -89,103 +88,101 @@
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
.linkBase:hover {
|
.linkBase:hover {
|
||||||
color: rgba(55, 65, 81, 0.88);
|
color: rgba(55, 65, 81, 0.88);
|
||||||
background: rgb(249, 250, 251);
|
background: rgb(249, 250, 251);
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkBase.linkActive {
|
.linkBase.linkActive {
|
||||||
color: rgb(31, 114, 55);
|
color: rgb(31, 114, 55);
|
||||||
background: #edf8ee;
|
background: #edf8ee;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyWrap {
|
.emptyWrap {
|
||||||
padding-top: 8;
|
padding-top: 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapperCollapsed {
|
.wrapperCollapsed {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsedList {
|
.collapsedList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtn {
|
.iconBtn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.iconBtnActive,
|
.iconBtnActive,
|
||||||
.iconBtnActive:hover {
|
.iconBtnActive:hover {
|
||||||
background: rgba(46, 166, 75, 0.16);
|
background: rgba(46, 166, 75, 0.16);
|
||||||
color: rgb(31, 114, 55);
|
color: rgb(31, 114, 55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtnNav:hover {
|
.iconBtnNav:hover {
|
||||||
color: #101828;
|
color: #101828;
|
||||||
background: #f2f4f7;
|
background: #f2f4f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtnAction {
|
.iconBtnAction {
|
||||||
background: linear-gradient(160deg,#2ea64b,#1f7237);
|
background: linear-gradient(160deg, #2ea64b, #1f7237);
|
||||||
font-size: small;
|
font-size: small;
|
||||||
box-shadow: 0 1px 3px #1f723740;
|
box-shadow: 0 1px 3px #1f723740;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtnAction:hover {
|
.iconBtnAction:hover {
|
||||||
background: linear-gradient(160deg,#268f40,#185c2b);
|
background: linear-gradient(160deg, #268f40, #185c2b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .iconBtn. */
|
/* .iconBtn. */
|
||||||
|
|
||||||
|
|
||||||
.collapseBtn {
|
.collapseBtn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user