Initial commit: Базовая структура сайта
This commit is contained in:
40
.gitignore
vendored
Executable file
40
.gitignore
vendored
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# React
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
320
DEPLOYMENT.md
Executable file
320
DEPLOYMENT.md
Executable file
@@ -0,0 +1,320 @@
|
|||||||
|
# Инструкция по развертыванию
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Подготовка окружения
|
||||||
|
|
||||||
|
#### Установка зависимостей системы
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3.9 python3-pip python3-venv postgresql-client nodejs npm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- Установите Python 3.9+ с официального сайта
|
||||||
|
- Установите Node.js 18+ с официального сайта
|
||||||
|
- Установите PostgreSQL с официального сайта
|
||||||
|
|
||||||
|
### 2. Настройка баз данных
|
||||||
|
|
||||||
|
#### Создание readonly роли для ManicTime БД
|
||||||
|
|
||||||
|
**ВАЖНО**: Выполните эти команды на сервере PostgreSQL, где находится база данных ManicTime:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Подключитесь к PostgreSQL как суперпользователь
|
||||||
|
psql -U postgres
|
||||||
|
|
||||||
|
-- Создайте роль
|
||||||
|
CREATE ROLE manictime_readonly LOGIN PASSWORD 'N0v1y_S3cur3_P@ssw0rd!';
|
||||||
|
|
||||||
|
-- Предоставьте права на подключение к БД
|
||||||
|
GRANT CONNECT ON DATABASE "ManicTimeReports" TO manictime_readonly;
|
||||||
|
|
||||||
|
-- Предоставьте права на схему
|
||||||
|
GRANT USAGE ON SCHEMA public TO manictime_readonly;
|
||||||
|
|
||||||
|
-- Предоставьте права на чтение таблиц
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO manictime_readonly;
|
||||||
|
|
||||||
|
-- Для новых таблиц (если они будут созданы)
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT ON TABLES TO manictime_readonly;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание служебной базы данных
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE dashboard_service;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Создание виртуального окружения
|
||||||
|
python -m venv venv
|
||||||
|
|
||||||
|
# Активация (Windows)
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
|
# Активация (Linux/Mac)
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Создание файла .env
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Отредактируйте .env с вашими настройками
|
||||||
|
# Используйте любой текстовый редактор
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Настройка .env файла
|
||||||
|
|
||||||
|
Откройте `backend/.env` и заполните следующие параметры:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ManicTime Database (Read-Only)
|
||||||
|
MANICTIME_DB_HOST=192.168.1.100 # IP или hostname сервера ManicTime БД
|
||||||
|
MANICTIME_DB_PORT=5432
|
||||||
|
MANICTIME_DB_NAME=ManicTimeReports
|
||||||
|
MANICTIME_DB_USER=manictime_readonly
|
||||||
|
MANICTIME_DB_PASSWORD=N0v1y_S3cur3_P@ssw0rd! # Пароль, который вы установили выше
|
||||||
|
|
||||||
|
# Service Database
|
||||||
|
SERVICE_DB_HOST=localhost
|
||||||
|
SERVICE_DB_PORT=5432
|
||||||
|
SERVICE_DB_NAME=dashboard_service
|
||||||
|
SERVICE_DB_USER=postgres
|
||||||
|
SERVICE_DB_PASSWORD=ваш_пароль_postgres
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
JWT_SECRET_KEY=$(openssl rand -hex 32) # Сгенерируйте случайный ключ
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Для генерации JWT_SECRET_KEY на Linux/Mac:**
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
**Для Windows:**
|
||||||
|
Используйте онлайн генератор или Python:
|
||||||
|
```python
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_hex(32))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Инициализация базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Применение миграций
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Создание начальных ролей и администратора
|
||||||
|
python scripts/init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Запуск Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Режим разработки
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# Или для production (рекомендуется использовать gunicorn)
|
||||||
|
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend будет доступен по адресу: `http://localhost:8000`
|
||||||
|
|
||||||
|
API документация: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### 5. Настройка Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Запуск в режиме разработки
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Или сборка для production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend будет доступен по адресу: `http://localhost:3000`
|
||||||
|
|
||||||
|
### 6. Первый вход
|
||||||
|
|
||||||
|
После успешного запуска:
|
||||||
|
|
||||||
|
1. Откройте браузер и перейдите на `http://localhost:3000`
|
||||||
|
2. Вы увидите страницу входа
|
||||||
|
3. Используйте учетные данные:
|
||||||
|
- **Логин**: `admin`
|
||||||
|
- **Пароль**: `admin123`
|
||||||
|
4. После входа **НЕМЕДЛЕННО** измените пароль через панель администратора
|
||||||
|
|
||||||
|
## Развертывание в Production
|
||||||
|
|
||||||
|
### Использование Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создайте .env файл в корне проекта
|
||||||
|
cp backend/.env.example .env
|
||||||
|
|
||||||
|
# Отредактируйте .env
|
||||||
|
|
||||||
|
# Запуск всех сервисов
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройка Nginx (рекомендуется)
|
||||||
|
|
||||||
|
Пример конфигурации Nginx для production:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
root /path/to/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd Service (для Linux)
|
||||||
|
|
||||||
|
Создайте файл `/etc/systemd/system/manictime-dashboard.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=ManicTime Dashboard Backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/path/to/dash/backend
|
||||||
|
Environment="PATH=/path/to/dash/backend/venv/bin"
|
||||||
|
ExecStart=/path/to/dash/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Активация сервиса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable manictime-dashboard
|
||||||
|
sudo systemctl start manictime-dashboard
|
||||||
|
sudo systemctl status manictime-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### Проблема: Не удается подключиться к БД ManicTime
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте, что роль `manictime_readonly` создана и имеет правильные права
|
||||||
|
2. Проверьте файрвол - порт 5432 должен быть открыт
|
||||||
|
3. Проверьте настройки в `.env`
|
||||||
|
4. Попробуйте подключиться вручную: `psql -h HOST -U manictime_readonly -d ManicTimeReports`
|
||||||
|
|
||||||
|
### Проблема: Ошибка при миграциях
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```bash
|
||||||
|
# Проверьте подключение к служебной БД
|
||||||
|
psql -h localhost -U postgres -d dashboard_service
|
||||||
|
|
||||||
|
# Если БД не существует, создайте её
|
||||||
|
createdb dashboard_service
|
||||||
|
|
||||||
|
# Повторите миграции
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Frontend не может подключиться к Backend
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте, что Backend запущен на порту 8000
|
||||||
|
2. Проверьте настройки прокси в `vite.config.js`
|
||||||
|
3. Проверьте CORS настройки в `backend/app/main.py`
|
||||||
|
4. Проверьте консоль браузера на наличие ошибок
|
||||||
|
|
||||||
|
### Проблема: JWT токены не работают
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Убедитесь, что `JWT_SECRET_KEY` установлен в `.env`
|
||||||
|
2. Проверьте, что ключ достаточно длинный (минимум 32 символа)
|
||||||
|
3. Перезапустите Backend после изменения ключа
|
||||||
|
|
||||||
|
## Обновление системы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Остановите сервисы
|
||||||
|
docker-compose down # или systemctl stop manictime-dashboard
|
||||||
|
|
||||||
|
# 2. Обновите код
|
||||||
|
git pull # или скопируйте новые файлы
|
||||||
|
|
||||||
|
# 3. Обновите зависимости
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 4. Примените миграции БД
|
||||||
|
cd backend
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 5. Перезапустите сервисы
|
||||||
|
docker-compose up -d # или systemctl restart manictime-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Резервное копирование
|
||||||
|
|
||||||
|
### База данных служебной БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание резервной копии
|
||||||
|
pg_dump -h localhost -U postgres dashboard_service > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Восстановление
|
||||||
|
psql -h localhost -U postgres dashboard_service < backup_20240101.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Файлы конфигурации
|
||||||
|
|
||||||
|
Всегда сохраняйте копию файла `.env` в безопасном месте!
|
||||||
|
|
||||||
23
backend/.env.example
Executable file
23
backend/.env.example
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
# ManicTime Database Connection (Read-Only)
|
||||||
|
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
|
||||||
|
MANICTIME_DB_HOST=localhost
|
||||||
|
MANICTIME_DB_PORT=5432
|
||||||
|
MANICTIME_DB_NAME=ManicTimeReports
|
||||||
|
MANICTIME_DB_USER=manictime_readonly
|
||||||
|
MANICTIME_DB_PASSWORD=N0v1y_S3cur3_P@ssw0rd!
|
||||||
|
|
||||||
|
# Service Database (для управления сервисом)
|
||||||
|
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
|
||||||
|
SERVICE_DB_HOST=localhost
|
||||||
|
SERVICE_DB_PORT=5432
|
||||||
|
SERVICE_DB_NAME=dashboard_service
|
||||||
|
SERVICE_DB_USER=postgres
|
||||||
|
SERVICE_DB_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
# ВАЖНО: Сгенерируйте случайный ключ для production!
|
||||||
|
# Для генерации на Linux/Mac: openssl rand -hex 32
|
||||||
|
# Для Windows: используйте Python: import secrets; print(secrets.token_hex(32))
|
||||||
|
JWT_SECRET_KEY=change-this-secret-key-in-production-use-random-hex-string
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
22
backend/Dockerfile
Executable file
22
backend/Dockerfile
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Установка зависимостей системы
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Копирование requirements и установка зависимостей Python
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копирование кода приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Открытие порта
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Команда запуска
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
114
backend/alembic.ini
Executable file
114
backend/alembic.ini
Executable file
@@ -0,0 +1,114 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration files
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# version_locations option
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new_file_template = %%(slug)s
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = postgresql://postgres:password@localhost/dashboard_service
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
|
|
||||||
79
backend/alembic/env.py
Executable file
79
backend/alembic/env.py
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Добавляем путь к приложению
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.core.database import Base, service_engine
|
||||||
|
from app.models.service_db import * # Импортируем все модели
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the Alembic env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = service_engine
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
|
|
||||||
25
backend/alembic/script.py.mako
Executable file
25
backend/alembic/script.py.mako
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
|
|
||||||
86
backend/alembic/versions/001_initial_migration.py
Executable file
86
backend/alembic/versions/001_initial_migration.py
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '001'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Создание таблицы app_roles
|
||||||
|
op.create_table(
|
||||||
|
'app_roles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role_name', sa.String(length=50), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('role_name')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создание таблицы app_users
|
||||||
|
op.create_table(
|
||||||
|
'app_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('login', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['app_roles.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('login')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_app_users_id'), 'app_users', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_app_users_login'), 'app_users', ['login'], unique=True)
|
||||||
|
|
||||||
|
# Создание таблицы leave_events
|
||||||
|
op.create_table(
|
||||||
|
'leave_events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('leave_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['app_users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_leave_events_id'), 'leave_events', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Создание таблицы app_configuration
|
||||||
|
op.create_table(
|
||||||
|
'app_configuration',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('value', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('key')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_app_configuration_id'), 'app_configuration', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_app_configuration_key'), 'app_configuration', ['key'], unique=True)
|
||||||
|
|
||||||
|
# Добавление начальных ролей
|
||||||
|
op.execute("INSERT INTO app_roles (role_name) VALUES ('Admin'), ('User')")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_app_configuration_key'), table_name='app_configuration')
|
||||||
|
op.drop_index(op.f('ix_app_configuration_id'), table_name='app_configuration')
|
||||||
|
op.drop_table('app_configuration')
|
||||||
|
op.drop_index(op.f('ix_leave_events_id'), table_name='leave_events')
|
||||||
|
op.drop_table('leave_events')
|
||||||
|
op.drop_index(op.f('ix_app_users_login'), table_name='app_users')
|
||||||
|
op.drop_index(op.f('ix_app_users_id'), table_name='app_users')
|
||||||
|
op.drop_table('app_users')
|
||||||
|
op.drop_table('app_roles')
|
||||||
|
|
||||||
0
backend/app/__init__.py
Executable file
0
backend/app/__init__.py
Executable file
8
backend/app/__main__.py
Executable file
8
backend/app/__main__.py
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Точка входа для запуска приложения через python -m app
|
||||||
|
"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
0
backend/app/api/__init__.py
Executable file
0
backend/app/api/__init__.py
Executable file
15
backend/app/api/v1/__init__.py
Executable file
15
backend/app/api/v1/__init__.py
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
API v1 роутер
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from app.api.v1 import auth, summary, timeline, metrics, leave, admin
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
api_router.include_router(summary.router, prefix="/summary", tags=["summary"])
|
||||||
|
api_router.include_router(timeline.router, prefix="/timeline", tags=["timeline"])
|
||||||
|
api_router.include_router(metrics.router, prefix="/metrics", tags=["metrics"])
|
||||||
|
api_router.include_router(leave.router, prefix="/leave", tags=["leave"])
|
||||||
|
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
120
backend/app/api/v1/admin.py
Executable file
120
backend/app/api/v1/admin.py
Executable file
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
Модуль Панель Администратора
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from app.core.database import get_service_db
|
||||||
|
from app.core.security import get_admin_user, get_password_hash
|
||||||
|
from app.models.service_db import AppUser, AppRole, AppConfiguration
|
||||||
|
from app.schemas.user import UserCreate, UserResponse
|
||||||
|
from app.schemas.config import ConfigResponse, ConfigUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[UserResponse])
|
||||||
|
async def get_users(
|
||||||
|
admin_user: AppUser = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""Получение списка всех пользователей сервиса"""
|
||||||
|
users = db.query(AppUser).all()
|
||||||
|
result = []
|
||||||
|
for user in users:
|
||||||
|
result.append(UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
role_id=user.role_id,
|
||||||
|
role_name=user.role.role_name if user.role else None,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
user_data: UserCreate,
|
||||||
|
admin_user: AppUser = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""Создание нового пользователя сервиса"""
|
||||||
|
# Проверка существования логина
|
||||||
|
existing_user = db.query(AppUser).filter(AppUser.login == user_data.login).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Пользователь с таким логином уже существует")
|
||||||
|
|
||||||
|
# Проверка существования роли
|
||||||
|
role = db.query(AppRole).filter(AppRole.id == user_data.role_id).first()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Роль не найдена")
|
||||||
|
|
||||||
|
new_user = AppUser(
|
||||||
|
login=user_data.login,
|
||||||
|
hashed_password=get_password_hash(user_data.password),
|
||||||
|
role_id=user_data.role_id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=new_user.id,
|
||||||
|
login=new_user.login,
|
||||||
|
role_id=new_user.role_id,
|
||||||
|
role_name=new_user.role.role_name if new_user.role else None,
|
||||||
|
is_active=new_user.is_active,
|
||||||
|
created_at=new_user.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=List[ConfigResponse])
|
||||||
|
async def get_config(
|
||||||
|
admin_user: AppUser = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""Получение конфигурации (нечувствительные данные)"""
|
||||||
|
config_items = db.query(AppConfiguration).filter(
|
||||||
|
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in config_items:
|
||||||
|
result.append(ConfigResponse(key=item.key, value=item.value))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config", response_model=ConfigResponse)
|
||||||
|
async def update_config(
|
||||||
|
config_data: ConfigUpdate,
|
||||||
|
admin_user: AppUser = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""Обновление конфигурации (нечувствительные данные)"""
|
||||||
|
allowed_keys = ["manictime_host", "manictime_port", "manictime_dbname"]
|
||||||
|
|
||||||
|
if config_data.key not in allowed_keys:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Ключ '{config_data.key}' не разрешен для редактирования"
|
||||||
|
)
|
||||||
|
|
||||||
|
config_item = db.query(AppConfiguration).filter(
|
||||||
|
AppConfiguration.key == config_data.key
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config_item:
|
||||||
|
config_item = AppConfiguration(key=config_data.key, value=config_data.value)
|
||||||
|
db.add(config_item)
|
||||||
|
else:
|
||||||
|
config_item.value = config_data.value
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config_item)
|
||||||
|
|
||||||
|
return ConfigResponse(key=config_item.key, value=config_item.value)
|
||||||
|
|
||||||
43
backend/app/api/v1/auth.py
Executable file
43
backend/app/api/v1/auth.py
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Модуль аутентификации
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_service_db
|
||||||
|
from app.core.security import verify_password, create_access_token
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.service_db import AppUser
|
||||||
|
from app.schemas.auth import LoginRequest, Token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
login_data: LoginRequest,
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""Аутентификация пользователя и получение JWT токена"""
|
||||||
|
user = db.query(AppUser).filter(AppUser.login == login_data.login).first()
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный логин или пароль"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify_password(login_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Неверный логин или пароль"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.id, "role": user.role.role_name},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
99
backend/app/api/v1/leave.py
Executable file
99
backend/app/api/v1/leave.py
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Модуль Отпуска/Больничные
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import date
|
||||||
|
from typing import List
|
||||||
|
from app.core.database import get_service_db
|
||||||
|
from app.core.security import get_current_user, get_admin_user
|
||||||
|
from app.models.service_db import AppUser, LeaveEvent
|
||||||
|
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", response_model=List[LeaveEventResponse])
|
||||||
|
async def get_leave_events(
|
||||||
|
start_date: date = Query(..., description="Начальная дата"),
|
||||||
|
end_date: date = Query(..., description="Конечная дата"),
|
||||||
|
current_user: AppUser = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение списка событий отпусков и больничных за период
|
||||||
|
"""
|
||||||
|
if start_date > end_date:
|
||||||
|
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
|
||||||
|
|
||||||
|
events = db.query(LeaveEvent).filter(
|
||||||
|
LeaveEvent.start_date <= end_date,
|
||||||
|
LeaveEvent.end_date >= start_date
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for event in events:
|
||||||
|
result.append(LeaveEventResponse(
|
||||||
|
id=event.id,
|
||||||
|
user_id=event.user_id,
|
||||||
|
user_login=event.user.login if event.user else None,
|
||||||
|
start_date=event.start_date,
|
||||||
|
end_date=event.end_date,
|
||||||
|
leave_type=event.leave_type,
|
||||||
|
created_at=event.created_at.isoformat() if event.created_at else None
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events", response_model=LeaveEventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_leave_event(
|
||||||
|
event_data: LeaveEventCreate,
|
||||||
|
admin_user: AppUser = Depends(get_admin_user),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Создание нового события отпуска/больничного (только для администраторов)
|
||||||
|
"""
|
||||||
|
if event_data.start_date > event_data.end_date:
|
||||||
|
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
|
||||||
|
|
||||||
|
# Проверка существования пользователя
|
||||||
|
user = db.query(AppUser).filter(AppUser.id == event_data.user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
# Проверка на пересечение с существующими событиями
|
||||||
|
overlapping = db.query(LeaveEvent).filter(
|
||||||
|
LeaveEvent.user_id == event_data.user_id,
|
||||||
|
LeaveEvent.start_date <= event_data.end_date,
|
||||||
|
LeaveEvent.end_date >= event_data.start_date
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if overlapping:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Уже существует событие в этом периоде для данного пользователя"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_event = LeaveEvent(
|
||||||
|
user_id=event_data.user_id,
|
||||||
|
start_date=event_data.start_date,
|
||||||
|
end_date=event_data.end_date,
|
||||||
|
leave_type=event_data.leave_type
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_event)
|
||||||
|
|
||||||
|
return LeaveEventResponse(
|
||||||
|
id=new_event.id,
|
||||||
|
user_id=new_event.user_id,
|
||||||
|
user_login=new_event.user.login if new_event.user else None,
|
||||||
|
start_date=new_event.start_date,
|
||||||
|
end_date=new_event.end_date,
|
||||||
|
leave_type=new_event.leave_type,
|
||||||
|
created_at=new_event.created_at.isoformat() if new_event.created_at else None
|
||||||
|
)
|
||||||
|
|
||||||
98
backend/app/api/v1/metrics.py
Executable file
98
backend/app/api/v1/metrics.py
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Модуль Метрика - числовые показатели эффективности
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from typing import Literal
|
||||||
|
from app.core.database import get_manictime_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.service_db import AppUser
|
||||||
|
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aggregate", response_model=MetricsAggregateResponse)
|
||||||
|
async def get_metrics_aggregate(
|
||||||
|
period: Literal["week", "month", "quarter", "year"] = Query(..., description="Период агрегации"),
|
||||||
|
year: int = Query(..., description="Год"),
|
||||||
|
current_user: AppUser = Depends(get_current_user),
|
||||||
|
manictime_db: Session = Depends(get_manictime_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение агрегированных метрик за указанный период
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if year < 2000 or year > 2100:
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный год")
|
||||||
|
|
||||||
|
# Определение функции агрегации в зависимости от периода
|
||||||
|
date_trunc_func = {
|
||||||
|
"week": "week",
|
||||||
|
"month": "month",
|
||||||
|
"quarter": "quarter",
|
||||||
|
"year": "year"
|
||||||
|
}.get(period, "quarter")
|
||||||
|
|
||||||
|
# SQL запрос для агрегации активного времени
|
||||||
|
query = text(f"""
|
||||||
|
SELECT
|
||||||
|
u."DisplayName" AS user_name,
|
||||||
|
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime") AS period_start,
|
||||||
|
SUM(EXTRACT(EPOCH FROM (a."EndLocalTime" - a."StartLocalTime"))) AS total_seconds
|
||||||
|
FROM "Ar_Activity" a
|
||||||
|
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
|
||||||
|
JOIN "Ar_User" u ON t."OwnerId" = u."UserId"
|
||||||
|
WHERE
|
||||||
|
t."SchemaName" = 'ManicTime/Computer usage'
|
||||||
|
AND a."Name" = 'Active'
|
||||||
|
AND EXTRACT(YEAR FROM a."StartLocalTime") = :year
|
||||||
|
GROUP BY
|
||||||
|
u."DisplayName",
|
||||||
|
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime")
|
||||||
|
ORDER BY
|
||||||
|
u."DisplayName",
|
||||||
|
period_start
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = manictime_db.execute(query, {"year": year})
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# Группировка данных по пользователям
|
||||||
|
user_data = {}
|
||||||
|
for row in rows:
|
||||||
|
user_name = row.user_name
|
||||||
|
if user_name not in user_data:
|
||||||
|
user_data[user_name] = {}
|
||||||
|
|
||||||
|
# Формирование ключа периода
|
||||||
|
period_start = row.period_start
|
||||||
|
if period == "week":
|
||||||
|
period_key = f"W{period_start.isocalendar()[1]}"
|
||||||
|
elif period == "month":
|
||||||
|
period_key = f"M{period_start.month}"
|
||||||
|
elif period == "quarter":
|
||||||
|
quarter = (period_start.month - 1) // 3 + 1
|
||||||
|
period_key = f"Q{quarter}"
|
||||||
|
else: # year
|
||||||
|
period_key = "Y1"
|
||||||
|
|
||||||
|
# Конвертация секунд в часы
|
||||||
|
hours = float(row.total_seconds) / 3600
|
||||||
|
user_data[user_name][period_key] = round(hours, 2)
|
||||||
|
|
||||||
|
# Формирование ответа
|
||||||
|
metrics_rows = []
|
||||||
|
for user_name, periods in user_data.items():
|
||||||
|
metrics_rows.append(MetricsRow(user=user_name, data=periods))
|
||||||
|
|
||||||
|
return MetricsAggregateResponse(
|
||||||
|
period_type=period,
|
||||||
|
year=year,
|
||||||
|
data=metrics_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")
|
||||||
|
|
||||||
123
backend/app/api/v1/summary.py
Executable file
123
backend/app/api/v1/summary.py
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Модуль Сводка - агрегированная гистограмма активности
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from app.core.database import get_manictime_db, get_service_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.service_db import AppUser, AppConfiguration
|
||||||
|
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/histogram", response_model=SummaryHistogramResponse)
|
||||||
|
async def get_summary_histogram(
|
||||||
|
start_date: str = Query(..., description="Начальная дата (YYYY-MM-DD)"),
|
||||||
|
end_date: str = Query(..., description="Конечная дата (YYYY-MM-DD)"),
|
||||||
|
current_user: AppUser = Depends(get_current_user),
|
||||||
|
manictime_db: Session = Depends(get_manictime_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение данных для гистограммы активности по дням
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Валидация дат
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
|
||||||
|
if start_dt > end_dt:
|
||||||
|
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
|
||||||
|
|
||||||
|
# SQL запрос для агрегации данных
|
||||||
|
query = text("""
|
||||||
|
WITH computer_usage AS (
|
||||||
|
SELECT
|
||||||
|
a."StartLocalTime",
|
||||||
|
a."EndLocalTime",
|
||||||
|
a."Name",
|
||||||
|
t."OwnerId",
|
||||||
|
DATE_TRUNC('day', a."StartLocalTime") AS "day"
|
||||||
|
FROM "Ar_Activity" a
|
||||||
|
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
|
||||||
|
WHERE t."SchemaName" = 'ManicTime/Computer usage'
|
||||||
|
AND a."StartLocalTime" >= :start_date
|
||||||
|
AND a."EndLocalTime" <= :end_date
|
||||||
|
),
|
||||||
|
productive_time AS (
|
||||||
|
SELECT
|
||||||
|
a."StartLocalTime",
|
||||||
|
a."EndLocalTime",
|
||||||
|
t."OwnerId",
|
||||||
|
DATE_TRUNC('day', a."StartLocalTime") AS "day"
|
||||||
|
FROM "Ar_Activity" a
|
||||||
|
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
|
||||||
|
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
|
||||||
|
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
|
||||||
|
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
|
||||||
|
WHERE c."Name" = 'Productive'
|
||||||
|
AND a."StartLocalTime" >= :start_date
|
||||||
|
AND a."EndLocalTime" <= :end_date
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cu."day",
|
||||||
|
SUM(CASE WHEN cu."Name" = 'Active'
|
||||||
|
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
|
||||||
|
ELSE 0 END) AS active_seconds,
|
||||||
|
SUM(CASE WHEN cu."Name" = 'Away'
|
||||||
|
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
|
||||||
|
ELSE 0 END) AS away_seconds,
|
||||||
|
SUM(CASE WHEN cu."Name" IN ('Session Locked', 'Power Off')
|
||||||
|
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
|
||||||
|
ELSE 0 END) AS afk_seconds,
|
||||||
|
COALESCE(SUM(EXTRACT(EPOCH FROM (pt."EndLocalTime" - pt."StartLocalTime"))), 0) AS productive_seconds
|
||||||
|
FROM computer_usage cu
|
||||||
|
LEFT JOIN productive_time pt ON cu."day" = pt."day"
|
||||||
|
AND cu."OwnerId" = pt."OwnerId"
|
||||||
|
AND pt."StartLocalTime" < cu."EndLocalTime"
|
||||||
|
AND pt."EndLocalTime" > cu."StartLocalTime"
|
||||||
|
GROUP BY cu."day"
|
||||||
|
ORDER BY cu."day"
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = manictime_db.execute(
|
||||||
|
query,
|
||||||
|
{"start_date": start_date, "end_date": end_date}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# Формирование ответа
|
||||||
|
labels = []
|
||||||
|
active_data = []
|
||||||
|
away_data = []
|
||||||
|
afk_data = []
|
||||||
|
productive_data = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
day = row.day.strftime("%Y-%m-%d")
|
||||||
|
labels.append(day)
|
||||||
|
active_data.append(float(row.active_seconds or 0))
|
||||||
|
away_data.append(float(row.away_seconds or 0))
|
||||||
|
afk_data.append(float(row.afk_seconds or 0))
|
||||||
|
productive_data.append(float(row.productive_seconds or 0))
|
||||||
|
|
||||||
|
datasets = [
|
||||||
|
SummaryDataset(label="Активный", color="green", data=active_data),
|
||||||
|
SummaryDataset(label="Неактивный", color="red", data=away_data),
|
||||||
|
SummaryDataset(label="Не у ПК", color="yellow", data=afk_data),
|
||||||
|
SummaryDataset(label="Продуктивность", color="orange", data=productive_data),
|
||||||
|
]
|
||||||
|
|
||||||
|
return SummaryHistogramResponse(labels=labels, datasets=datasets)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")
|
||||||
|
|
||||||
146
backend/app/api/v1/timeline.py
Executable file
146
backend/app/api/v1/timeline.py
Executable file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Модуль Хронология - индивидуальные линейки активности
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from app.core.database import get_manictime_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.service_db import AppUser
|
||||||
|
from app.schemas.timeline import TimelineActivityResponse, UserActivity, ActivitySegment
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-activity", response_model=TimelineActivityResponse)
|
||||||
|
async def get_user_activity(
|
||||||
|
date: str = Query(..., description="Дата (YYYY-MM-DD)"),
|
||||||
|
user_ids: List[int] = Query(..., description="Список ID пользователей"),
|
||||||
|
current_user: AppUser = Depends(get_current_user),
|
||||||
|
manictime_db: Session = Depends(get_manictime_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение активности пользователей за указанную дату для построения линеек
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Валидация даты
|
||||||
|
date_dt = datetime.strptime(date, "%Y-%m-%d")
|
||||||
|
date_start = date_dt.strftime("%Y-%m-%d 00:00:00")
|
||||||
|
date_end = date_dt.strftime("%Y-%m-%d 23:59:59")
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Необходимо указать хотя бы одного пользователя")
|
||||||
|
|
||||||
|
# Получение информации о пользователях
|
||||||
|
users_query = text("""
|
||||||
|
SELECT "UserId", "DisplayName"
|
||||||
|
FROM "Ar_User"
|
||||||
|
WHERE "UserId" = ANY(:user_ids)
|
||||||
|
""")
|
||||||
|
|
||||||
|
users_result = manictime_db.execute(users_query, {"user_ids": user_ids})
|
||||||
|
users_map = {row.UserId: row.DisplayName for row in users_result}
|
||||||
|
|
||||||
|
if not users_map:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователи не найдены")
|
||||||
|
|
||||||
|
# Получение активности с шкалы Computer usage
|
||||||
|
activity_query = text("""
|
||||||
|
SELECT
|
||||||
|
t."OwnerId",
|
||||||
|
a."Name",
|
||||||
|
a."StartLocalTime",
|
||||||
|
a."EndLocalTime"
|
||||||
|
FROM "Ar_Activity" a
|
||||||
|
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
|
||||||
|
WHERE t."SchemaName" = 'ManicTime/Computer usage'
|
||||||
|
AND t."OwnerId" = ANY(:user_ids)
|
||||||
|
AND a."StartLocalTime" >= :date_start
|
||||||
|
AND a."EndLocalTime" <= :date_end
|
||||||
|
ORDER BY t."OwnerId", a."StartLocalTime"
|
||||||
|
""")
|
||||||
|
|
||||||
|
activity_result = manictime_db.execute(
|
||||||
|
activity_query,
|
||||||
|
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получение продуктивного времени
|
||||||
|
productive_query = text("""
|
||||||
|
SELECT
|
||||||
|
t."OwnerId",
|
||||||
|
a."StartLocalTime",
|
||||||
|
a."EndLocalTime"
|
||||||
|
FROM "Ar_Activity" a
|
||||||
|
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
|
||||||
|
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
|
||||||
|
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
|
||||||
|
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
|
||||||
|
WHERE c."Name" = 'Productive'
|
||||||
|
AND t."OwnerId" = ANY(:user_ids)
|
||||||
|
AND a."StartLocalTime" >= :date_start
|
||||||
|
AND a."EndLocalTime" <= :date_end
|
||||||
|
ORDER BY t."OwnerId", a."StartLocalTime"
|
||||||
|
""")
|
||||||
|
|
||||||
|
productive_result = manictime_db.execute(
|
||||||
|
productive_query,
|
||||||
|
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Группировка по пользователям
|
||||||
|
user_activities = {}
|
||||||
|
|
||||||
|
for row in activity_result:
|
||||||
|
user_id = row.OwnerId
|
||||||
|
if user_id not in user_activities:
|
||||||
|
user_activities[user_id] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": users_map.get(user_id, f"User {user_id}"),
|
||||||
|
"segments": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Определение типа сегмента
|
||||||
|
segment_type = "Active"
|
||||||
|
if row.Name == "Away":
|
||||||
|
segment_type = "Away"
|
||||||
|
elif row.Name == "Session Locked":
|
||||||
|
segment_type = "Session Locked"
|
||||||
|
elif row.Name == "Power Off":
|
||||||
|
segment_type = "Power Off"
|
||||||
|
|
||||||
|
user_activities[user_id]["segments"].append(
|
||||||
|
{
|
||||||
|
"type": segment_type,
|
||||||
|
"start": row.StartLocalTime.isoformat(),
|
||||||
|
"end": row.EndLocalTime.isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавление продуктивного времени
|
||||||
|
for row in productive_result:
|
||||||
|
user_id = row.OwnerId
|
||||||
|
if user_id in user_activities:
|
||||||
|
user_activities[user_id]["segments"].append(
|
||||||
|
{
|
||||||
|
"type": "Productive",
|
||||||
|
"start": row.StartLocalTime.isoformat(),
|
||||||
|
"end": row.EndLocalTime.isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формирование ответа
|
||||||
|
activities = []
|
||||||
|
for user_id in user_ids:
|
||||||
|
if user_id in user_activities:
|
||||||
|
activities.append(UserActivity(**user_activities[user_id]))
|
||||||
|
|
||||||
|
return TimelineActivityResponse(date=date, activities=activities)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")
|
||||||
|
|
||||||
0
backend/app/core/__init__.py
Executable file
0
backend/app/core/__init__.py
Executable file
37
backend/app/core/config.py
Executable file
37
backend/app/core/config.py
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация приложения
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# ManicTime Database (Read-Only)
|
||||||
|
MANICTIME_DB_HOST: str
|
||||||
|
MANICTIME_DB_PORT: int = 5432
|
||||||
|
MANICTIME_DB_NAME: str
|
||||||
|
MANICTIME_DB_USER: str
|
||||||
|
MANICTIME_DB_PASSWORD: str
|
||||||
|
|
||||||
|
# Service Database
|
||||||
|
SERVICE_DB_HOST: str
|
||||||
|
SERVICE_DB_PORT: int = 5432
|
||||||
|
SERVICE_DB_NAME: str
|
||||||
|
SERVICE_DB_USER: str
|
||||||
|
SERVICE_DB_PASSWORD: str
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
JWT_SECRET_KEY: str
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
103
backend/app/core/database.py
Executable file
103
backend/app/core/database.py
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Управление подключениями к базам данных
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.core.config import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Service Database (создаем первой, чтобы можно было читать конфигурацию)
|
||||||
|
service_engine = create_engine(
|
||||||
|
f"postgresql://{settings.SERVICE_DB_USER}:{settings.SERVICE_DB_PASSWORD}@"
|
||||||
|
f"{settings.SERVICE_DB_HOST}:{settings.SERVICE_DB_PORT}/{settings.SERVICE_DB_NAME}",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
echo=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ServiceSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=service_engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_manictime_config():
|
||||||
|
"""Получение конфигурации подключения к ManicTime из служебной БД"""
|
||||||
|
db = ServiceSessionLocal()
|
||||||
|
try:
|
||||||
|
from app.models.service_db import AppConfiguration
|
||||||
|
config = {}
|
||||||
|
config_items = db.query(AppConfiguration).filter(
|
||||||
|
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for item in config_items:
|
||||||
|
key = item.key.replace("manictime_", "")
|
||||||
|
config[key] = item.value
|
||||||
|
|
||||||
|
# Используем значения из БД, если они есть, иначе из .env
|
||||||
|
host = config.get("host") or settings.MANICTIME_DB_HOST
|
||||||
|
port = config.get("port") or settings.MANICTIME_DB_PORT
|
||||||
|
dbname = config.get("dbname") or settings.MANICTIME_DB_NAME
|
||||||
|
|
||||||
|
return {
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"dbname": dbname,
|
||||||
|
"user": settings.MANICTIME_DB_USER, # Всегда из .env
|
||||||
|
"password": settings.MANICTIME_DB_PASSWORD # Всегда из .env
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось загрузить конфигурацию из БД, используем .env: {e}")
|
||||||
|
return {
|
||||||
|
"host": settings.MANICTIME_DB_HOST,
|
||||||
|
"port": settings.MANICTIME_DB_PORT,
|
||||||
|
"dbname": settings.MANICTIME_DB_NAME,
|
||||||
|
"user": settings.MANICTIME_DB_USER,
|
||||||
|
"password": settings.MANICTIME_DB_PASSWORD
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_manictime_engine():
|
||||||
|
"""Создание engine для ManicTime с учетом конфигурации из БД"""
|
||||||
|
config = get_manictime_config()
|
||||||
|
return create_engine(
|
||||||
|
f"postgresql://{config['user']}:{config['password']}@"
|
||||||
|
f"{config['host']}:{config['port']}/{config['dbname']}",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
echo=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ManicTime Database (Read-Only) - создается динамически
|
||||||
|
manictime_engine = None
|
||||||
|
ManicTimeSessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_manictime_db():
|
||||||
|
"""Dependency для получения сессии ManicTime БД (read-only)"""
|
||||||
|
global manictime_engine, ManicTimeSessionLocal
|
||||||
|
|
||||||
|
# Создаем engine динамически, чтобы учитывать изменения конфигурации
|
||||||
|
if manictime_engine is None:
|
||||||
|
manictime_engine = get_manictime_engine()
|
||||||
|
ManicTimeSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=manictime_engine)
|
||||||
|
|
||||||
|
db = ManicTimeSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_db():
|
||||||
|
"""Dependency для получения сессии служебной БД"""
|
||||||
|
db = ServiceSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
74
backend/app/core/security.py
Executable file
74
backend/app/core/security.py
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Модуль безопасности: JWT, хеширование паролей
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_service_db
|
||||||
|
from app.models.service_db import AppUser
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Проверка пароля"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Хеширование пароля"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
"""Создание JWT токена"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_service_db)
|
||||||
|
) -> AppUser:
|
||||||
|
"""Получение текущего пользователя из JWT токена"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Не удалось проверить учетные данные",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
user_id: int = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = db.query(AppUser).filter(AppUser.id == user_id, AppUser.is_active == True).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
|
||||||
|
"""Проверка, что пользователь является администратором"""
|
||||||
|
if current_user.role.role_name != "Admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Недостаточно прав доступа"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
36
backend/app/main.py
Executable file
36
backend/app/main.py
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Главный модуль FastAPI приложения
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.api.v1 import api_router
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="ManicTime Dashboard API",
|
||||||
|
description="API для сервиса дашбордов и аналитики ManicTime",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS настройки
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Подключение роутеров
|
||||||
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "ManicTime Dashboard API", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
14
backend/app/models/__init__.py
Executable file
14
backend/app/models/__init__.py
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
from app.models.service_db import (
|
||||||
|
AppUser,
|
||||||
|
AppRole,
|
||||||
|
LeaveEvent,
|
||||||
|
AppConfiguration
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppUser",
|
||||||
|
"AppRole",
|
||||||
|
"LeaveEvent",
|
||||||
|
"AppConfiguration"
|
||||||
|
]
|
||||||
|
|
||||||
57
backend/app/models/service_db.py
Executable file
57
backend/app/models/service_db.py
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Модели для служебной базы данных
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DateTime, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AppRole(Base):
|
||||||
|
"""Роли пользователей сервиса"""
|
||||||
|
__tablename__ = "app_roles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
role_name = Column(String(50), unique=True, nullable=False)
|
||||||
|
|
||||||
|
users = relationship("AppUser", back_populates="role")
|
||||||
|
|
||||||
|
|
||||||
|
class AppUser(Base):
|
||||||
|
"""Пользователи сервиса дашбордов"""
|
||||||
|
__tablename__ = "app_users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
login = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
role_id = Column(Integer, ForeignKey("app_roles.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
role = relationship("AppRole", back_populates="users")
|
||||||
|
leave_events = relationship("LeaveEvent", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class LeaveEvent(Base):
|
||||||
|
"""События отпусков и больничных"""
|
||||||
|
__tablename__ = "leave_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("app_users.id"), nullable=False)
|
||||||
|
start_date = Column(Date, nullable=False)
|
||||||
|
end_date = Column(Date, nullable=False)
|
||||||
|
leave_type = Column(String(50), nullable=False) # 'Отпуск', 'Больничный'
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("AppUser", back_populates="leave_events")
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfiguration(Base):
|
||||||
|
"""Конфигурация сервиса (нечувствительные данные)"""
|
||||||
|
__tablename__ = "app_configuration"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
value = Column(String(500), nullable=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
26
backend/app/schemas/__init__.py
Executable file
26
backend/app/schemas/__init__.py
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
from app.schemas.auth import Token, TokenData, LoginRequest
|
||||||
|
from app.schemas.user import UserCreate, UserResponse
|
||||||
|
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
|
||||||
|
from app.schemas.timeline import TimelineActivityResponse, ActivitySegment
|
||||||
|
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
|
||||||
|
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
|
||||||
|
from app.schemas.config import ConfigResponse, ConfigUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Token",
|
||||||
|
"TokenData",
|
||||||
|
"LoginRequest",
|
||||||
|
"UserCreate",
|
||||||
|
"UserResponse",
|
||||||
|
"SummaryHistogramResponse",
|
||||||
|
"SummaryDataset",
|
||||||
|
"TimelineActivityResponse",
|
||||||
|
"ActivitySegment",
|
||||||
|
"MetricsAggregateResponse",
|
||||||
|
"MetricsRow",
|
||||||
|
"LeaveEventCreate",
|
||||||
|
"LeaveEventResponse",
|
||||||
|
"ConfigResponse",
|
||||||
|
"ConfigUpdate"
|
||||||
|
]
|
||||||
|
|
||||||
21
backend/app/schemas/auth.py
Executable file
21
backend/app/schemas/auth.py
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
Схемы для аутентификации
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
login: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
|
||||||
16
backend/app/schemas/config.py
Executable file
16
backend/app/schemas/config.py
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Схемы для конфигурации
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigUpdate(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: Optional[str] = None
|
||||||
|
|
||||||
27
backend/app/schemas/leave.py
Executable file
27
backend/app/schemas/leave.py
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Схемы для модуля Отпуска/Больничные
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class LeaveEventCreate(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
leave_type: str # "Отпуск", "Больничный"
|
||||||
|
|
||||||
|
|
||||||
|
class LeaveEventResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
user_login: Optional[str] = None
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
leave_type: str
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
17
backend/app/schemas/metrics.py
Executable file
17
backend/app/schemas/metrics.py
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Схемы для модуля Метрика
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsRow(BaseModel):
|
||||||
|
user: str
|
||||||
|
data: Dict[str, Any] # Динамические ключи типа "Q1", "Q2", "W1", "M1" и т.д.
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsAggregateResponse(BaseModel):
|
||||||
|
period_type: str # 'week', 'month', 'quarter', 'year'
|
||||||
|
year: int
|
||||||
|
data: List[MetricsRow]
|
||||||
|
|
||||||
17
backend/app/schemas/summary.py
Executable file
17
backend/app/schemas/summary.py
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Схемы для модуля Сводка
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryDataset(BaseModel):
|
||||||
|
label: str
|
||||||
|
color: str
|
||||||
|
data: List[float] # секунды
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryHistogramResponse(BaseModel):
|
||||||
|
labels: List[str] # даты
|
||||||
|
datasets: List[SummaryDataset]
|
||||||
|
|
||||||
23
backend/app/schemas/timeline.py
Executable file
23
backend/app/schemas/timeline.py
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Схемы для модуля Хронология
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySegment(BaseModel):
|
||||||
|
type: str # "Active", "Away", "Productive", "Session Locked", "Power Off"
|
||||||
|
start: str # ISO datetime
|
||||||
|
end: str # ISO datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserActivity(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
display_name: str
|
||||||
|
segments: List[ActivitySegment]
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineActivityResponse(BaseModel):
|
||||||
|
date: str
|
||||||
|
activities: List[UserActivity]
|
||||||
|
|
||||||
25
backend/app/schemas/user.py
Executable file
25
backend/app/schemas/user.py
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Схемы для пользователей
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
login: str
|
||||||
|
password: str
|
||||||
|
role_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
login: str
|
||||||
|
role_id: int
|
||||||
|
role_name: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
12
backend/requirements.txt
Executable file
12
backend/requirements.txt
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
alembic==1.12.1
|
||||||
|
|
||||||
73
backend/scripts/init_db.py
Executable file
73
backend/scripts/init_db.py
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Скрипт для инициализации базы данных
|
||||||
|
Создает начальные роли и первого администратора
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Добавляем путь к приложению
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import ServiceSessionLocal, Base, service_engine
|
||||||
|
from app.models.service_db import AppRole, AppUser
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Инициализация базы данных"""
|
||||||
|
# Создание таблиц
|
||||||
|
Base.metadata.create_all(bind=service_engine)
|
||||||
|
|
||||||
|
db: Session = ServiceSessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создание ролей, если их нет
|
||||||
|
admin_role = db.query(AppRole).filter(AppRole.role_name == "Admin").first()
|
||||||
|
if not admin_role:
|
||||||
|
admin_role = AppRole(role_name="Admin")
|
||||||
|
db.add(admin_role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin_role)
|
||||||
|
print("✓ Роль Admin создана")
|
||||||
|
else:
|
||||||
|
print("✓ Роль Admin уже существует")
|
||||||
|
|
||||||
|
user_role = db.query(AppRole).filter(AppRole.role_name == "User").first()
|
||||||
|
if not user_role:
|
||||||
|
user_role = AppRole(role_name="User")
|
||||||
|
db.add(user_role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user_role)
|
||||||
|
print("✓ Роль User создана")
|
||||||
|
else:
|
||||||
|
print("✓ Роль User уже существует")
|
||||||
|
|
||||||
|
# Создание первого администратора, если его нет
|
||||||
|
admin_user = db.query(AppUser).filter(AppUser.login == "admin").first()
|
||||||
|
if not admin_user:
|
||||||
|
admin_user = AppUser(
|
||||||
|
login="admin",
|
||||||
|
hashed_password=get_password_hash("admin123"),
|
||||||
|
role_id=admin_role.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(admin_user)
|
||||||
|
db.commit()
|
||||||
|
print("✓ Создан администратор: login=admin, password=admin123")
|
||||||
|
print("⚠ ВАЖНО: Измените пароль администратора после первого входа!")
|
||||||
|
else:
|
||||||
|
print("✓ Администратор уже существует")
|
||||||
|
|
||||||
|
print("\n✓ Инициализация базы данных завершена успешно")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"✗ Ошибка при инициализации: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Инициализация базы данных...")
|
||||||
|
init_database()
|
||||||
|
|
||||||
51
docker-compose.yml
Executable file
51
docker-compose.yml
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- MANICTIME_DB_HOST=${MANICTIME_DB_HOST}
|
||||||
|
- MANICTIME_DB_PORT=${MANICTIME_DB_PORT}
|
||||||
|
- MANICTIME_DB_NAME=${MANICTIME_DB_NAME}
|
||||||
|
- MANICTIME_DB_USER=${MANICTIME_DB_USER}
|
||||||
|
- MANICTIME_DB_PASSWORD=${MANICTIME_DB_PASSWORD}
|
||||||
|
- SERVICE_DB_HOST=${SERVICE_DB_HOST}
|
||||||
|
- SERVICE_DB_PORT=${SERVICE_DB_PORT}
|
||||||
|
- SERVICE_DB_NAME=${SERVICE_DB_NAME}
|
||||||
|
- SERVICE_DB_USER=${SERVICE_DB_USER}
|
||||||
|
- SERVICE_DB_PASSWORD=${SERVICE_DB_PASSWORD}
|
||||||
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
- service_db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
service_db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${SERVICE_DB_NAME}
|
||||||
|
- POSTGRES_USER=${SERVICE_DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${SERVICE_DB_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- service_db_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
service_db_data:
|
||||||
|
|
||||||
17
frontend/Dockerfile
Executable file
17
frontend/Dockerfile
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копирование package.json и установка зависимостей
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Копирование исходного кода
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Открытие порта
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Команда запуска
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
13
frontend/index.html
Executable file
13
frontend/index.html
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ManicTime Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
28
frontend/package.json
Executable file
28
frontend/package.json
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "manictime-dashboard-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-big-calendar": "^1.8.5",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"date-fns": "^2.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
82
frontend/src/App.css
Executable file
82
frontend/src/App.css
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
44
frontend/src/App.jsx
Executable file
44
frontend/src/App.jsx
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Summary from './pages/Summary'
|
||||||
|
import Timeline from './pages/Timeline'
|
||||||
|
import Metrics from './pages/Metrics'
|
||||||
|
import LeaveCalendar from './pages/LeaveCalendar'
|
||||||
|
import AdminPanel from './pages/AdminPanel'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="summary" element={<Summary />} />
|
||||||
|
<Route path="timeline" element={<Timeline />} />
|
||||||
|
<Route path="metrics" element={<Metrics />} />
|
||||||
|
<Route path="leave" element={<LeaveCalendar />} />
|
||||||
|
<Route path="admin" element={<AdminPanel />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
61
frontend/src/components/Layout.css
Executable file
61
frontend/src/components/Layout.css
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
65
frontend/src/components/Layout.jsx
Executable file
65
frontend/src/components/Layout.jsx
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import './Layout.css'
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const { logout, user } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const isActive = (path) => location.pathname === path
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="navbar-brand">
|
||||||
|
<h2>ManicTime Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-menu">
|
||||||
|
<Link
|
||||||
|
to="/summary"
|
||||||
|
className={`nav-link ${isActive('/summary') ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Сводка
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/timeline"
|
||||||
|
className={`nav-link ${isActive('/timeline') ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Хронология
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/metrics"
|
||||||
|
className={`nav-link ${isActive('/metrics') ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Метрика
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/leave"
|
||||||
|
className={`nav-link ${isActive('/leave') ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Отпуска/Больничные
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={`nav-link ${isActive('/admin') ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Администрирование
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-actions">
|
||||||
|
<span className="user-info">{user?.login || 'Пользователь'}</span>
|
||||||
|
<button onClick={logout} className="btn btn-secondary">
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="main-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout
|
||||||
|
|
||||||
20
frontend/src/components/ProtectedRoute.jsx
Executable file
20
frontend/src/components/ProtectedRoute.jsx
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Загрузка...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children || <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute
|
||||||
|
|
||||||
66
frontend/src/contexts/AuthContext.jsx
Executable file
66
frontend/src/contexts/AuthContext.jsx
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||||
|
// Можно добавить запрос для получения информации о пользователе
|
||||||
|
} else {
|
||||||
|
delete axios.defaults.headers.common['Authorization']
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const login = async (login, password) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/v1/auth/token', {
|
||||||
|
login,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
const newToken = response.data.access_token
|
||||||
|
setToken(newToken)
|
||||||
|
localStorage.setItem('token', newToken)
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || 'Ошибка входа'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null)
|
||||||
|
setUser(null)
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
delete axios.defaults.headers.common['Authorization']
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
loading,
|
||||||
|
isAuthenticated: !!token
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
24
frontend/src/index.css
Executable file
24
frontend/src/index.css
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/src/main.jsx
Executable file
11
frontend/src/main.jsx
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
106
frontend/src/pages/AdminPanel.css
Executable file
106
frontend/src/pages/AdminPanel.css
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
.admin-panel {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-description {
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item label {
|
||||||
|
width: 150px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
222
frontend/src/pages/AdminPanel.jsx
Executable file
222
frontend/src/pages/AdminPanel.jsx
Executable file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './AdminPanel.css'
|
||||||
|
|
||||||
|
const AdminPanel = () => {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [config, setConfig] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [showUserModal, setShowUserModal] = useState(false)
|
||||||
|
const [newUser, setNewUser] = useState({
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
role_id: 2
|
||||||
|
})
|
||||||
|
const [roles, setRoles] = useState([
|
||||||
|
{ id: 1, name: 'Admin' },
|
||||||
|
{ id: 2, name: 'User' }
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
fetchConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/admin/users')
|
||||||
|
setUsers(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки пользователей')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/admin/config')
|
||||||
|
setConfig(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки конфигурации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
if (!newUser.login || !newUser.password) {
|
||||||
|
setError('Заполните все поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/admin/users', newUser)
|
||||||
|
setSuccess('Пользователь успешно создан')
|
||||||
|
setShowUserModal(false)
|
||||||
|
setNewUser({ login: '', password: '', role_id: 2 })
|
||||||
|
fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка создания пользователя')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateConfig = async (key, value) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await axios.put('/api/v1/admin/config', { key, value })
|
||||||
|
setSuccess('Конфигурация обновлена')
|
||||||
|
fetchConfig()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка обновления конфигурации')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<h1>Панель администратора</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error card" onClick={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="success card" onClick={() => setSuccess('')}>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Пользователи</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowUserModal(true)}
|
||||||
|
>
|
||||||
|
Создать пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.id}</td>
|
||||||
|
<td>{user.login}</td>
|
||||||
|
<td>{user.role_name}</td>
|
||||||
|
<td>{user.is_active ? 'Активен' : 'Неактивен'}</td>
|
||||||
|
<td>
|
||||||
|
{new Date(user.created_at).toLocaleDateString('ru-RU')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Конфигурация подключения к ManicTime</h2>
|
||||||
|
<p className="config-description">
|
||||||
|
Здесь можно изменить нечувствительные параметры подключения к базе
|
||||||
|
данных ManicTime. Пароль и пользователь настраиваются только через
|
||||||
|
переменные окружения на сервере.
|
||||||
|
</p>
|
||||||
|
<div className="config-list">
|
||||||
|
{config.map((item) => (
|
||||||
|
<div key={item.key} className="config-item">
|
||||||
|
<label>{item.key.replace('manictime_', '').toUpperCase()}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.value || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateConfig(item.key, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Не задано"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showUserModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowUserModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Создать пользователя</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Логин</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUser.login}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUser({ ...newUser, login: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUser({ ...newUser, password: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Роль</label>
|
||||||
|
<select
|
||||||
|
value={newUser.role_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUser({
|
||||||
|
...newUser,
|
||||||
|
role_id: parseInt(e.target.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowUserModal(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Создание...' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminPanel
|
||||||
|
|
||||||
41
frontend/src/pages/Dashboard.css
Executable file
41
frontend/src/pages/Dashboard.css
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
.dashboard {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard h1 {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card h3 {
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
32
frontend/src/pages/Dashboard.jsx
Executable file
32
frontend/src/pages/Dashboard.jsx
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import './Dashboard.css'
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<h1>Главная панель</h1>
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<Link to="/summary" className="dashboard-card">
|
||||||
|
<h3>Сводка</h3>
|
||||||
|
<p>Агрегированная гистограмма активности по категориям</p>
|
||||||
|
</Link>
|
||||||
|
<Link to="/timeline" className="dashboard-card">
|
||||||
|
<h3>Хронология</h3>
|
||||||
|
<p>Индивидуальные линейки активности пользователей</p>
|
||||||
|
</Link>
|
||||||
|
<Link to="/metrics" className="dashboard-card">
|
||||||
|
<h3>Метрика</h3>
|
||||||
|
<p>Числовые показатели эффективности в динамике</p>
|
||||||
|
</Link>
|
||||||
|
<Link to="/leave" className="dashboard-card">
|
||||||
|
<h3>Отпуска/Больничные</h3>
|
||||||
|
<p>Календарь отсутствий сотрудников</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
|
|
||||||
44
frontend/src/pages/LeaveCalendar.css
Executable file
44
frontend/src/pages/LeaveCalendar.css
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
.leave-calendar {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leave-calendar h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
231
frontend/src/pages/LeaveCalendar.jsx
Executable file
231
frontend/src/pages/LeaveCalendar.jsx
Executable file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Calendar, momentLocalizer } from 'react-big-calendar'
|
||||||
|
import moment from 'moment'
|
||||||
|
import 'react-big-calendar/lib/css/react-big-calendar.css'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './LeaveCalendar.css'
|
||||||
|
|
||||||
|
const localizer = momentLocalizer(moment)
|
||||||
|
|
||||||
|
const LeaveCalendar = () => {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [selectedRange, setSelectedRange] = useState(null)
|
||||||
|
const [newEvent, setNewEvent] = useState({
|
||||||
|
user_id: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
leave_type: 'Отпуск'
|
||||||
|
})
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentDate = new Date()
|
||||||
|
const startDate = new Date(currentDate.getFullYear(), 0, 1)
|
||||||
|
const endDate = new Date(currentDate.getFullYear(), 11, 31)
|
||||||
|
fetchEvents(startDate, endDate)
|
||||||
|
fetchUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchEvents = async (startDate, endDate) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/leave/events', {
|
||||||
|
params: {
|
||||||
|
start_date: startDate.toISOString().split('T')[0],
|
||||||
|
end_date: endDate.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const formattedEvents = response.data.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: `${event.user_login || `User ${event.user_id}`} - ${event.leave_type}`,
|
||||||
|
start: new Date(event.start_date),
|
||||||
|
end: new Date(event.end_date),
|
||||||
|
resource: event
|
||||||
|
}))
|
||||||
|
setEvents(formattedEvents)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/admin/users')
|
||||||
|
setUsers(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки пользователей:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectSlot = ({ start, end }) => {
|
||||||
|
setSelectedRange({ start, end })
|
||||||
|
setNewEvent({
|
||||||
|
user_id: '',
|
||||||
|
start_date: start.toISOString().split('T')[0],
|
||||||
|
end_date: end.toISOString().split('T')[0],
|
||||||
|
leave_type: 'Отпуск'
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateEvent = async () => {
|
||||||
|
if (!newEvent.user_id || !newEvent.start_date || !newEvent.end_date) {
|
||||||
|
setError('Заполните все поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/leave/events', {
|
||||||
|
user_id: parseInt(newEvent.user_id),
|
||||||
|
start_date: newEvent.start_date,
|
||||||
|
end_date: newEvent.end_date,
|
||||||
|
leave_type: newEvent.leave_type
|
||||||
|
})
|
||||||
|
setShowModal(false)
|
||||||
|
const startDate = new Date(newEvent.start_date)
|
||||||
|
const endDate = new Date(newEvent.end_date)
|
||||||
|
fetchEvents(
|
||||||
|
new Date(startDate.getFullYear(), 0, 1),
|
||||||
|
new Date(endDate.getFullYear(), 11, 31)
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка создания события')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventStyleGetter = (event) => {
|
||||||
|
const leaveType = event.resource?.leave_type
|
||||||
|
let backgroundColor = '#3174ad'
|
||||||
|
if (leaveType === 'Больничный') {
|
||||||
|
backgroundColor = '#dc3545'
|
||||||
|
} else if (leaveType === 'Отпуск') {
|
||||||
|
backgroundColor = '#28a745'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
backgroundColor,
|
||||||
|
borderRadius: '5px',
|
||||||
|
opacity: 0.8,
|
||||||
|
color: 'white',
|
||||||
|
border: '0px',
|
||||||
|
display: 'block'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="leave-calendar">
|
||||||
|
<h1>Отпуска и больничные</h1>
|
||||||
|
|
||||||
|
{error && <div className="error card">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ height: '600px' }}>
|
||||||
|
<Calendar
|
||||||
|
localizer={localizer}
|
||||||
|
events={events}
|
||||||
|
startAccessor="start"
|
||||||
|
endAccessor="end"
|
||||||
|
onSelectSlot={handleSelectSlot}
|
||||||
|
selectable
|
||||||
|
eventPropGetter={eventStyleGetter}
|
||||||
|
culture="ru"
|
||||||
|
messages={{
|
||||||
|
next: 'Вперед',
|
||||||
|
previous: 'Назад',
|
||||||
|
today: 'Сегодня',
|
||||||
|
month: 'Месяц',
|
||||||
|
week: 'Неделя',
|
||||||
|
day: 'День',
|
||||||
|
agenda: 'Повестка дня',
|
||||||
|
date: 'Дата',
|
||||||
|
time: 'Время',
|
||||||
|
event: 'Событие',
|
||||||
|
noEventsInRange: 'Нет событий в выбранном диапазоне'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Добавить событие</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Пользователь</label>
|
||||||
|
<select
|
||||||
|
value={newEvent.user_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewEvent({ ...newEvent, user_id: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите пользователя</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.login}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Начальная дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newEvent.start_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewEvent({ ...newEvent, start_date: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Конечная дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newEvent.end_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewEvent({ ...newEvent, end_date: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Тип</label>
|
||||||
|
<select
|
||||||
|
value={newEvent.leave_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewEvent({ ...newEvent, leave_type: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="Отпуск">Отпуск</option>
|
||||||
|
<option value="Больничный">Больничный</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleCreateEvent}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeaveCalendar
|
||||||
|
|
||||||
31
frontend/src/pages/Login.css
Executable file
31
frontend/src/pages/Login.css
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h2 {
|
||||||
|
text-align: center;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
72
frontend/src/pages/Login.jsx
Executable file
72
frontend/src/pages/Login.jsx
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import './Login.css'
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [login, setLogin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { login: authLogin } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const result = await authLogin(login, password)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-container">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>ManicTime Dashboard</h1>
|
||||||
|
<h2>Вход в систему</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="login">Логин</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="login"
|
||||||
|
value={login}
|
||||||
|
onChange={(e) => setLogin(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', marginTop: '10px' }}
|
||||||
|
>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
|
|
||||||
52
frontend/src/pages/Metrics.css
Executable file
52
frontend/src/pages/Metrics.css
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
.metrics {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-controls .form-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table th,
|
||||||
|
.metrics-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
142
frontend/src/pages/Metrics.jsx
Executable file
142
frontend/src/pages/Metrics.jsx
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './Metrics.css'
|
||||||
|
|
||||||
|
const Metrics = () => {
|
||||||
|
const [period, setPeriod] = useState('quarter')
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [period, year])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/metrics/aggregate', {
|
||||||
|
params: { period, year }
|
||||||
|
})
|
||||||
|
setData(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPeriodLabel = (key) => {
|
||||||
|
if (period === 'week') {
|
||||||
|
return `Неделя ${key.replace('W', '')}`
|
||||||
|
} else if (period === 'month') {
|
||||||
|
const months = [
|
||||||
|
'Январь',
|
||||||
|
'Февраль',
|
||||||
|
'Март',
|
||||||
|
'Апрель',
|
||||||
|
'Май',
|
||||||
|
'Июнь',
|
||||||
|
'Июль',
|
||||||
|
'Август',
|
||||||
|
'Сентябрь',
|
||||||
|
'Октябрь',
|
||||||
|
'Ноябрь',
|
||||||
|
'Декабрь'
|
||||||
|
]
|
||||||
|
return months[parseInt(key.replace('M', '')) - 1]
|
||||||
|
} else if (period === 'quarter') {
|
||||||
|
return `Квартал ${key.replace('Q', '')}`
|
||||||
|
} else {
|
||||||
|
return 'Год'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllPeriodKeys = () => {
|
||||||
|
if (!data || !data.data || data.data.length === 0) return []
|
||||||
|
const allKeys = new Set()
|
||||||
|
data.data.forEach((row) => {
|
||||||
|
Object.keys(row.data).forEach((key) => allKeys.add(key))
|
||||||
|
})
|
||||||
|
return Array.from(allKeys).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="metrics">
|
||||||
|
<h1>Метрики эффективности</h1>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
fetchData()
|
||||||
|
}}
|
||||||
|
className="metrics-controls"
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Период</label>
|
||||||
|
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
|
||||||
|
<option value="week">Неделя</option>
|
||||||
|
<option value="month">Месяц</option>
|
||||||
|
<option value="quarter">Квартал</option>
|
||||||
|
<option value="year">Год</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Год</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Загрузка...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error card">{error}</div>}
|
||||||
|
|
||||||
|
{data && !loading && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>
|
||||||
|
Агрегированные метрики за {year} год ({period === 'week' ? 'по неделям' : period === 'month' ? 'по месяцам' : period === 'quarter' ? 'по кварталам' : 'за год'})
|
||||||
|
</h2>
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="metrics-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
{getAllPeriodKeys().map((key) => (
|
||||||
|
<th key={key}>{getPeriodLabel(key)}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.data.map((row, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="user-name">{row.user}</td>
|
||||||
|
{getAllPeriodKeys().map((key) => (
|
||||||
|
<td key={key}>
|
||||||
|
{row.data[key] ? `${row.data[key]} ч` : '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Metrics
|
||||||
|
|
||||||
21
frontend/src/pages/Summary.css
Executable file
21
frontend/src/pages/Summary.css
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
.summary {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-form .form-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
142
frontend/src/pages/Summary.jsx
Executable file
142
frontend/src/pages/Summary.jsx
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Bar } from 'react-chartjs-2'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
} from 'chart.js'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './Summary.css'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
)
|
||||||
|
|
||||||
|
const Summary = () => {
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useState(
|
||||||
|
new Date().toISOString().split('T')[0]
|
||||||
|
)
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/summary/histogram', {
|
||||||
|
params: { start_date: startDate, end_date: endDate }
|
||||||
|
})
|
||||||
|
setData(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data
|
||||||
|
? {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: data.datasets.map((dataset) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
data: dataset.data.map((seconds) => seconds / 3600), // Конвертация в часы
|
||||||
|
backgroundColor: dataset.color,
|
||||||
|
borderColor: dataset.color,
|
||||||
|
borderWidth: 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Активность по дням (часы)'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)} ч`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Часы'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="summary">
|
||||||
|
<h1>Сводка активности</h1>
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleSubmit} className="date-range-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Начальная дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Конечная дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Загрузка...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error card">{error}</div>}
|
||||||
|
|
||||||
|
{data && !loading && (
|
||||||
|
<div className="card">
|
||||||
|
<Bar data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Summary
|
||||||
|
|
||||||
92
frontend/src/pages/Timeline.css
Executable file
92
frontend/src/pages/Timeline.css
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
.timeline {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-label {
|
||||||
|
width: 200px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-right: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
flex: 1;
|
||||||
|
height: 30px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-segment {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-segment:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
236
frontend/src/pages/Timeline.jsx
Executable file
236
frontend/src/pages/Timeline.jsx
Executable file
@@ -0,0 +1,236 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import './Timeline.css'
|
||||||
|
|
||||||
|
const Timeline = () => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(
|
||||||
|
new Date().toISOString().split('T')[0]
|
||||||
|
)
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState([])
|
||||||
|
const [activities, setActivities] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedUserIds.length > 0) {
|
||||||
|
fetchActivities()
|
||||||
|
} else {
|
||||||
|
setActivities([])
|
||||||
|
}
|
||||||
|
}, [selectedDate, selectedUserIds])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
// В реальном приложении нужно получить список пользователей из ManicTime
|
||||||
|
// Пока используем заглушку
|
||||||
|
const response = await axios.get('/api/v1/timeline/user-activity', {
|
||||||
|
params: { date: selectedDate, user_ids: [1] }
|
||||||
|
})
|
||||||
|
// Получаем пользователей из ответа
|
||||||
|
if (response.data.activities) {
|
||||||
|
const uniqueUsers = response.data.activities.map((a) => ({
|
||||||
|
id: a.user_id,
|
||||||
|
name: a.display_name
|
||||||
|
}))
|
||||||
|
setUsers(uniqueUsers)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки пользователей:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/timeline/user-activity', {
|
||||||
|
params: {
|
||||||
|
date: selectedDate,
|
||||||
|
user_ids: selectedUserIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setActivities(response.data.activities || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUser = (userId) => {
|
||||||
|
setSelectedUserIds((prev) =>
|
||||||
|
prev.includes(userId)
|
||||||
|
? prev.filter((id) => id !== userId)
|
||||||
|
: [...prev, userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSegmentColor = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Active':
|
||||||
|
return '#28a745'
|
||||||
|
case 'Away':
|
||||||
|
return '#dc3545'
|
||||||
|
case 'Session Locked':
|
||||||
|
case 'Power Off':
|
||||||
|
return '#ffc107'
|
||||||
|
case 'Productive':
|
||||||
|
return '#fd7e14'
|
||||||
|
default:
|
||||||
|
return '#6c757d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (isoString) => {
|
||||||
|
return new Date(isoString).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculatePosition = (start, dayStart, dayEnd) => {
|
||||||
|
const startTime = new Date(start).getTime()
|
||||||
|
const dayStartTime = new Date(dayStart).getTime()
|
||||||
|
const dayEndTime = new Date(dayEnd).getTime()
|
||||||
|
const totalDuration = dayEndTime - dayStartTime
|
||||||
|
const position = ((startTime - dayStartTime) / totalDuration) * 100
|
||||||
|
return Math.max(0, Math.min(100, position))
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateWidth = (start, end, dayStart, dayEnd) => {
|
||||||
|
const startTime = new Date(start).getTime()
|
||||||
|
const endTime = new Date(end).getTime()
|
||||||
|
const dayStartTime = new Date(dayStart).getTime()
|
||||||
|
const dayEndTime = new Date(dayEnd).getTime()
|
||||||
|
const totalDuration = dayEndTime - dayStartTime
|
||||||
|
const segmentDuration = endTime - startTime
|
||||||
|
const width = (segmentDuration / totalDuration) * 100
|
||||||
|
return Math.max(1, Math.min(100, width))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayStart = new Date(`${selectedDate}T00:00:00`)
|
||||||
|
const dayEnd = new Date(`${selectedDate}T23:59:59`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="timeline">
|
||||||
|
<h1>Хронология активности</h1>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Пользователи</label>
|
||||||
|
<div className="user-checklist">
|
||||||
|
{users.map((user) => (
|
||||||
|
<label key={user.id} className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUserIds.includes(user.id)}
|
||||||
|
onChange={() => toggleUser(user.id)}
|
||||||
|
/>
|
||||||
|
{user.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<p className="text-muted">
|
||||||
|
Выберите дату и загрузите данные для отображения пользователей
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error card">{error}</div>}
|
||||||
|
|
||||||
|
{loading && <div className="card">Загрузка...</div>}
|
||||||
|
|
||||||
|
{activities.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Активность за {selectedDate}</h2>
|
||||||
|
<div className="timeline-container">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<div key={activity.user_id} className="timeline-row">
|
||||||
|
<div className="timeline-label">{activity.display_name}</div>
|
||||||
|
<div className="timeline-ruler">
|
||||||
|
{activity.segments.map((segment, idx) => {
|
||||||
|
const left = calculatePosition(
|
||||||
|
segment.start,
|
||||||
|
dayStart.toISOString(),
|
||||||
|
dayEnd.toISOString()
|
||||||
|
)
|
||||||
|
const width = calculateWidth(
|
||||||
|
segment.start,
|
||||||
|
segment.end,
|
||||||
|
dayStart.toISOString(),
|
||||||
|
dayEnd.toISOString()
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="timeline-segment"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
backgroundColor: getSegmentColor(segment.type),
|
||||||
|
title: `${segment.type}: ${formatTime(
|
||||||
|
segment.start
|
||||||
|
)} - ${formatTime(segment.end)}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="timeline-legend">
|
||||||
|
<div className="legend-item">
|
||||||
|
<span
|
||||||
|
className="legend-color"
|
||||||
|
style={{ backgroundColor: '#28a745' }}
|
||||||
|
/>
|
||||||
|
Активный
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<span
|
||||||
|
className="legend-color"
|
||||||
|
style={{ backgroundColor: '#dc3545' }}
|
||||||
|
/>
|
||||||
|
Неактивный
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<span
|
||||||
|
className="legend-color"
|
||||||
|
style={{ backgroundColor: '#ffc107' }}
|
||||||
|
/>
|
||||||
|
Не у ПК
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<span
|
||||||
|
className="legend-color"
|
||||||
|
style={{ backgroundColor: '#fd7e14' }}
|
||||||
|
/>
|
||||||
|
Продуктивность
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timeline
|
||||||
|
|
||||||
16
frontend/vite.config.js
Executable file
16
frontend/vite.config.js
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user