diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..daac185 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100755 index 0000000..5f503ad --- /dev/null +++ b/DEPLOYMENT.md @@ -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` в безопасном месте! + diff --git a/backend/.env.example b/backend/.env.example new file mode 100755 index 0000000..5058e3d --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..820311d --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..536499c --- /dev/null +++ b/backend/alembic.ini @@ -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 + diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100755 index 0000000..e71fe53 --- /dev/null +++ b/backend/alembic/env.py @@ -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() + diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100755 index 0000000..586c565 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} + diff --git a/backend/alembic/versions/001_initial_migration.py b/backend/alembic/versions/001_initial_migration.py new file mode 100755 index 0000000..ada5bb6 --- /dev/null +++ b/backend/alembic/versions/001_initial_migration.py @@ -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') + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/__main__.py b/backend/app/__main__.py new file mode 100755 index 0000000..ba4c95c --- /dev/null +++ b/backend/app/__main__.py @@ -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) + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100755 index 0000000..f5d404d --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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"]) + diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100755 index 0000000..4efcbcb --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -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) + diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100755 index 0000000..e5af2c1 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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"} + diff --git a/backend/app/api/v1/leave.py b/backend/app/api/v1/leave.py new file mode 100755 index 0000000..4015b5d --- /dev/null +++ b/backend/app/api/v1/leave.py @@ -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 + ) + diff --git a/backend/app/api/v1/metrics.py b/backend/app/api/v1/metrics.py new file mode 100755 index 0000000..5c2ae7c --- /dev/null +++ b/backend/app/api/v1/metrics.py @@ -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)}") + diff --git a/backend/app/api/v1/summary.py b/backend/app/api/v1/summary.py new file mode 100755 index 0000000..8c14114 --- /dev/null +++ b/backend/app/api/v1/summary.py @@ -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)}") + diff --git a/backend/app/api/v1/timeline.py b/backend/app/api/v1/timeline.py new file mode 100755 index 0000000..c024c23 --- /dev/null +++ b/backend/app/api/v1/timeline.py @@ -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)}") + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100755 index 0000000..e06c865 --- /dev/null +++ b/backend/app/core/config.py @@ -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() + diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100755 index 0000000..e071d09 --- /dev/null +++ b/backend/app/core/database.py @@ -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() + diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100755 index 0000000..66a8b14 --- /dev/null +++ b/backend/app/core/security.py @@ -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 + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100755 index 0000000..9be4cc1 --- /dev/null +++ b/backend/app/main.py @@ -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"} + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100755 index 0000000..d888ed5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,14 @@ +from app.models.service_db import ( + AppUser, + AppRole, + LeaveEvent, + AppConfiguration +) + +__all__ = [ + "AppUser", + "AppRole", + "LeaveEvent", + "AppConfiguration" +] + diff --git a/backend/app/models/service_db.py b/backend/app/models/service_db.py new file mode 100755 index 0000000..c4e0028 --- /dev/null +++ b/backend/app/models/service_db.py @@ -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()) + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100755 index 0000000..d910312 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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" +] + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100755 index 0000000..8775f75 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 + diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100755 index 0000000..716b44d --- /dev/null +++ b/backend/app/schemas/config.py @@ -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 + diff --git a/backend/app/schemas/leave.py b/backend/app/schemas/leave.py new file mode 100755 index 0000000..df78cc5 --- /dev/null +++ b/backend/app/schemas/leave.py @@ -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 + diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py new file mode 100755 index 0000000..0241241 --- /dev/null +++ b/backend/app/schemas/metrics.py @@ -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] + diff --git a/backend/app/schemas/summary.py b/backend/app/schemas/summary.py new file mode 100755 index 0000000..7a9ec5a --- /dev/null +++ b/backend/app/schemas/summary.py @@ -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] + diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py new file mode 100755 index 0000000..7b469d3 --- /dev/null +++ b/backend/app/schemas/timeline.py @@ -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] + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100755 index 0000000..7004751 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..585fd2b --- /dev/null +++ b/backend/requirements.txt @@ -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 + diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100755 index 0000000..dfe0690 --- /dev/null +++ b/backend/scripts/init_db.py @@ -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() + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..a04e04d --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100755 index 0000000..be7ccda --- /dev/null +++ b/frontend/Dockerfile @@ -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"] + diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..3972c4c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + ManicTime Dashboard + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100755 index 0000000..503e757 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100755 index 0000000..e3a922f --- /dev/null +++ b/frontend/src/App.css @@ -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; +} + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100755 index 0000000..93549d6 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ) +} + +export default App + diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css new file mode 100755 index 0000000..8e16c49 --- /dev/null +++ b/frontend/src/components/Layout.css @@ -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; +} + diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100755 index 0000000..07d490c --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -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 ( +
+ +
+ +
+
+ ) +} + +export default Layout + diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100755 index 0000000..b3e4b28 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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
Загрузка...
+ } + + if (!isAuthenticated) { + return + } + + return children || +} + +export default ProtectedRoute + diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100755 index 0000000..05d8d1e --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -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 {children} +} + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100755 index 0000000..c939800 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} + diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100755 index 0000000..3da4128 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) + diff --git a/frontend/src/pages/AdminPanel.css b/frontend/src/pages/AdminPanel.css new file mode 100755 index 0000000..d57eb1f --- /dev/null +++ b/frontend/src/pages/AdminPanel.css @@ -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; +} + diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx new file mode 100755 index 0000000..08a1b5b --- /dev/null +++ b/frontend/src/pages/AdminPanel.jsx @@ -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 ( +
+

Панель администратора

+ + {error && ( +
setError('')}> + {error} +
+ )} + {success && ( +
setSuccess('')}> + {success} +
+ )} + +
+
+

Пользователи

+ +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
IDЛогинРольСтатусСоздан
{user.id}{user.login}{user.role_name}{user.is_active ? 'Активен' : 'Неактивен'} + {new Date(user.created_at).toLocaleDateString('ru-RU')} +
+
+ +
+

Конфигурация подключения к ManicTime

+

+ Здесь можно изменить нечувствительные параметры подключения к базе + данных ManicTime. Пароль и пользователь настраиваются только через + переменные окружения на сервере. +

+
+ {config.map((item) => ( +
+ + + handleUpdateConfig(item.key, e.target.value) + } + placeholder="Не задано" + /> +
+ ))} +
+
+ + {showUserModal && ( +
setShowUserModal(false)}> +
e.stopPropagation()}> +

Создать пользователя

+
+ + + setNewUser({ ...newUser, login: e.target.value }) + } + required + /> +
+
+ + + setNewUser({ ...newUser, password: e.target.value }) + } + required + /> +
+
+ + +
+
+ + +
+
+
+ )} +
+ ) +} + +export default AdminPanel + diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100755 index 0000000..b570d88 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -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; +} + diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100755 index 0000000..997b9bf --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import './Dashboard.css' + +const Dashboard = () => { + return ( +
+

Главная панель

+
+ +

Сводка

+

Агрегированная гистограмма активности по категориям

+ + +

Хронология

+

Индивидуальные линейки активности пользователей

+ + +

Метрика

+

Числовые показатели эффективности в динамике

+ + +

Отпуска/Больничные

+

Календарь отсутствий сотрудников

+ +
+
+ ) +} + +export default Dashboard + diff --git a/frontend/src/pages/LeaveCalendar.css b/frontend/src/pages/LeaveCalendar.css new file mode 100755 index 0000000..22cb3f9 --- /dev/null +++ b/frontend/src/pages/LeaveCalendar.css @@ -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; +} + diff --git a/frontend/src/pages/LeaveCalendar.jsx b/frontend/src/pages/LeaveCalendar.jsx new file mode 100755 index 0000000..8f65261 --- /dev/null +++ b/frontend/src/pages/LeaveCalendar.jsx @@ -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 ( +
+

Отпуска и больничные

+ + {error &&
{error}
} + +
+ +
+ + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +

Добавить событие

+
+ + +
+
+ + + setNewEvent({ ...newEvent, start_date: e.target.value }) + } + required + /> +
+
+ + + setNewEvent({ ...newEvent, end_date: e.target.value }) + } + required + /> +
+
+ + +
+
+ + +
+
+
+ )} +
+ ) +} + +export default LeaveCalendar + diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css new file mode 100755 index 0000000..ec585cf --- /dev/null +++ b/frontend/src/pages/Login.css @@ -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; +} + diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100755 index 0000000..60bb59c --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -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 ( +
+
+

ManicTime Dashboard

+

Вход в систему

+
+
+ + setLogin(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+
+ ) +} + +export default Login + diff --git a/frontend/src/pages/Metrics.css b/frontend/src/pages/Metrics.css new file mode 100755 index 0000000..3ba690e --- /dev/null +++ b/frontend/src/pages/Metrics.css @@ -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; +} + diff --git a/frontend/src/pages/Metrics.jsx b/frontend/src/pages/Metrics.jsx new file mode 100755 index 0000000..330e3b0 --- /dev/null +++ b/frontend/src/pages/Metrics.jsx @@ -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 ( +
+

Метрики эффективности

+ +
+
{ + e.preventDefault() + fetchData() + }} + className="metrics-controls" + > +
+ + +
+
+ + setYear(parseInt(e.target.value))} + min="2000" + max="2100" + required + /> +
+ +
+
+ + {error &&
{error}
} + + {data && !loading && ( +
+

+ Агрегированные метрики за {year} год ({period === 'week' ? 'по неделям' : period === 'month' ? 'по месяцам' : period === 'quarter' ? 'по кварталам' : 'за год'}) +

+
+ + + + + {getAllPeriodKeys().map((key) => ( + + ))} + + + + {data.data.map((row, idx) => ( + + + {getAllPeriodKeys().map((key) => ( + + ))} + + ))} + +
Пользователь{getPeriodLabel(key)}
{row.user} + {row.data[key] ? `${row.data[key]} ч` : '-'} +
+
+
+ )} +
+ ) +} + +export default Metrics + diff --git a/frontend/src/pages/Summary.css b/frontend/src/pages/Summary.css new file mode 100755 index 0000000..8e66824 --- /dev/null +++ b/frontend/src/pages/Summary.css @@ -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; +} + diff --git a/frontend/src/pages/Summary.jsx b/frontend/src/pages/Summary.jsx new file mode 100755 index 0000000..c5f1724 --- /dev/null +++ b/frontend/src/pages/Summary.jsx @@ -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 ( +
+

Сводка активности

+
+
+
+ + setStartDate(e.target.value)} + required + /> +
+
+ + setEndDate(e.target.value)} + required + /> +
+ +
+
+ + {error &&
{error}
} + + {data && !loading && ( +
+ +
+ )} +
+ ) +} + +export default Summary + diff --git a/frontend/src/pages/Timeline.css b/frontend/src/pages/Timeline.css new file mode 100755 index 0000000..47851c0 --- /dev/null +++ b/frontend/src/pages/Timeline.css @@ -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; +} + diff --git a/frontend/src/pages/Timeline.jsx b/frontend/src/pages/Timeline.jsx new file mode 100755 index 0000000..18d3d5f --- /dev/null +++ b/frontend/src/pages/Timeline.jsx @@ -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 ( +
+

Хронология активности

+ +
+
+ + setSelectedDate(e.target.value)} + required + /> +
+ +
+ +
+ {users.map((user) => ( + + ))} + {users.length === 0 && ( +

+ Выберите дату и загрузите данные для отображения пользователей +

+ )} +
+
+
+ + {error &&
{error}
} + + {loading &&
Загрузка...
} + + {activities.length > 0 && ( +
+

Активность за {selectedDate}

+
+ {activities.map((activity) => ( +
+
{activity.display_name}
+
+ {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 ( +
+ ) + })} +
+
+ ))} +
+
+
+ + Активный +
+
+ + Неактивный +
+
+ + Не у ПК +
+
+ + Продуктивность +
+
+
+ )} +
+ ) +} + +export default Timeline + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100755 index 0000000..ba60ffd --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) +