chore: вычищены остатки доменного кода, каркас проходит tsc/biome/rsbuild

This commit is contained in:
Andrey Epifancev 2026-10-02 23:34:58 +04:00
parent 29e5cc5bf4
commit 1bd97a3672
16 changed files with 372 additions and 1108 deletions

View File

@ -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

View File

@ -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")

View File

@ -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()
]

View File

@ -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()

View File

@ -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

View File

@ -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
View 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)

View File

@ -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;

View File

@ -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
View File

@ -0,0 +1 @@
/// <reference types="@rsbuild/core/types" />

View File

@ -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;
} }

View File

@ -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>,
); );

View File

@ -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>
</>
);
} }

View File

@ -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>;

View File

@ -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>
);
} }

View File

@ -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;
} }