Initial commit: Базовая структура сайта

This commit is contained in:
2026-02-11 12:06:30 +05:00
parent b41f161e8f
commit d9a2ad7f15
62 changed files with 3901 additions and 0 deletions

40
.gitignore vendored Executable file
View File

@@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
*.egg-info/
dist/
build/
# FastAPI
.env
!.env.example
*.log
# React
node_modules/
dist/
build/
.DS_Store
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Database
*.db
*.sqlite
# OS
.DS_Store
Thumbs.db

320
DEPLOYMENT.md Executable file
View File

@@ -0,0 +1,320 @@
# Инструкция по развертыванию
## Быстрый старт
### 1. Подготовка окружения
#### Установка зависимостей системы
**Ubuntu/Debian:**
```bash
sudo apt-get update
sudo apt-get install -y python3.9 python3-pip python3-venv postgresql-client nodejs npm
```
**Windows:**
- Установите Python 3.9+ с официального сайта
- Установите Node.js 18+ с официального сайта
- Установите PostgreSQL с официального сайта
### 2. Настройка баз данных
#### Создание readonly роли для ManicTime БД
**ВАЖНО**: Выполните эти команды на сервере PostgreSQL, где находится база данных ManicTime:
```sql
-- Подключитесь к PostgreSQL как суперпользователь
psql -U postgres
-- Создайте роль
CREATE ROLE manictime_readonly LOGIN PASSWORD 'N0v1y_S3cur3_P@ssw0rd!';
-- Предоставьте права на подключение к БД
GRANT CONNECT ON DATABASE "ManicTimeReports" TO manictime_readonly;
-- Предоставьте права на схему
GRANT USAGE ON SCHEMA public TO manictime_readonly;
-- Предоставьте права на чтение таблиц
GRANT SELECT ON ALL TABLES IN SCHEMA public TO manictime_readonly;
-- Для новых таблиц (если они будут созданы)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO manictime_readonly;
```
#### Создание служебной базы данных
```sql
CREATE DATABASE dashboard_service;
```
### 3. Настройка Backend
```bash
cd backend
# Создание виртуального окружения
python -m venv venv
# Активация (Windows)
venv\Scripts\activate
# Активация (Linux/Mac)
source venv/bin/activate
# Установка зависимостей
pip install -r requirements.txt
# Создание файла .env
cp .env.example .env
# Отредактируйте .env с вашими настройками
# Используйте любой текстовый редактор
```
#### Настройка .env файла
Откройте `backend/.env` и заполните следующие параметры:
```env
# ManicTime Database (Read-Only)
MANICTIME_DB_HOST=192.168.1.100 # IP или hostname сервера ManicTime БД
MANICTIME_DB_PORT=5432
MANICTIME_DB_NAME=ManicTimeReports
MANICTIME_DB_USER=manictime_readonly
MANICTIME_DB_PASSWORD=N0v1y_S3cur3_P@ssw0rd! # Пароль, который вы установили выше
# Service Database
SERVICE_DB_HOST=localhost
SERVICE_DB_PORT=5432
SERVICE_DB_NAME=dashboard_service
SERVICE_DB_USER=postgres
SERVICE_DB_PASSWORD=ваш_пароль_postgres
# JWT Settings
JWT_SECRET_KEY=$(openssl rand -hex 32) # Сгенерируйте случайный ключ
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
```
**Для генерации JWT_SECRET_KEY на Linux/Mac:**
```bash
openssl rand -hex 32
```
**Для Windows:**
Используйте онлайн генератор или Python:
```python
import secrets
print(secrets.token_hex(32))
```
#### Инициализация базы данных
```bash
# Применение миграций
alembic upgrade head
# Создание начальных ролей и администратора
python scripts/init_db.py
```
### 4. Запуск Backend
```bash
# Режим разработки
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Или для production (рекомендуется использовать gunicorn)
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
```
Backend будет доступен по адресу: `http://localhost:8000`
API документация: `http://localhost:8000/docs`
### 5. Настройка Frontend
```bash
cd frontend
# Установка зависимостей
npm install
# Запуск в режиме разработки
npm run dev
# Или сборка для production
npm run build
```
Frontend будет доступен по адресу: `http://localhost:3000`
### 6. Первый вход
После успешного запуска:
1. Откройте браузер и перейдите на `http://localhost:3000`
2. Вы увидите страницу входа
3. Используйте учетные данные:
- **Логин**: `admin`
- **Пароль**: `admin123`
4. После входа **НЕМЕДЛЕННО** измените пароль через панель администратора
## Развертывание в Production
### Использование Docker
```bash
# Создайте .env файл в корне проекта
cp backend/.env.example .env
# Отредактируйте .env
# Запуск всех сервисов
docker-compose up -d
# Просмотр логов
docker-compose logs -f
# Остановка
docker-compose down
```
### Настройка Nginx (рекомендуется)
Пример конфигурации Nginx для production:
```nginx
server {
listen 80;
server_name your-domain.com;
# Frontend
location / {
root /path/to/frontend/dist;
try_files $uri $uri/ /index.html;
}
# Backend API
location /api {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Systemd Service (для Linux)
Создайте файл `/etc/systemd/system/manictime-dashboard.service`:
```ini
[Unit]
Description=ManicTime Dashboard Backend
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/dash/backend
Environment="PATH=/path/to/dash/backend/venv/bin"
ExecStart=/path/to/dash/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
[Install]
WantedBy=multi-user.target
```
Активация сервиса:
```bash
sudo systemctl daemon-reload
sudo systemctl enable manictime-dashboard
sudo systemctl start manictime-dashboard
sudo systemctl status manictime-dashboard
```
## Устранение неполадок
### Проблема: Не удается подключиться к БД ManicTime
**Решение:**
1. Проверьте, что роль `manictime_readonly` создана и имеет правильные права
2. Проверьте файрвол - порт 5432 должен быть открыт
3. Проверьте настройки в `.env`
4. Попробуйте подключиться вручную: `psql -h HOST -U manictime_readonly -d ManicTimeReports`
### Проблема: Ошибка при миграциях
**Решение:**
```bash
# Проверьте подключение к служебной БД
psql -h localhost -U postgres -d dashboard_service
# Если БД не существует, создайте её
createdb dashboard_service
# Повторите миграции
alembic upgrade head
```
### Проблема: Frontend не может подключиться к Backend
**Решение:**
1. Проверьте, что Backend запущен на порту 8000
2. Проверьте настройки прокси в `vite.config.js`
3. Проверьте CORS настройки в `backend/app/main.py`
4. Проверьте консоль браузера на наличие ошибок
### Проблема: JWT токены не работают
**Решение:**
1. Убедитесь, что `JWT_SECRET_KEY` установлен в `.env`
2. Проверьте, что ключ достаточно длинный (минимум 32 символа)
3. Перезапустите Backend после изменения ключа
## Обновление системы
```bash
# 1. Остановите сервисы
docker-compose down # или systemctl stop manictime-dashboard
# 2. Обновите код
git pull # или скопируйте новые файлы
# 3. Обновите зависимости
cd backend
pip install -r requirements.txt
cd ../frontend
npm install
# 4. Примените миграции БД
cd backend
alembic upgrade head
# 5. Перезапустите сервисы
docker-compose up -d # или systemctl restart manictime-dashboard
```
## Резервное копирование
### База данных служебной БД
```bash
# Создание резервной копии
pg_dump -h localhost -U postgres dashboard_service > backup_$(date +%Y%m%d).sql
# Восстановление
psql -h localhost -U postgres dashboard_service < backup_20240101.sql
```
### Файлы конфигурации
Всегда сохраняйте копию файла `.env` в безопасном месте!

23
backend/.env.example Executable file
View File

@@ -0,0 +1,23 @@
# ManicTime Database Connection (Read-Only)
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
MANICTIME_DB_HOST=localhost
MANICTIME_DB_PORT=5432
MANICTIME_DB_NAME=ManicTimeReports
MANICTIME_DB_USER=manictime_readonly
MANICTIME_DB_PASSWORD=N0v1y_S3cur3_P@ssw0rd!
# Service Database (для управления сервисом)
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
SERVICE_DB_HOST=localhost
SERVICE_DB_PORT=5432
SERVICE_DB_NAME=dashboard_service
SERVICE_DB_USER=postgres
SERVICE_DB_PASSWORD=your_password_here
# JWT Settings
# ВАЖНО: Сгенерируйте случайный ключ для production!
# Для генерации на Linux/Mac: openssl rand -hex 32
# Для Windows: используйте Python: import secrets; print(secrets.token_hex(32))
JWT_SECRET_KEY=change-this-secret-key-in-production-use-random-hex-string
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30

22
backend/Dockerfile Executable file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Установка зависимостей системы
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Копирование requirements и установка зависимостей Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода приложения
COPY . .
# Открытие порта
EXPOSE 8000
# Команда запуска
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

114
backend/alembic.ini Executable file
View File

@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration files
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# version_locations option
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new_file_template = %%(slug)s
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://postgres:password@localhost/dashboard_service
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

79
backend/alembic/env.py Executable file
View File

@@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Добавляем путь к приложению
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.database import Base, service_engine
from app.models.service_db import * # Импортируем все модели
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the Alembic env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = service_engine
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
backend/alembic/script.py.mako Executable file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,86 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Создание таблицы app_roles
op.create_table(
'app_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_name', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_name')
)
# Создание таблицы app_users
op.create_table(
'app_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('login', sa.String(length=100), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
sa.ForeignKeyConstraint(['role_id'], ['app_roles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('login')
)
op.create_index(op.f('ix_app_users_id'), 'app_users', ['id'], unique=False)
op.create_index(op.f('ix_app_users_login'), 'app_users', ['login'], unique=True)
# Создание таблицы leave_events
op.create_table(
'leave_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=False),
sa.Column('leave_type', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['app_users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_leave_events_id'), 'leave_events', ['id'], unique=False)
# Создание таблицы app_configuration
op.create_table(
'app_configuration',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value', sa.String(length=500), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_app_configuration_id'), 'app_configuration', ['id'], unique=False)
op.create_index(op.f('ix_app_configuration_key'), 'app_configuration', ['key'], unique=True)
# Добавление начальных ролей
op.execute("INSERT INTO app_roles (role_name) VALUES ('Admin'), ('User')")
def downgrade() -> None:
op.drop_index(op.f('ix_app_configuration_key'), table_name='app_configuration')
op.drop_index(op.f('ix_app_configuration_id'), table_name='app_configuration')
op.drop_table('app_configuration')
op.drop_index(op.f('ix_leave_events_id'), table_name='leave_events')
op.drop_table('leave_events')
op.drop_index(op.f('ix_app_users_login'), table_name='app_users')
op.drop_index(op.f('ix_app_users_id'), table_name='app_users')
op.drop_table('app_users')
op.drop_table('app_roles')

0
backend/app/__init__.py Executable file
View File

8
backend/app/__main__.py Executable file
View File

@@ -0,0 +1,8 @@
"""
Точка входа для запуска приложения через python -m app
"""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

0
backend/app/api/__init__.py Executable file
View File

15
backend/app/api/v1/__init__.py Executable file
View File

@@ -0,0 +1,15 @@
"""
API v1 роутер
"""
from fastapi import APIRouter
from app.api.v1 import auth, summary, timeline, metrics, leave, admin
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(summary.router, prefix="/summary", tags=["summary"])
api_router.include_router(timeline.router, prefix="/timeline", tags=["timeline"])
api_router.include_router(metrics.router, prefix="/metrics", tags=["metrics"])
api_router.include_router(leave.router, prefix="/leave", tags=["leave"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

120
backend/app/api/v1/admin.py Executable file
View File

@@ -0,0 +1,120 @@
"""
Модуль Панель Администратора
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_service_db
from app.core.security import get_admin_user, get_password_hash
from app.models.service_db import AppUser, AppRole, AppConfiguration
from app.schemas.user import UserCreate, UserResponse
from app.schemas.config import ConfigResponse, ConfigUpdate
router = APIRouter()
@router.get("/users", response_model=List[UserResponse])
async def get_users(
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Получение списка всех пользователей сервиса"""
users = db.query(AppUser).all()
result = []
for user in users:
result.append(UserResponse(
id=user.id,
login=user.login,
role_id=user.role_id,
role_name=user.role.role_name if user.role else None,
is_active=user.is_active,
created_at=user.created_at
))
return result
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Создание нового пользователя сервиса"""
# Проверка существования логина
existing_user = db.query(AppUser).filter(AppUser.login == user_data.login).first()
if existing_user:
raise HTTPException(status_code=400, detail="Пользователь с таким логином уже существует")
# Проверка существования роли
role = db.query(AppRole).filter(AppRole.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Роль не найдена")
new_user = AppUser(
login=user_data.login,
hashed_password=get_password_hash(user_data.password),
role_id=user_data.role_id,
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return UserResponse(
id=new_user.id,
login=new_user.login,
role_id=new_user.role_id,
role_name=new_user.role.role_name if new_user.role else None,
is_active=new_user.is_active,
created_at=new_user.created_at
)
@router.get("/config", response_model=List[ConfigResponse])
async def get_config(
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Получение конфигурации (нечувствительные данные)"""
config_items = db.query(AppConfiguration).filter(
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
).all()
result = []
for item in config_items:
result.append(ConfigResponse(key=item.key, value=item.value))
return result
@router.put("/config", response_model=ConfigResponse)
async def update_config(
config_data: ConfigUpdate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Обновление конфигурации (нечувствительные данные)"""
allowed_keys = ["manictime_host", "manictime_port", "manictime_dbname"]
if config_data.key not in allowed_keys:
raise HTTPException(
status_code=400,
detail=f"Ключ '{config_data.key}' не разрешен для редактирования"
)
config_item = db.query(AppConfiguration).filter(
AppConfiguration.key == config_data.key
).first()
if not config_item:
config_item = AppConfiguration(key=config_data.key, value=config_data.value)
db.add(config_item)
else:
config_item.value = config_data.value
db.commit()
db.refresh(config_item)
return ConfigResponse(key=config_item.key, value=config_item.value)

43
backend/app/api/v1/auth.py Executable file
View File

@@ -0,0 +1,43 @@
"""
Модуль аутентификации
"""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_service_db
from app.core.security import verify_password, create_access_token
from app.core.config import settings
from app.models.service_db import AppUser
from app.schemas.auth import LoginRequest, Token
router = APIRouter()
@router.post("/token", response_model=Token)
async def login(
login_data: LoginRequest,
db: Session = Depends(get_service_db)
):
"""Аутентификация пользователя и получение JWT токена"""
user = db.query(AppUser).filter(AppUser.login == login_data.login).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль"
)
if not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль"
)
access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.id, "role": user.role.role_name},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

99
backend/app/api/v1/leave.py Executable file
View File

@@ -0,0 +1,99 @@
"""
Модуль Отпуска/Больничные
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.orm import Session
from datetime import date
from typing import List
from app.core.database import get_service_db
from app.core.security import get_current_user, get_admin_user
from app.models.service_db import AppUser, LeaveEvent
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
router = APIRouter()
@router.get("/events", response_model=List[LeaveEventResponse])
async def get_leave_events(
start_date: date = Query(..., description="Начальная дата"),
end_date: date = Query(..., description="Конечная дата"),
current_user: AppUser = Depends(get_current_user),
db: Session = Depends(get_service_db)
):
"""
Получение списка событий отпусков и больничных за период
"""
if start_date > end_date:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
events = db.query(LeaveEvent).filter(
LeaveEvent.start_date <= end_date,
LeaveEvent.end_date >= start_date
).all()
result = []
for event in events:
result.append(LeaveEventResponse(
id=event.id,
user_id=event.user_id,
user_login=event.user.login if event.user else None,
start_date=event.start_date,
end_date=event.end_date,
leave_type=event.leave_type,
created_at=event.created_at.isoformat() if event.created_at else None
))
return result
@router.post("/events", response_model=LeaveEventResponse, status_code=status.HTTP_201_CREATED)
async def create_leave_event(
event_data: LeaveEventCreate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""
Создание нового события отпуска/больничного (только для администраторов)
"""
if event_data.start_date > event_data.end_date:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
# Проверка существования пользователя
user = db.query(AppUser).filter(AppUser.id == event_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
# Проверка на пересечение с существующими событиями
overlapping = db.query(LeaveEvent).filter(
LeaveEvent.user_id == event_data.user_id,
LeaveEvent.start_date <= event_data.end_date,
LeaveEvent.end_date >= event_data.start_date
).first()
if overlapping:
raise HTTPException(
status_code=400,
detail="Уже существует событие в этом периоде для данного пользователя"
)
new_event = LeaveEvent(
user_id=event_data.user_id,
start_date=event_data.start_date,
end_date=event_data.end_date,
leave_type=event_data.leave_type
)
db.add(new_event)
db.commit()
db.refresh(new_event)
return LeaveEventResponse(
id=new_event.id,
user_id=new_event.user_id,
user_login=new_event.user.login if new_event.user else None,
start_date=new_event.start_date,
end_date=new_event.end_date,
leave_type=new_event.leave_type,
created_at=new_event.created_at.isoformat() if new_event.created_at else None
)

98
backend/app/api/v1/metrics.py Executable file
View File

@@ -0,0 +1,98 @@
"""
Модуль Метрика - числовые показатели эффективности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Literal
from app.core.database import get_manictime_db
from app.core.security import get_current_user
from app.models.service_db import AppUser
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
router = APIRouter()
@router.get("/aggregate", response_model=MetricsAggregateResponse)
async def get_metrics_aggregate(
period: Literal["week", "month", "quarter", "year"] = Query(..., description="Период агрегации"),
year: int = Query(..., description="Год"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение агрегированных метрик за указанный период
"""
try:
if year < 2000 or year > 2100:
raise HTTPException(status_code=400, detail="Неверный год")
# Определение функции агрегации в зависимости от периода
date_trunc_func = {
"week": "week",
"month": "month",
"quarter": "quarter",
"year": "year"
}.get(period, "quarter")
# SQL запрос для агрегации активного времени
query = text(f"""
SELECT
u."DisplayName" AS user_name,
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime") AS period_start,
SUM(EXTRACT(EPOCH FROM (a."EndLocalTime" - a."StartLocalTime"))) AS total_seconds
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_User" u ON t."OwnerId" = u."UserId"
WHERE
t."SchemaName" = 'ManicTime/Computer usage'
AND a."Name" = 'Active'
AND EXTRACT(YEAR FROM a."StartLocalTime") = :year
GROUP BY
u."DisplayName",
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime")
ORDER BY
u."DisplayName",
period_start
""")
result = manictime_db.execute(query, {"year": year})
rows = result.fetchall()
# Группировка данных по пользователям
user_data = {}
for row in rows:
user_name = row.user_name
if user_name not in user_data:
user_data[user_name] = {}
# Формирование ключа периода
period_start = row.period_start
if period == "week":
period_key = f"W{period_start.isocalendar()[1]}"
elif period == "month":
period_key = f"M{period_start.month}"
elif period == "quarter":
quarter = (period_start.month - 1) // 3 + 1
period_key = f"Q{quarter}"
else: # year
period_key = "Y1"
# Конвертация секунд в часы
hours = float(row.total_seconds) / 3600
user_data[user_name][period_key] = round(hours, 2)
# Формирование ответа
metrics_rows = []
for user_name, periods in user_data.items():
metrics_rows.append(MetricsRow(user=user_name, data=periods))
return MetricsAggregateResponse(
period_type=period,
year=year,
data=metrics_rows
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

123
backend/app/api/v1/summary.py Executable file
View File

@@ -0,0 +1,123 @@
"""
Модуль Сводка - агрегированная гистограмма активности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from datetime import datetime
from typing import List
from app.core.database import get_manictime_db, get_service_db
from app.core.security import get_current_user
from app.models.service_db import AppUser, AppConfiguration
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
router = APIRouter()
@router.get("/histogram", response_model=SummaryHistogramResponse)
async def get_summary_histogram(
start_date: str = Query(..., description="Начальная дата (YYYY-MM-DD)"),
end_date: str = Query(..., description="Конечная дата (YYYY-MM-DD)"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение данных для гистограммы активности по дням
"""
try:
# Валидация дат
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
if start_dt > end_dt:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
# SQL запрос для агрегации данных
query = text("""
WITH computer_usage AS (
SELECT
a."StartLocalTime",
a."EndLocalTime",
a."Name",
t."OwnerId",
DATE_TRUNC('day', a."StartLocalTime") AS "day"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
WHERE t."SchemaName" = 'ManicTime/Computer usage'
AND a."StartLocalTime" >= :start_date
AND a."EndLocalTime" <= :end_date
),
productive_time AS (
SELECT
a."StartLocalTime",
a."EndLocalTime",
t."OwnerId",
DATE_TRUNC('day', a."StartLocalTime") AS "day"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
WHERE c."Name" = 'Productive'
AND a."StartLocalTime" >= :start_date
AND a."EndLocalTime" <= :end_date
)
SELECT
cu."day",
SUM(CASE WHEN cu."Name" = 'Active'
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS active_seconds,
SUM(CASE WHEN cu."Name" = 'Away'
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS away_seconds,
SUM(CASE WHEN cu."Name" IN ('Session Locked', 'Power Off')
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS afk_seconds,
COALESCE(SUM(EXTRACT(EPOCH FROM (pt."EndLocalTime" - pt."StartLocalTime"))), 0) AS productive_seconds
FROM computer_usage cu
LEFT JOIN productive_time pt ON cu."day" = pt."day"
AND cu."OwnerId" = pt."OwnerId"
AND pt."StartLocalTime" < cu."EndLocalTime"
AND pt."EndLocalTime" > cu."StartLocalTime"
GROUP BY cu."day"
ORDER BY cu."day"
""")
result = manictime_db.execute(
query,
{"start_date": start_date, "end_date": end_date}
)
rows = result.fetchall()
# Формирование ответа
labels = []
active_data = []
away_data = []
afk_data = []
productive_data = []
for row in rows:
day = row.day.strftime("%Y-%m-%d")
labels.append(day)
active_data.append(float(row.active_seconds or 0))
away_data.append(float(row.away_seconds or 0))
afk_data.append(float(row.afk_seconds or 0))
productive_data.append(float(row.productive_seconds or 0))
datasets = [
SummaryDataset(label="Активный", color="green", data=active_data),
SummaryDataset(label="Неактивный", color="red", data=away_data),
SummaryDataset(label="Не у ПК", color="yellow", data=afk_data),
SummaryDataset(label="Продуктивность", color="orange", data=productive_data),
]
return SummaryHistogramResponse(labels=labels, datasets=datasets)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

146
backend/app/api/v1/timeline.py Executable file
View File

@@ -0,0 +1,146 @@
"""
Модуль Хронология - индивидуальные линейки активности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List
from datetime import datetime
from app.core.database import get_manictime_db
from app.core.security import get_current_user
from app.models.service_db import AppUser
from app.schemas.timeline import TimelineActivityResponse, UserActivity, ActivitySegment
router = APIRouter()
@router.get("/user-activity", response_model=TimelineActivityResponse)
async def get_user_activity(
date: str = Query(..., description="Дата (YYYY-MM-DD)"),
user_ids: List[int] = Query(..., description="Список ID пользователей"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение активности пользователей за указанную дату для построения линеек
"""
try:
# Валидация даты
date_dt = datetime.strptime(date, "%Y-%m-%d")
date_start = date_dt.strftime("%Y-%m-%d 00:00:00")
date_end = date_dt.strftime("%Y-%m-%d 23:59:59")
if not user_ids:
raise HTTPException(status_code=400, detail="Необходимо указать хотя бы одного пользователя")
# Получение информации о пользователях
users_query = text("""
SELECT "UserId", "DisplayName"
FROM "Ar_User"
WHERE "UserId" = ANY(:user_ids)
""")
users_result = manictime_db.execute(users_query, {"user_ids": user_ids})
users_map = {row.UserId: row.DisplayName for row in users_result}
if not users_map:
raise HTTPException(status_code=404, detail="Пользователи не найдены")
# Получение активности с шкалы Computer usage
activity_query = text("""
SELECT
t."OwnerId",
a."Name",
a."StartLocalTime",
a."EndLocalTime"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
WHERE t."SchemaName" = 'ManicTime/Computer usage'
AND t."OwnerId" = ANY(:user_ids)
AND a."StartLocalTime" >= :date_start
AND a."EndLocalTime" <= :date_end
ORDER BY t."OwnerId", a."StartLocalTime"
""")
activity_result = manictime_db.execute(
activity_query,
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
)
# Получение продуктивного времени
productive_query = text("""
SELECT
t."OwnerId",
a."StartLocalTime",
a."EndLocalTime"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
WHERE c."Name" = 'Productive'
AND t."OwnerId" = ANY(:user_ids)
AND a."StartLocalTime" >= :date_start
AND a."EndLocalTime" <= :date_end
ORDER BY t."OwnerId", a."StartLocalTime"
""")
productive_result = manictime_db.execute(
productive_query,
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
)
# Группировка по пользователям
user_activities = {}
for row in activity_result:
user_id = row.OwnerId
if user_id not in user_activities:
user_activities[user_id] = {
"user_id": user_id,
"display_name": users_map.get(user_id, f"User {user_id}"),
"segments": []
}
# Определение типа сегмента
segment_type = "Active"
if row.Name == "Away":
segment_type = "Away"
elif row.Name == "Session Locked":
segment_type = "Session Locked"
elif row.Name == "Power Off":
segment_type = "Power Off"
user_activities[user_id]["segments"].append(
{
"type": segment_type,
"start": row.StartLocalTime.isoformat(),
"end": row.EndLocalTime.isoformat()
}
)
# Добавление продуктивного времени
for row in productive_result:
user_id = row.OwnerId
if user_id in user_activities:
user_activities[user_id]["segments"].append(
{
"type": "Productive",
"start": row.StartLocalTime.isoformat(),
"end": row.EndLocalTime.isoformat()
}
)
# Формирование ответа
activities = []
for user_id in user_ids:
if user_id in user_activities:
activities.append(UserActivity(**user_activities[user_id]))
return TimelineActivityResponse(date=date, activities=activities)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

0
backend/app/core/__init__.py Executable file
View File

37
backend/app/core/config.py Executable file
View File

@@ -0,0 +1,37 @@
"""
Конфигурация приложения
"""
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# ManicTime Database (Read-Only)
MANICTIME_DB_HOST: str
MANICTIME_DB_PORT: int = 5432
MANICTIME_DB_NAME: str
MANICTIME_DB_USER: str
MANICTIME_DB_PASSWORD: str
# Service Database
SERVICE_DB_HOST: str
SERVICE_DB_PORT: int = 5432
SERVICE_DB_NAME: str
SERVICE_DB_USER: str
SERVICE_DB_PASSWORD: str
# JWT Settings
JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

103
backend/app/core/database.py Executable file
View File

@@ -0,0 +1,103 @@
"""
Управление подключениями к базам данных
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# Service Database (создаем первой, чтобы можно было читать конфигурацию)
service_engine = create_engine(
f"postgresql://{settings.SERVICE_DB_USER}:{settings.SERVICE_DB_PASSWORD}@"
f"{settings.SERVICE_DB_HOST}:{settings.SERVICE_DB_PORT}/{settings.SERVICE_DB_NAME}",
pool_pre_ping=True,
echo=False
)
ServiceSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=service_engine)
Base = declarative_base()
def get_manictime_config():
"""Получение конфигурации подключения к ManicTime из служебной БД"""
db = ServiceSessionLocal()
try:
from app.models.service_db import AppConfiguration
config = {}
config_items = db.query(AppConfiguration).filter(
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
).all()
for item in config_items:
key = item.key.replace("manictime_", "")
config[key] = item.value
# Используем значения из БД, если они есть, иначе из .env
host = config.get("host") or settings.MANICTIME_DB_HOST
port = config.get("port") or settings.MANICTIME_DB_PORT
dbname = config.get("dbname") or settings.MANICTIME_DB_NAME
return {
"host": host,
"port": port,
"dbname": dbname,
"user": settings.MANICTIME_DB_USER, # Всегда из .env
"password": settings.MANICTIME_DB_PASSWORD # Всегда из .env
}
except Exception as e:
logger.warning(f"Не удалось загрузить конфигурацию из БД, используем .env: {e}")
return {
"host": settings.MANICTIME_DB_HOST,
"port": settings.MANICTIME_DB_PORT,
"dbname": settings.MANICTIME_DB_NAME,
"user": settings.MANICTIME_DB_USER,
"password": settings.MANICTIME_DB_PASSWORD
}
finally:
db.close()
def get_manictime_engine():
"""Создание engine для ManicTime с учетом конфигурации из БД"""
config = get_manictime_config()
return create_engine(
f"postgresql://{config['user']}:{config['password']}@"
f"{config['host']}:{config['port']}/{config['dbname']}",
pool_pre_ping=True,
echo=False
)
# ManicTime Database (Read-Only) - создается динамически
manictime_engine = None
ManicTimeSessionLocal = None
def get_manictime_db():
"""Dependency для получения сессии ManicTime БД (read-only)"""
global manictime_engine, ManicTimeSessionLocal
# Создаем engine динамически, чтобы учитывать изменения конфигурации
if manictime_engine is None:
manictime_engine = get_manictime_engine()
ManicTimeSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=manictime_engine)
db = ManicTimeSessionLocal()
try:
yield db
finally:
db.close()
def get_service_db():
"""Dependency для получения сессии служебной БД"""
db = ServiceSessionLocal()
try:
yield db
finally:
db.close()

74
backend/app/core/security.py Executable file
View File

@@ -0,0 +1,74 @@
"""
Модуль безопасности: JWT, хеширование паролей
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_service_db
from app.models.service_db import AppUser
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароля"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Хеширование пароля"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Создание JWT токена"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_service_db)
) -> AppUser:
"""Получение текущего пользователя из JWT токена"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не удалось проверить учетные данные",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(AppUser).filter(AppUser.id == user_id, AppUser.is_active == True).first()
if user is None:
raise credentials_exception
return user
async def get_admin_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
"""Проверка, что пользователь является администратором"""
if current_user.role.role_name != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав доступа"
)
return current_user

36
backend/app/main.py Executable file
View File

@@ -0,0 +1,36 @@
"""
Главный модуль FastAPI приложения
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import api_router
app = FastAPI(
title="ManicTime Dashboard API",
description="API для сервиса дашбордов и аналитики ManicTime",
version="1.0.0"
)
# CORS настройки
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Подключение роутеров
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
return {"message": "ManicTime Dashboard API", "version": "1.0.0"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}

14
backend/app/models/__init__.py Executable file
View File

@@ -0,0 +1,14 @@
from app.models.service_db import (
AppUser,
AppRole,
LeaveEvent,
AppConfiguration
)
__all__ = [
"AppUser",
"AppRole",
"LeaveEvent",
"AppConfiguration"
]

View File

@@ -0,0 +1,57 @@
"""
Модели для служебной базы данных
"""
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class AppRole(Base):
"""Роли пользователей сервиса"""
__tablename__ = "app_roles"
id = Column(Integer, primary_key=True, index=True)
role_name = Column(String(50), unique=True, nullable=False)
users = relationship("AppUser", back_populates="role")
class AppUser(Base):
"""Пользователи сервиса дашбордов"""
__tablename__ = "app_users"
id = Column(Integer, primary_key=True, index=True)
login = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
role_id = Column(Integer, ForeignKey("app_roles.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
is_active = Column(Boolean, default=True)
role = relationship("AppRole", back_populates="users")
leave_events = relationship("LeaveEvent", back_populates="user")
class LeaveEvent(Base):
"""События отпусков и больничных"""
__tablename__ = "leave_events"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("app_users.id"), nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
leave_type = Column(String(50), nullable=False) # 'Отпуск', 'Больничный'
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("AppUser", back_populates="leave_events")
class AppConfiguration(Base):
"""Конфигурация сервиса (нечувствительные данные)"""
__tablename__ = "app_configuration"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

26
backend/app/schemas/__init__.py Executable file
View File

@@ -0,0 +1,26 @@
from app.schemas.auth import Token, TokenData, LoginRequest
from app.schemas.user import UserCreate, UserResponse
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
from app.schemas.timeline import TimelineActivityResponse, ActivitySegment
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
from app.schemas.config import ConfigResponse, ConfigUpdate
__all__ = [
"Token",
"TokenData",
"LoginRequest",
"UserCreate",
"UserResponse",
"SummaryHistogramResponse",
"SummaryDataset",
"TimelineActivityResponse",
"ActivitySegment",
"MetricsAggregateResponse",
"MetricsRow",
"LeaveEventCreate",
"LeaveEventResponse",
"ConfigResponse",
"ConfigUpdate"
]

21
backend/app/schemas/auth.py Executable file
View File

@@ -0,0 +1,21 @@
"""
Схемы для аутентификации
"""
from pydantic import BaseModel
from typing import Optional
class LoginRequest(BaseModel):
login: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None
role: Optional[str] = None

16
backend/app/schemas/config.py Executable file
View File

@@ -0,0 +1,16 @@
"""
Схемы для конфигурации
"""
from pydantic import BaseModel
from typing import Optional
class ConfigResponse(BaseModel):
key: str
value: Optional[str] = None
class ConfigUpdate(BaseModel):
key: str
value: Optional[str] = None

27
backend/app/schemas/leave.py Executable file
View File

@@ -0,0 +1,27 @@
"""
Схемы для модуля Отпуска/Больничные
"""
from pydantic import BaseModel
from datetime import date
from typing import Optional
class LeaveEventCreate(BaseModel):
user_id: int
start_date: date
end_date: date
leave_type: str # "Отпуск", "Больничный"
class LeaveEventResponse(BaseModel):
id: int
user_id: int
user_login: Optional[str] = None
start_date: date
end_date: date
leave_type: str
created_at: Optional[str] = None
class Config:
from_attributes = True

17
backend/app/schemas/metrics.py Executable file
View File

@@ -0,0 +1,17 @@
"""
Схемы для модуля Метрика
"""
from pydantic import BaseModel
from typing import List, Dict, Any
class MetricsRow(BaseModel):
user: str
data: Dict[str, Any] # Динамические ключи типа "Q1", "Q2", "W1", "M1" и т.д.
class MetricsAggregateResponse(BaseModel):
period_type: str # 'week', 'month', 'quarter', 'year'
year: int
data: List[MetricsRow]

17
backend/app/schemas/summary.py Executable file
View File

@@ -0,0 +1,17 @@
"""
Схемы для модуля Сводка
"""
from pydantic import BaseModel
from typing import List
class SummaryDataset(BaseModel):
label: str
color: str
data: List[float] # секунды
class SummaryHistogramResponse(BaseModel):
labels: List[str] # даты
datasets: List[SummaryDataset]

23
backend/app/schemas/timeline.py Executable file
View File

@@ -0,0 +1,23 @@
"""
Схемы для модуля Хронология
"""
from pydantic import BaseModel
from typing import List
class ActivitySegment(BaseModel):
type: str # "Active", "Away", "Productive", "Session Locked", "Power Off"
start: str # ISO datetime
end: str # ISO datetime
class UserActivity(BaseModel):
user_id: int
display_name: str
segments: List[ActivitySegment]
class TimelineActivityResponse(BaseModel):
date: str
activities: List[UserActivity]

25
backend/app/schemas/user.py Executable file
View File

@@ -0,0 +1,25 @@
"""
Схемы для пользователей
"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserCreate(BaseModel):
login: str
password: str
role_id: int
class UserResponse(BaseModel):
id: int
login: str
role_id: int
role_name: Optional[str] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True

12
backend/requirements.txt Executable file
View File

@@ -0,0 +1,12 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-dotenv==1.0.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
alembic==1.12.1

73
backend/scripts/init_db.py Executable file
View File

@@ -0,0 +1,73 @@
"""
Скрипт для инициализации базы данных
Создает начальные роли и первого администратора
"""
import sys
import os
# Добавляем путь к приложению
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from sqlalchemy.orm import Session
from app.core.database import ServiceSessionLocal, Base, service_engine
from app.models.service_db import AppRole, AppUser
from app.core.security import get_password_hash
def init_database():
"""Инициализация базы данных"""
# Создание таблиц
Base.metadata.create_all(bind=service_engine)
db: Session = ServiceSessionLocal()
try:
# Создание ролей, если их нет
admin_role = db.query(AppRole).filter(AppRole.role_name == "Admin").first()
if not admin_role:
admin_role = AppRole(role_name="Admin")
db.add(admin_role)
db.commit()
db.refresh(admin_role)
print("✓ Роль Admin создана")
else:
print("✓ Роль Admin уже существует")
user_role = db.query(AppRole).filter(AppRole.role_name == "User").first()
if not user_role:
user_role = AppRole(role_name="User")
db.add(user_role)
db.commit()
db.refresh(user_role)
print("✓ Роль User создана")
else:
print("✓ Роль User уже существует")
# Создание первого администратора, если его нет
admin_user = db.query(AppUser).filter(AppUser.login == "admin").first()
if not admin_user:
admin_user = AppUser(
login="admin",
hashed_password=get_password_hash("admin123"),
role_id=admin_role.id,
is_active=True
)
db.add(admin_user)
db.commit()
print("✓ Создан администратор: login=admin, password=admin123")
print("⚠ ВАЖНО: Измените пароль администратора после первого входа!")
else:
print("✓ Администратор уже существует")
print("\n✓ Инициализация базы данных завершена успешно")
except Exception as e:
db.rollback()
print(f"✗ Ошибка при инициализации: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
print("Инициализация базы данных...")
init_database()

51
docker-compose.yml Executable file
View File

@@ -0,0 +1,51 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- MANICTIME_DB_HOST=${MANICTIME_DB_HOST}
- MANICTIME_DB_PORT=${MANICTIME_DB_PORT}
- MANICTIME_DB_NAME=${MANICTIME_DB_NAME}
- MANICTIME_DB_USER=${MANICTIME_DB_USER}
- MANICTIME_DB_PASSWORD=${MANICTIME_DB_PASSWORD}
- SERVICE_DB_HOST=${SERVICE_DB_HOST}
- SERVICE_DB_PORT=${SERVICE_DB_PORT}
- SERVICE_DB_NAME=${SERVICE_DB_NAME}
- SERVICE_DB_USER=${SERVICE_DB_USER}
- SERVICE_DB_PASSWORD=${SERVICE_DB_PASSWORD}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
volumes:
- ./backend:/app
depends_on:
- service_db
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
restart: unless-stopped
service_db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=${SERVICE_DB_NAME}
- POSTGRES_USER=${SERVICE_DB_USER}
- POSTGRES_PASSWORD=${SERVICE_DB_PASSWORD}
ports:
- "5433:5432"
volumes:
- service_db_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
service_db_data:

17
frontend/Dockerfile Executable file
View File

@@ -0,0 +1,17 @@
FROM node:18-alpine
WORKDIR /app
# Копирование package.json и установка зависимостей
COPY package.json .
RUN npm install
# Копирование исходного кода
COPY . .
# Открытие порта
EXPOSE 3000
# Команда запуска
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Executable file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ManicTime Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

28
frontend/package.json Executable file
View File

@@ -0,0 +1,28 @@
{
"name": "manictime-dashboard-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0",
"react-big-calendar": "^1.8.5",
"moment": "^2.29.4",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

82
frontend/src/App.css Executable file
View File

@@ -0,0 +1,82 @@
.app {
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #007bff;
}
.error {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
}
.success {
color: #28a745;
font-size: 14px;
margin-top: 5px;
}

44
frontend/src/App.jsx Executable file
View File

@@ -0,0 +1,44 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Summary from './pages/Summary'
import Timeline from './pages/Timeline'
import Metrics from './pages/Metrics'
import LeaveCalendar from './pages/LeaveCalendar'
import AdminPanel from './pages/AdminPanel'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import './App.css'
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="summary" element={<Summary />} />
<Route path="timeline" element={<Timeline />} />
<Route path="metrics" element={<Metrics />} />
<Route path="leave" element={<LeaveCalendar />} />
<Route path="admin" element={<AdminPanel />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</AuthProvider>
)
}
export default App

View File

@@ -0,0 +1,61 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 15px 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand h2 {
margin: 0;
font-size: 24px;
}
.navbar-menu {
display: flex;
gap: 20px;
flex: 1;
margin-left: 40px;
}
.nav-link {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nav-link.active {
background-color: #3498db;
}
.navbar-actions {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
font-size: 14px;
opacity: 0.9;
}
.main-content {
flex: 1;
padding: 20px;
background-color: #f5f5f5;
}

View File

@@ -0,0 +1,65 @@
import React from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import './Layout.css'
const Layout = () => {
const { logout, user } = useAuth()
const location = useLocation()
const isActive = (path) => location.pathname === path
return (
<div className="layout">
<nav className="navbar">
<div className="navbar-brand">
<h2>ManicTime Dashboard</h2>
</div>
<div className="navbar-menu">
<Link
to="/summary"
className={`nav-link ${isActive('/summary') ? 'active' : ''}`}
>
Сводка
</Link>
<Link
to="/timeline"
className={`nav-link ${isActive('/timeline') ? 'active' : ''}`}
>
Хронология
</Link>
<Link
to="/metrics"
className={`nav-link ${isActive('/metrics') ? 'active' : ''}`}
>
Метрика
</Link>
<Link
to="/leave"
className={`nav-link ${isActive('/leave') ? 'active' : ''}`}
>
Отпуска/Больничные
</Link>
<Link
to="/admin"
className={`nav-link ${isActive('/admin') ? 'active' : ''}`}
>
Администрирование
</Link>
</div>
<div className="navbar-actions">
<span className="user-info">{user?.login || 'Пользователь'}</span>
<button onClick={logout} className="btn btn-secondary">
Выход
</button>
</div>
</nav>
<main className="main-content">
<Outlet />
</main>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div>Загрузка...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return children || <Outlet />
}
export default ProtectedRoute

View File

@@ -0,0 +1,66 @@
import React, { createContext, useState, useContext, useEffect } from 'react'
import axios from 'axios'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [token, setToken] = useState(localStorage.getItem('token'))
useEffect(() => {
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
// Можно добавить запрос для получения информации о пользователе
} else {
delete axios.defaults.headers.common['Authorization']
}
setLoading(false)
}, [token])
const login = async (login, password) => {
try {
const response = await axios.post('/api/v1/auth/token', {
login,
password
})
const newToken = response.data.access_token
setToken(newToken)
localStorage.setItem('token', newToken)
axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || 'Ошибка входа'
}
}
}
const logout = () => {
setToken(null)
setUser(null)
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
}
const value = {
user,
token,
login,
logout,
loading,
isAuthenticated: !!token
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

24
frontend/src/index.css Executable file
View File

@@ -0,0 +1,24 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
#root {
min-height: 100vh;
}

11
frontend/src/main.jsx Executable file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

106
frontend/src/pages/AdminPanel.css Executable file
View File

@@ -0,0 +1,106 @@
.admin-panel {
max-width: 1400px;
margin: 0 auto;
}
.admin-panel h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h2 {
margin: 0;
color: #2c3e50;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.admin-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.admin-table tbody tr:hover {
background-color: #f8f9fa;
}
.config-description {
color: #6c757d;
margin-bottom: 20px;
line-height: 1.6;
}
.config-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.config-item {
display: flex;
align-items: center;
gap: 15px;
}
.config-item label {
width: 150px;
font-weight: 500;
color: #2c3e50;
}
.config-item input {
flex: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}

222
frontend/src/pages/AdminPanel.jsx Executable file
View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './AdminPanel.css'
const AdminPanel = () => {
const [users, setUsers] = useState([])
const [config, setConfig] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [showUserModal, setShowUserModal] = useState(false)
const [newUser, setNewUser] = useState({
login: '',
password: '',
role_id: 2
})
const [roles, setRoles] = useState([
{ id: 1, name: 'Admin' },
{ id: 2, name: 'User' }
])
useEffect(() => {
fetchUsers()
fetchConfig()
}, [])
const fetchUsers = async () => {
try {
const response = await axios.get('/api/v1/admin/users')
setUsers(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки пользователей')
}
}
const fetchConfig = async () => {
try {
const response = await axios.get('/api/v1/admin/config')
setConfig(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки конфигурации')
}
}
const handleCreateUser = async () => {
if (!newUser.login || !newUser.password) {
setError('Заполните все поля')
return
}
setLoading(true)
setError('')
try {
await axios.post('/api/v1/admin/users', newUser)
setSuccess('Пользователь успешно создан')
setShowUserModal(false)
setNewUser({ login: '', password: '', role_id: 2 })
fetchUsers()
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка создания пользователя')
} finally {
setLoading(false)
}
}
const handleUpdateConfig = async (key, value) => {
setLoading(true)
setError('')
try {
await axios.put('/api/v1/admin/config', { key, value })
setSuccess('Конфигурация обновлена')
fetchConfig()
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка обновления конфигурации')
} finally {
setLoading(false)
}
}
return (
<div className="admin-panel">
<h1>Панель администратора</h1>
{error && (
<div className="error card" onClick={() => setError('')}>
{error}
</div>
)}
{success && (
<div className="success card" onClick={() => setSuccess('')}>
{success}
</div>
)}
<div className="card">
<div className="card-header">
<h2>Пользователи</h2>
<button
className="btn btn-primary"
onClick={() => setShowUserModal(true)}
>
Создать пользователя
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.login}</td>
<td>{user.role_name}</td>
<td>{user.is_active ? 'Активен' : 'Неактивен'}</td>
<td>
{new Date(user.created_at).toLocaleDateString('ru-RU')}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>Конфигурация подключения к ManicTime</h2>
<p className="config-description">
Здесь можно изменить нечувствительные параметры подключения к базе
данных ManicTime. Пароль и пользователь настраиваются только через
переменные окружения на сервере.
</p>
<div className="config-list">
{config.map((item) => (
<div key={item.key} className="config-item">
<label>{item.key.replace('manictime_', '').toUpperCase()}</label>
<input
type="text"
value={item.value || ''}
onChange={(e) =>
handleUpdateConfig(item.key, e.target.value)
}
placeholder="Не задано"
/>
</div>
))}
</div>
</div>
{showUserModal && (
<div className="modal-overlay" onClick={() => setShowUserModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Создать пользователя</h2>
<div className="form-group">
<label>Логин</label>
<input
type="text"
value={newUser.login}
onChange={(e) =>
setNewUser({ ...newUser, login: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Пароль</label>
<input
type="password"
value={newUser.password}
onChange={(e) =>
setNewUser({ ...newUser, password: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Роль</label>
<select
value={newUser.role_id}
onChange={(e) =>
setNewUser({
...newUser,
role_id: parseInt(e.target.value)
})
}
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="modal-actions">
<button
className="btn btn-secondary"
onClick={() => setShowUserModal(false)}
>
Отмена
</button>
<button
className="btn btn-primary"
onClick={handleCreateUser}
disabled={loading}
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminPanel

View File

@@ -0,0 +1,41 @@
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.dashboard h1 {
margin-bottom: 30px;
color: #2c3e50;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.dashboard-card {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: inherit;
transition: transform 0.3s, box-shadow 0.3s;
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.dashboard-card h3 {
color: #3498db;
margin-bottom: 10px;
}
.dashboard-card p {
color: #7f8c8d;
line-height: 1.6;
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { Link } from 'react-router-dom'
import './Dashboard.css'
const Dashboard = () => {
return (
<div className="dashboard">
<h1>Главная панель</h1>
<div className="dashboard-grid">
<Link to="/summary" className="dashboard-card">
<h3>Сводка</h3>
<p>Агрегированная гистограмма активности по категориям</p>
</Link>
<Link to="/timeline" className="dashboard-card">
<h3>Хронология</h3>
<p>Индивидуальные линейки активности пользователей</p>
</Link>
<Link to="/metrics" className="dashboard-card">
<h3>Метрика</h3>
<p>Числовые показатели эффективности в динамике</p>
</Link>
<Link to="/leave" className="dashboard-card">
<h3>Отпуска/Больничные</h3>
<p>Календарь отсутствий сотрудников</p>
</Link>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,44 @@
.leave-calendar {
max-width: 1400px;
margin: 0 auto;
}
.leave-calendar h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-bottom: 20px;
color: #2c3e50;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react'
import { Calendar, momentLocalizer } from 'react-big-calendar'
import moment from 'moment'
import 'react-big-calendar/lib/css/react-big-calendar.css'
import axios from 'axios'
import './LeaveCalendar.css'
const localizer = momentLocalizer(moment)
const LeaveCalendar = () => {
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
const [selectedRange, setSelectedRange] = useState(null)
const [newEvent, setNewEvent] = useState({
user_id: '',
start_date: '',
end_date: '',
leave_type: 'Отпуск'
})
const [users, setUsers] = useState([])
useEffect(() => {
const currentDate = new Date()
const startDate = new Date(currentDate.getFullYear(), 0, 1)
const endDate = new Date(currentDate.getFullYear(), 11, 31)
fetchEvents(startDate, endDate)
fetchUsers()
}, [])
const fetchEvents = async (startDate, endDate) => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/leave/events', {
params: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
}
})
const formattedEvents = response.data.map((event) => ({
id: event.id,
title: `${event.user_login || `User ${event.user_id}`} - ${event.leave_type}`,
start: new Date(event.start_date),
end: new Date(event.end_date),
resource: event
}))
setEvents(formattedEvents)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const fetchUsers = async () => {
try {
const response = await axios.get('/api/v1/admin/users')
setUsers(response.data)
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
}
}
const handleSelectSlot = ({ start, end }) => {
setSelectedRange({ start, end })
setNewEvent({
user_id: '',
start_date: start.toISOString().split('T')[0],
end_date: end.toISOString().split('T')[0],
leave_type: 'Отпуск'
})
setShowModal(true)
}
const handleCreateEvent = async () => {
if (!newEvent.user_id || !newEvent.start_date || !newEvent.end_date) {
setError('Заполните все поля')
return
}
try {
await axios.post('/api/v1/leave/events', {
user_id: parseInt(newEvent.user_id),
start_date: newEvent.start_date,
end_date: newEvent.end_date,
leave_type: newEvent.leave_type
})
setShowModal(false)
const startDate = new Date(newEvent.start_date)
const endDate = new Date(newEvent.end_date)
fetchEvents(
new Date(startDate.getFullYear(), 0, 1),
new Date(endDate.getFullYear(), 11, 31)
)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка создания события')
}
}
const eventStyleGetter = (event) => {
const leaveType = event.resource?.leave_type
let backgroundColor = '#3174ad'
if (leaveType === 'Больничный') {
backgroundColor = '#dc3545'
} else if (leaveType === 'Отпуск') {
backgroundColor = '#28a745'
}
return {
style: {
backgroundColor,
borderRadius: '5px',
opacity: 0.8,
color: 'white',
border: '0px',
display: 'block'
}
}
}
return (
<div className="leave-calendar">
<h1>Отпуска и больничные</h1>
{error && <div className="error card">{error}</div>}
<div className="card" style={{ height: '600px' }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
onSelectSlot={handleSelectSlot}
selectable
eventPropGetter={eventStyleGetter}
culture="ru"
messages={{
next: 'Вперед',
previous: 'Назад',
today: 'Сегодня',
month: 'Месяц',
week: 'Неделя',
day: 'День',
agenda: 'Повестка дня',
date: 'Дата',
time: 'Время',
event: 'Событие',
noEventsInRange: 'Нет событий в выбранном диапазоне'
}}
/>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Добавить событие</h2>
<div className="form-group">
<label>Пользователь</label>
<select
value={newEvent.user_id}
onChange={(e) =>
setNewEvent({ ...newEvent, user_id: e.target.value })
}
required
>
<option value="">Выберите пользователя</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.login}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Начальная дата</label>
<input
type="date"
value={newEvent.start_date}
onChange={(e) =>
setNewEvent({ ...newEvent, start_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Конечная дата</label>
<input
type="date"
value={newEvent.end_date}
onChange={(e) =>
setNewEvent({ ...newEvent, end_date: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label>Тип</label>
<select
value={newEvent.leave_type}
onChange={(e) =>
setNewEvent({ ...newEvent, leave_type: e.target.value })
}
>
<option value="Отпуск">Отпуск</option>
<option value="Больничный">Больничный</option>
</select>
</div>
<div className="modal-actions">
<button
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Отмена
</button>
<button
className="btn btn-primary"
onClick={handleCreateEvent}
>
Создать
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default LeaveCalendar

31
frontend/src/pages/Login.css Executable file
View File

@@ -0,0 +1,31 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-card h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 10px;
}
.login-card h2 {
text-align: center;
color: #7f8c8d;
font-size: 18px;
font-weight: normal;
margin-bottom: 30px;
}

72
frontend/src/pages/Login.jsx Executable file
View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import './Login.css'
const Login = () => {
const [login, setLogin] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login: authLogin } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await authLogin(login, password)
setLoading(false)
if (result.success) {
navigate('/')
} else {
setError(result.error)
}
}
return (
<div className="login-container">
<div className="login-card">
<h1>ManicTime Dashboard</h1>
<h2>Вход в систему</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="login">Логин</label>
<input
type="text"
id="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Пароль</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error">{error}</div>}
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '10px' }}
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
</div>
)
}
export default Login

52
frontend/src/pages/Metrics.css Executable file
View File

@@ -0,0 +1,52 @@
.metrics {
max-width: 1400px;
margin: 0 auto;
}
.metrics h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.metrics-controls {
display: flex;
gap: 20px;
align-items: flex-end;
}
.metrics-controls .form-group {
flex: 1;
margin-bottom: 0;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
}
.metrics-table th,
.metrics-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.metrics-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.metrics-table tbody tr:hover {
background-color: #f8f9fa;
}
.user-name {
font-weight: 500;
}

142
frontend/src/pages/Metrics.jsx Executable file
View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './Metrics.css'
const Metrics = () => {
const [period, setPeriod] = useState('quarter')
const [year, setYear] = useState(new Date().getFullYear())
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchData()
}, [period, year])
const fetchData = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/metrics/aggregate', {
params: { period, year }
})
setData(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const getPeriodLabel = (key) => {
if (period === 'week') {
return `Неделя ${key.replace('W', '')}`
} else if (period === 'month') {
const months = [
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь'
]
return months[parseInt(key.replace('M', '')) - 1]
} else if (period === 'quarter') {
return `Квартал ${key.replace('Q', '')}`
} else {
return 'Год'
}
}
const getAllPeriodKeys = () => {
if (!data || !data.data || data.data.length === 0) return []
const allKeys = new Set()
data.data.forEach((row) => {
Object.keys(row.data).forEach((key) => allKeys.add(key))
})
return Array.from(allKeys).sort()
}
return (
<div className="metrics">
<h1>Метрики эффективности</h1>
<div className="card">
<form
onSubmit={(e) => {
e.preventDefault()
fetchData()
}}
className="metrics-controls"
>
<div className="form-group">
<label>Период</label>
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
</select>
</div>
<div className="form-group">
<label>Год</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
min="2000"
max="2100"
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Загрузка...' : 'Обновить'}
</button>
</form>
</div>
{error && <div className="error card">{error}</div>}
{data && !loading && (
<div className="card">
<h2>
Агрегированные метрики за {year} год ({period === 'week' ? 'по неделям' : period === 'month' ? 'по месяцам' : period === 'quarter' ? 'по кварталам' : 'за год'})
</h2>
<div className="table-container">
<table className="metrics-table">
<thead>
<tr>
<th>Пользователь</th>
{getAllPeriodKeys().map((key) => (
<th key={key}>{getPeriodLabel(key)}</th>
))}
</tr>
</thead>
<tbody>
{data.data.map((row, idx) => (
<tr key={idx}>
<td className="user-name">{row.user}</td>
{getAllPeriodKeys().map((key) => (
<td key={key}>
{row.data[key] ? `${row.data[key]} ч` : '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
export default Metrics

21
frontend/src/pages/Summary.css Executable file
View File

@@ -0,0 +1,21 @@
.summary {
max-width: 1200px;
margin: 0 auto;
}
.summary h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.date-range-form {
display: flex;
gap: 20px;
align-items: flex-end;
}
.date-range-form .form-group {
flex: 1;
margin-bottom: 0;
}

142
frontend/src/pages/Summary.jsx Executable file
View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
import { Bar } from 'react-chartjs-2'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import axios from 'axios'
import './Summary.css'
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
)
const Summary = () => {
const [startDate, setStartDate] = useState(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
)
const [endDate, setEndDate] = useState(
new Date().toISOString().split('T')[0]
)
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fetchData = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/summary/histogram', {
params: { start_date: startDate, end_date: endDate }
})
setData(response.data)
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
const handleSubmit = (e) => {
e.preventDefault()
fetchData()
}
const chartData = data
? {
labels: data.labels,
datasets: data.datasets.map((dataset) => ({
label: dataset.label,
data: dataset.data.map((seconds) => seconds / 3600), // Конвертация в часы
backgroundColor: dataset.color,
borderColor: dataset.color,
borderWidth: 1
}))
}
: null
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: 'Активность по дням (часы)'
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)} ч`
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Часы'
}
}
}
}
return (
<div className="summary">
<h1>Сводка активности</h1>
<div className="card">
<form onSubmit={handleSubmit} className="date-range-form">
<div className="form-group">
<label>Начальная дата</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Конечная дата</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Загрузка...' : 'Обновить'}
</button>
</form>
</div>
{error && <div className="error card">{error}</div>}
{data && !loading && (
<div className="card">
<Bar data={chartData} options={chartOptions} />
</div>
)}
</div>
)
}
export default Summary

92
frontend/src/pages/Timeline.css Executable file
View File

@@ -0,0 +1,92 @@
.timeline {
max-width: 1400px;
margin: 0 auto;
}
.timeline h1 {
margin-bottom: 20px;
color: #2c3e50;
}
.user-checklist {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type='checkbox'] {
width: auto;
cursor: pointer;
}
.timeline-container {
margin-top: 20px;
}
.timeline-row {
display: flex;
margin-bottom: 15px;
align-items: center;
}
.timeline-label {
width: 200px;
font-weight: 500;
padding-right: 15px;
flex-shrink: 0;
}
.timeline-ruler {
flex: 1;
height: 30px;
background-color: #f0f0f0;
position: relative;
border-radius: 4px;
overflow: hidden;
}
.timeline-segment {
position: absolute;
height: 100%;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.3s;
}
.timeline-segment:hover {
opacity: 0.8;
}
.timeline-legend {
display: flex;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.text-muted {
color: #6c757d;
font-style: italic;
}

236
frontend/src/pages/Timeline.jsx Executable file
View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import './Timeline.css'
const Timeline = () => {
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split('T')[0]
)
const [users, setUsers] = useState([])
const [selectedUserIds, setSelectedUserIds] = useState([])
const [activities, setActivities] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchUsers()
}, [])
useEffect(() => {
if (selectedUserIds.length > 0) {
fetchActivities()
} else {
setActivities([])
}
}, [selectedDate, selectedUserIds])
const fetchUsers = async () => {
try {
// В реальном приложении нужно получить список пользователей из ManicTime
// Пока используем заглушку
const response = await axios.get('/api/v1/timeline/user-activity', {
params: { date: selectedDate, user_ids: [1] }
})
// Получаем пользователей из ответа
if (response.data.activities) {
const uniqueUsers = response.data.activities.map((a) => ({
id: a.user_id,
name: a.display_name
}))
setUsers(uniqueUsers)
}
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
}
}
const fetchActivities = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get('/api/v1/timeline/user-activity', {
params: {
date: selectedDate,
user_ids: selectedUserIds
}
})
setActivities(response.data.activities || [])
} catch (err) {
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
} finally {
setLoading(false)
}
}
const toggleUser = (userId) => {
setSelectedUserIds((prev) =>
prev.includes(userId)
? prev.filter((id) => id !== userId)
: [...prev, userId]
)
}
const getSegmentColor = (type) => {
switch (type) {
case 'Active':
return '#28a745'
case 'Away':
return '#dc3545'
case 'Session Locked':
case 'Power Off':
return '#ffc107'
case 'Productive':
return '#fd7e14'
default:
return '#6c757d'
}
}
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
}
const calculatePosition = (start, dayStart, dayEnd) => {
const startTime = new Date(start).getTime()
const dayStartTime = new Date(dayStart).getTime()
const dayEndTime = new Date(dayEnd).getTime()
const totalDuration = dayEndTime - dayStartTime
const position = ((startTime - dayStartTime) / totalDuration) * 100
return Math.max(0, Math.min(100, position))
}
const calculateWidth = (start, end, dayStart, dayEnd) => {
const startTime = new Date(start).getTime()
const endTime = new Date(end).getTime()
const dayStartTime = new Date(dayStart).getTime()
const dayEndTime = new Date(dayEnd).getTime()
const totalDuration = dayEndTime - dayStartTime
const segmentDuration = endTime - startTime
const width = (segmentDuration / totalDuration) * 100
return Math.max(1, Math.min(100, width))
}
const dayStart = new Date(`${selectedDate}T00:00:00`)
const dayEnd = new Date(`${selectedDate}T23:59:59`)
return (
<div className="timeline">
<h1>Хронология активности</h1>
<div className="card">
<div className="form-group">
<label>Дата</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Пользователи</label>
<div className="user-checklist">
{users.map((user) => (
<label key={user.id} className="checkbox-label">
<input
type="checkbox"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleUser(user.id)}
/>
{user.name}
</label>
))}
{users.length === 0 && (
<p className="text-muted">
Выберите дату и загрузите данные для отображения пользователей
</p>
)}
</div>
</div>
</div>
{error && <div className="error card">{error}</div>}
{loading && <div className="card">Загрузка...</div>}
{activities.length > 0 && (
<div className="card">
<h2>Активность за {selectedDate}</h2>
<div className="timeline-container">
{activities.map((activity) => (
<div key={activity.user_id} className="timeline-row">
<div className="timeline-label">{activity.display_name}</div>
<div className="timeline-ruler">
{activity.segments.map((segment, idx) => {
const left = calculatePosition(
segment.start,
dayStart.toISOString(),
dayEnd.toISOString()
)
const width = calculateWidth(
segment.start,
segment.end,
dayStart.toISOString(),
dayEnd.toISOString()
)
return (
<div
key={idx}
className="timeline-segment"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: getSegmentColor(segment.type),
title: `${segment.type}: ${formatTime(
segment.start
)} - ${formatTime(segment.end)}`
}}
/>
)
})}
</div>
</div>
))}
</div>
<div className="timeline-legend">
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#28a745' }}
/>
Активный
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#dc3545' }}
/>
Неактивный
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#ffc107' }}
/>
Не у ПК
</div>
<div className="legend-item">
<span
className="legend-color"
style={{ backgroundColor: '#fd7e14' }}
/>
Продуктивность
</div>
</div>
</div>
)}
</div>
)
}
export default Timeline

16
frontend/vite.config.js Executable file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})