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