Initial commit
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
|
||||
# API Configuration
|
||||
API_KEY_SALT=generate_random_salt_with_openssl
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
|
||||
# Frontend
|
||||
API_URL=http://localhost:3000/api/v1
|
||||
|
||||
# Production settings (uncomment for production)
|
||||
# NODE_ENV=production
|
||||
# CORS_ORIGIN=https://admin.yourdomain.com
|
||||
# API_URL=https://api.yourdomain.com/api/v1
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tgz
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# SSL certificates
|
||||
nginx/ssl/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
.cache/
|
||||
186
PROJECT_STRUCTURE.md
Normal file
186
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Структура проекта Polotsk Transit API
|
||||
|
||||
## 📁 Обзор директорий
|
||||
|
||||
```
|
||||
polotsk-transit-api/
|
||||
├── backend/ # Node.js API сервер
|
||||
│ ├── src/
|
||||
│ │ ├── config/ # Конфигурация (DB, Redis, Logger)
|
||||
│ │ ├── controllers/ # Бизнес-логика
|
||||
│ │ │ ├── routesController.js # Управление маршрутами
|
||||
│ │ │ ├── stopsController.js # Управление остановками + PostGIS
|
||||
│ │ │ ├── etaController.js # Расчет ETA
|
||||
│ │ │ └── syncController.js # Offline синхронизация
|
||||
│ │ ├── middleware/ # Middleware (Auth, Rate Limiting)
|
||||
│ │ ├── routes/ # Express маршруты
|
||||
│ │ └── index.js # Точка входа
|
||||
│ ├── migrations/ # SQL миграции (PostGIS схема)
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── frontend/ # React админ-панель
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React компоненты
|
||||
│ │ │ ├── RouteForm.jsx # Форма маршрута
|
||||
│ │ │ └── StopForm.jsx # Форма остановки
|
||||
│ │ ├── pages/ # Страницы
|
||||
│ │ │ ├── Dashboard.jsx # Главная страница
|
||||
│ │ │ ├── RoutesPage.jsx # Управление маршрутами
|
||||
│ │ │ ├── StopsPage.jsx # Остановки + карта Leaflet
|
||||
│ │ │ └── SchedulesPage.jsx # Расписания (заглушка)
|
||||
│ │ ├── services/ # API клиент
|
||||
│ │ ├── App.jsx # Главный компонент + роутинг
|
||||
│ │ ├── main.jsx # Точка входа
|
||||
│ │ └── index.css # Tailwind стили
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── nginx/ # Reverse proxy конфигурация
|
||||
│ └── nginx.conf # Nginx для API + Admin + SSL
|
||||
│
|
||||
├── docker-compose.yml # Оркестрация всех сервисов
|
||||
├── .env.example # Шаблон переменных окружения
|
||||
├── .gitignore
|
||||
├── README.md # Полная документация
|
||||
└── QUICKSTART.md # Быстрый старт
|
||||
|
||||
```
|
||||
|
||||
## 🎯 Ключевые файлы
|
||||
|
||||
### Backend
|
||||
|
||||
**Конфигурация:**
|
||||
- `backend/src/config/database.js` - PostgreSQL connection pool
|
||||
- `backend/src/config/redis.js` - Redis клиент для кеширования
|
||||
- `backend/src/config/logger.js` - Winston logger
|
||||
|
||||
**Контроллеры:**
|
||||
- `backend/src/controllers/routesController.js` - CRUD маршрутов + кеширование
|
||||
- `backend/src/controllers/stopsController.js` - CRUD остановок + PostGIS геопоиск
|
||||
- `backend/src/controllers/etaController.js` - Умный расчет времени прибытия
|
||||
- `backend/src/controllers/syncController.js` - Полная и инкрементальная синхронизация
|
||||
|
||||
**Middleware:**
|
||||
- `backend/src/middleware/auth.js` - API key аутентификация
|
||||
- `backend/src/middleware/rateLimiter.js` - Rate limiting с Redis
|
||||
|
||||
**Миграции:**
|
||||
- `backend/migrations/001_init.sql` - Полная схема БД с PostGIS
|
||||
|
||||
### Frontend
|
||||
|
||||
**Страницы:**
|
||||
- `frontend/src/pages/Dashboard.jsx` - Статистика и быстрый старт
|
||||
- `frontend/src/pages/RoutesPage.jsx` - Управление маршрутами с карточками
|
||||
- `frontend/src/pages/StopsPage.jsx` - Карта Leaflet + список остановок
|
||||
- `frontend/src/pages/SchedulesPage.jsx` - Заглушка для будущего функционала
|
||||
|
||||
**Компоненты:**
|
||||
- `frontend/src/components/RouteForm.jsx` - Форма создания/редактирования маршрута
|
||||
- `frontend/src/components/StopForm.jsx` - Форма создания/редактирования остановки
|
||||
|
||||
**API сервис:**
|
||||
- `frontend/src/services/api.js` - Axios клиент со всеми endpoints
|
||||
|
||||
### Инфраструктура
|
||||
|
||||
**Docker:**
|
||||
- `docker-compose.yml` - 5 сервисов (PostgreSQL, Redis, Backend, Frontend, Nginx)
|
||||
- `backend/Dockerfile` - Node.js production образ
|
||||
- `frontend/Dockerfile` - Multi-stage build с Nginx
|
||||
|
||||
**Nginx:**
|
||||
- `nginx/nginx.conf` - Reverse proxy с SSL, rate limiting, безопасностью
|
||||
|
||||
## 🔧 Основные технологии
|
||||
|
||||
| Компонент | Технология | Назначение |
|
||||
|-----------|------------|------------|
|
||||
| API Server | Express.js | REST API |
|
||||
| База данных | PostgreSQL + PostGIS | Хранение данных + геопространственные запросы |
|
||||
| Кеш | Redis | Кеширование + pub/sub для WebSocket |
|
||||
| Frontend | React + Vite | Админ-панель |
|
||||
| Карты | React Leaflet | Интерактивная карта |
|
||||
| Стили | Tailwind CSS | Быстрая разработка UI |
|
||||
| Контейнеризация | Docker Compose | Оркестрация сервисов |
|
||||
| Reverse Proxy | Nginx | SSL, rate limiting, безопасность |
|
||||
|
||||
## 📊 Схема базы данных (кратко)
|
||||
|
||||
```sql
|
||||
routes -- Маршруты (номер, название, тип, цвет)
|
||||
stops -- Остановки (название, GEOGRAPHY(Point), адрес)
|
||||
route_stops -- М:М связь маршрутов и остановок (с sequence и time_offset)
|
||||
schedules -- Расписания (departure_times[], day_type)
|
||||
vehicles -- Транспортные средства
|
||||
historical_delays -- Статистика задержек для умного ETA
|
||||
alerts -- Уведомления и алерты
|
||||
api_keys -- API ключи для аутентификации
|
||||
sync_log -- Лог изменений для offline синхронизации
|
||||
```
|
||||
|
||||
## 🚀 Что уже работает
|
||||
|
||||
✅ Полный REST API
|
||||
✅ CRUD для маршрутов и остановок
|
||||
✅ PostGIS геопространственные запросы (ближайшие остановки)
|
||||
✅ Расчет ETA на основе расписания + исторических задержек
|
||||
✅ Offline синхронизация (полная и инкрементальная)
|
||||
✅ Веб админ-панель с картой
|
||||
✅ Docker deployment
|
||||
✅ API key аутентификация
|
||||
✅ Rate limiting
|
||||
✅ Кеширование через Redis
|
||||
✅ Логирование
|
||||
|
||||
## 📝 Что добавить в будущем
|
||||
|
||||
**Версия 2.0 (Краудсорсинг):**
|
||||
- [ ] WebSocket для реалтайм GPS
|
||||
- [ ] Endpoint для приема GPS от пользователей
|
||||
- [ ] Агрегация и фильтрация GPS данных
|
||||
- [ ] Функция "Я еду в маршруте"
|
||||
|
||||
**Версия 3.0 (Продвинутые функции):**
|
||||
- [ ] IoT GPS-трекеры
|
||||
- [ ] ML для предсказания задержек
|
||||
- [ ] Push уведомления
|
||||
- [ ] Детальная аналитика
|
||||
|
||||
## 💡 Советы по развертыванию
|
||||
|
||||
1. **Обязательно смените пароли** в `.env` перед продакшеном
|
||||
2. **Используйте Cloudflare Tunnel** для скрытия домашнего IP
|
||||
3. **Настройте SSL** через Let's Encrypt или Cloudflare
|
||||
4. **Включите fail2ban** для защиты от брутфорса
|
||||
5. **Настройте бэкапы** PostgreSQL (pg_dump + cron)
|
||||
6. **Мониторинг** через Docker stats или Prometheus
|
||||
|
||||
## 🎓 Интеграция с мобильными приложениями
|
||||
|
||||
Используйте эндпоинт `/api/v1/sync` для offline-first подхода:
|
||||
|
||||
1. При первом запуске - полная синхронизация (без `lastSync`)
|
||||
2. При последующих - инкрементальная (с `lastSync=timestamp`)
|
||||
3. Храните данные локально (SQLite, Realm, Core Data)
|
||||
4. Обновляйте в фоне каждые 5-10 минут
|
||||
|
||||
Пример для iOS:
|
||||
```swift
|
||||
func syncData() async {
|
||||
let lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date
|
||||
let params = lastSync != nil ? ["lastSync": ISO8601DateFormatter().string(from: lastSync!)] : [:]
|
||||
|
||||
let data = try await api.get("/sync", params: params)
|
||||
await database.updateLocalData(data)
|
||||
|
||||
UserDefaults.standard.set(Date(), forKey: "lastSync")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Создано для Полоцка 🇧🇾 | MIT License**
|
||||
224
QUICKSTART.md
Normal file
224
QUICKSTART.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 🚀 Быстрый старт
|
||||
|
||||
## За 5 минут к рабочему API
|
||||
|
||||
### Шаг 1: Подготовка
|
||||
```bash
|
||||
# Убедитесь, что установлен Docker
|
||||
docker --version
|
||||
docker-compose --version
|
||||
|
||||
# Если нет - установите:
|
||||
# Ubuntu/Debian:
|
||||
sudo apt update
|
||||
sudo apt install docker.io docker-compose
|
||||
|
||||
# Добавьте пользователя в группу docker
|
||||
sudo usermod -aG docker $USER
|
||||
# Перелогиньтесь после этого
|
||||
```
|
||||
|
||||
### Шаг 2: Настройка
|
||||
```bash
|
||||
# Создайте .env файл
|
||||
cp .env.example .env
|
||||
|
||||
# Сгенерируйте безопасный пароль
|
||||
openssl rand -base64 32
|
||||
|
||||
# Отредактируйте .env и вставьте сгенерированный пароль в DB_PASSWORD
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Шаг 3: Запуск
|
||||
```bash
|
||||
# Запустите все сервисы
|
||||
docker-compose up -d
|
||||
|
||||
# Дождитесь инициализации (30-60 секунд)
|
||||
docker-compose logs -f postgres
|
||||
|
||||
# Когда увидите "database system is ready to accept connections" - нажмите Ctrl+C
|
||||
```
|
||||
|
||||
### Шаг 4: Проверка
|
||||
```bash
|
||||
# Проверьте API
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status":"healthy","timestamp":"2025-11-19T...","version":"1.0.0"}
|
||||
|
||||
# Проверьте базу данных
|
||||
curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/sync/status
|
||||
```
|
||||
|
||||
### Шаг 5: Откройте админ-панель
|
||||
Откройте в браузере: http://localhost:3001
|
||||
|
||||
**Готово! 🎉**
|
||||
|
||||
---
|
||||
|
||||
## Первые данные
|
||||
|
||||
### 1. Создайте первый маршрут
|
||||
Откройте админ-панель → Маршруты → "Добавить маршрут"
|
||||
|
||||
Или через API:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/routes \
|
||||
-H "X-API-Key: dev_key_12345678" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"route_number": "1",
|
||||
"name": "Центр - Вокзал",
|
||||
"type": "bus",
|
||||
"color": "#FF6B6B"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Добавьте остановки
|
||||
Откройте админ-панель → Остановки → Кликните на карту
|
||||
|
||||
Или через API:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/stops \
|
||||
-H "X-API-Key: dev_key_12345678" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Площадь Свободы",
|
||||
"latitude": 55.4869,
|
||||
"longitude": 28.7856,
|
||||
"address": "Центр города"
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Проверьте геопоиск
|
||||
```bash
|
||||
# Найдите остановки в радиусе 500м от центра Полоцка
|
||||
curl -H "X-API-Key: dev_key_12345678" \
|
||||
"http://localhost:3000/api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Доступ из локальной сети
|
||||
|
||||
### Вариант 1: Прямой доступ (не безопасно для интернета)
|
||||
```bash
|
||||
# Узнайте IP вашего сервера
|
||||
ip addr show | grep "inet "
|
||||
|
||||
# Доступ с других устройств в той же сети:
|
||||
# http://YOUR_SERVER_IP:3000 - API
|
||||
# http://YOUR_SERVER_IP:3001 - Admin
|
||||
```
|
||||
|
||||
### Вариант 2: Cloudflare Tunnel (рекомендуется для интернета)
|
||||
```bash
|
||||
# Установите cloudflared
|
||||
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
|
||||
sudo mv cloudflared /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/cloudflared
|
||||
|
||||
# Авторизуйтесь
|
||||
cloudflared tunnel login
|
||||
|
||||
# Создайте туннель
|
||||
cloudflared tunnel create polotsk-transit
|
||||
|
||||
# Создайте конфиг
|
||||
cat > ~/.cloudflared/config.yml << EOF
|
||||
tunnel: YOUR_TUNNEL_ID
|
||||
credentials-file: /home/$USER/.cloudflared/YOUR_TUNNEL_ID.json
|
||||
|
||||
ingress:
|
||||
- hostname: api.yourdomain.com
|
||||
service: http://localhost:3000
|
||||
- hostname: admin.yourdomain.com
|
||||
service: http://localhost:3001
|
||||
- service: http_status:404
|
||||
EOF
|
||||
|
||||
# Запустите туннель
|
||||
cloudflared tunnel run polotsk-transit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Создание API ключа для вашего приложения
|
||||
|
||||
```bash
|
||||
# Войдите в PostgreSQL
|
||||
docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit
|
||||
|
||||
# Создайте новый API ключ
|
||||
INSERT INTO api_keys (key, app_name, rate_limit, is_active)
|
||||
VALUES ('YOUR_GENERATED_KEY_HERE', 'My iOS App', 10000, true);
|
||||
|
||||
# Проверьте
|
||||
SELECT * FROM api_keys;
|
||||
|
||||
# Выйдите
|
||||
\q
|
||||
```
|
||||
|
||||
Генерация безопасного ключа:
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Остановка и удаление
|
||||
|
||||
```bash
|
||||
# Остановить все сервисы
|
||||
docker-compose down
|
||||
|
||||
# Удалить все данные (ОСТОРОЖНО!)
|
||||
docker-compose down -v
|
||||
|
||||
# Удалить образы
|
||||
docker-compose down --rmi all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
**"Cannot connect to database":**
|
||||
```bash
|
||||
# Проверьте, что PostgreSQL запущен
|
||||
docker-compose ps
|
||||
docker-compose logs postgres
|
||||
|
||||
# Перезапустите
|
||||
docker-compose restart postgres
|
||||
```
|
||||
|
||||
**"Port already in use":**
|
||||
```bash
|
||||
# Измените порты в docker-compose.yml
|
||||
# Например, вместо "3000:3000" используйте "3001:3000"
|
||||
```
|
||||
|
||||
**"Permission denied":**
|
||||
```bash
|
||||
# Убедитесь, что пользователь в группе docker
|
||||
sudo usermod -aG docker $USER
|
||||
# Перелогиньтесь
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Прочитайте полный README.md
|
||||
2. Добавьте свои маршруты и остановки
|
||||
3. Настройте расписания (в следующей версии)
|
||||
4. Интегрируйте API в ваше мобильное приложение
|
||||
5. Настройте безопасный доступ через Cloudflare
|
||||
|
||||
**Удачи! 🚀**
|
||||
345
README.md
Normal file
345
README.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Polotsk Transit API
|
||||
|
||||
Система управления информацией об общественном транспорте города Полоцка с REST API, веб-панелью администратора и поддержкой offline-синхронизации.
|
||||
|
||||
## 🚀 Возможности
|
||||
|
||||
### MVP (Версия 1.0 - статические данные)
|
||||
- ✅ REST API для маршрутов и остановок
|
||||
- ✅ Геопространственные запросы (PostGIS)
|
||||
- ✅ Расчет ETA на основе расписания
|
||||
- ✅ Offline-синхронизация для мобильных приложений
|
||||
- ✅ Веб-панель администратора
|
||||
- ✅ Кеширование через Redis
|
||||
- ✅ Аутентификация по API ключам
|
||||
- ✅ Rate limiting
|
||||
|
||||
### Технологический стек
|
||||
|
||||
**Backend:**
|
||||
- Node.js + Express
|
||||
- PostgreSQL + PostGIS
|
||||
- Redis
|
||||
- Docker
|
||||
|
||||
**Frontend (Admin Panel):**
|
||||
- React + Vite
|
||||
- Tailwind CSS
|
||||
- React Leaflet (карты)
|
||||
- Axios
|
||||
|
||||
## 📋 Требования
|
||||
|
||||
- Docker и Docker Compose
|
||||
- Node.js 18+ (для разработки без Docker)
|
||||
- Статический IP или динамический DNS
|
||||
- ~2GB свободного места на диске
|
||||
|
||||
## 🛠 Установка и запуск
|
||||
|
||||
### 1. Клонирование и настройка
|
||||
|
||||
```bash
|
||||
# Создайте .env файл из примера
|
||||
cp .env.example .env
|
||||
|
||||
# Настройте переменные окружения
|
||||
nano .env
|
||||
```
|
||||
|
||||
Важные переменные в `.env`:
|
||||
```env
|
||||
# Обязательно измените в продакшене!
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
API_KEY_SALT=generate_random_salt_with_openssl
|
||||
|
||||
# Для разработки оставьте как есть
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
API_URL=http://localhost:3000/api/v1
|
||||
```
|
||||
|
||||
Генерация безопасного salt:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Запуск с Docker (рекомендуется)
|
||||
|
||||
```bash
|
||||
# Сборка и запуск всех сервисов
|
||||
docker-compose up -d
|
||||
|
||||
# Проверка статуса
|
||||
docker-compose ps
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
Сервисы будут доступны:
|
||||
- **API**: http://localhost:3000
|
||||
- **Admin Panel**: http://localhost:3001
|
||||
- **PostgreSQL**: localhost:5432
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
### 3. Проверка работоспособности
|
||||
|
||||
```bash
|
||||
# Проверка API
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# Проверка базы данных
|
||||
docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit -c "SELECT COUNT(*) FROM routes;"
|
||||
```
|
||||
|
||||
### 4. Разработка без Docker
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
|
||||
# Убедитесь, что PostgreSQL и Redis запущены локально
|
||||
# Настройте DATABASE_URL и REDIS_URL в .env
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📱 Использование API
|
||||
|
||||
### Аутентификация
|
||||
|
||||
Все запросы требуют API ключ в заголовке:
|
||||
```bash
|
||||
curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/routes
|
||||
```
|
||||
|
||||
### Основные эндпоинты
|
||||
|
||||
**Маршруты:**
|
||||
```bash
|
||||
GET /api/v1/routes # Все маршруты
|
||||
GET /api/v1/routes/:id # Маршрут с остановками
|
||||
POST /api/v1/routes # Создать маршрут
|
||||
PUT /api/v1/routes/:id # Обновить маршрут
|
||||
DELETE /api/v1/routes/:id # Удалить маршрут
|
||||
```
|
||||
|
||||
**Остановки:**
|
||||
```bash
|
||||
GET /api/v1/stops # Все остановки
|
||||
GET /api/v1/stops/:id # Конкретная остановка
|
||||
GET /api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500 # Ближайшие
|
||||
POST /api/v1/stops # Создать остановку
|
||||
PUT /api/v1/stops/:id # Обновить остановку
|
||||
DELETE /api/v1/stops/:id # Удалить остановку
|
||||
```
|
||||
|
||||
**ETA (расчет времени прибытия):**
|
||||
```bash
|
||||
POST /api/v1/eta/calculate
|
||||
{
|
||||
"stopId": 1,
|
||||
"routeId": 1
|
||||
}
|
||||
|
||||
GET /api/v1/eta/stop/:stopId # ETA для всех маршрутов на остановке
|
||||
```
|
||||
|
||||
**Синхронизация (для offline-режима):**
|
||||
```bash
|
||||
GET /api/v1/sync # Полная синхронизация
|
||||
GET /api/v1/sync?lastSync=2025-11-19T10:00:00Z # Инкрементальная
|
||||
GET /api/v1/sync/status # Статус системы
|
||||
```
|
||||
|
||||
### Примеры запросов
|
||||
|
||||
**Создание маршрута:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/routes \
|
||||
-H "X-API-Key: dev_key_12345678" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"route_number": "1",
|
||||
"name": "Центр - Вокзал",
|
||||
"type": "bus",
|
||||
"color": "#0066CC",
|
||||
"description": "Основной маршрут через центр города"
|
||||
}'
|
||||
```
|
||||
|
||||
**Создание остановки:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/stops \
|
||||
-H "X-API-Key: dev_key_12345678" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Площадь Свободы",
|
||||
"latitude": 55.4869,
|
||||
"longitude": 28.7856,
|
||||
"address": "ул. Ленина, 1"
|
||||
}'
|
||||
```
|
||||
|
||||
**Поиск ближайших остановок:**
|
||||
```bash
|
||||
curl -H "X-API-Key: dev_key_12345678" \
|
||||
"http://localhost:3000/api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500"
|
||||
```
|
||||
|
||||
## 🔐 Безопасность домашнего сервера
|
||||
|
||||
### Обязательные меры безопасности
|
||||
|
||||
1. **Cloudflare Tunnel** (рекомендуется):
|
||||
```bash
|
||||
# Установка cloudflared
|
||||
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
|
||||
sudo mv cloudflared /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/cloudflared
|
||||
|
||||
# Аутентификация
|
||||
cloudflared tunnel login
|
||||
|
||||
# Создание туннеля
|
||||
cloudflared tunnel create polotsk-transit
|
||||
|
||||
# Запуск
|
||||
cloudflared tunnel run polotsk-transit
|
||||
```
|
||||
|
||||
2. **Настройка SSL в Nginx:**
|
||||
```bash
|
||||
# Получение сертификата Let's Encrypt
|
||||
sudo certbot --nginx -d api.yourdomain.com -d admin.yourdomain.com
|
||||
|
||||
# Сертификаты автоматически обновятся
|
||||
```
|
||||
|
||||
3. **Fail2ban:**
|
||||
```bash
|
||||
sudo apt install fail2ban
|
||||
sudo systemctl enable fail2ban
|
||||
sudo systemctl start fail2ban
|
||||
```
|
||||
|
||||
4. **Мониторинг:**
|
||||
```bash
|
||||
# Установка Prometheus + Grafana (опционально)
|
||||
# Или простой мониторинг через Docker stats
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Создание API ключей
|
||||
|
||||
API ключи можно создать через базу данных:
|
||||
|
||||
```sql
|
||||
INSERT INTO api_keys (key, app_name, rate_limit)
|
||||
VALUES ('your_secure_key_here', 'iOS App', 10000);
|
||||
```
|
||||
|
||||
Или через скрипт:
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const apiKey = crypto.randomBytes(32).toString('hex');
|
||||
console.log(apiKey);
|
||||
```
|
||||
|
||||
## 📊 Админ-панель
|
||||
|
||||
Откройте http://localhost:3001 в браузере.
|
||||
|
||||
**Функции:**
|
||||
- Управление маршрутами
|
||||
- Создание и редактирование остановок на карте
|
||||
- Просмотр статистики
|
||||
- Управление расписаниями (в разработке)
|
||||
|
||||
## 🗄 Структура базы данных
|
||||
|
||||
```sql
|
||||
routes # Маршруты
|
||||
stops # Остановки (с PostGIS геометрией)
|
||||
route_stops # Связь маршрутов и остановок
|
||||
schedules # Расписания
|
||||
vehicles # Транспортные средства
|
||||
historical_delays # Статистика задержек
|
||||
alerts # Уведомления
|
||||
api_keys # API ключи
|
||||
sync_log # Лог изменений для синхронизации
|
||||
```
|
||||
|
||||
## 🔄 Обновления
|
||||
|
||||
```bash
|
||||
# Остановка сервисов
|
||||
docker-compose down
|
||||
|
||||
# Обновление кода
|
||||
git pull
|
||||
|
||||
# Перезапуск
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**База данных не стартует:**
|
||||
```bash
|
||||
docker-compose logs postgres
|
||||
docker-compose down -v # Удалит volumes, ОСТОРОЖНО!
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**API не отвечает:**
|
||||
```bash
|
||||
docker-compose logs backend
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
**Frontend не собирается:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
**PostGIS ошибки:**
|
||||
```bash
|
||||
docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit
|
||||
SELECT PostGIS_Version();
|
||||
```
|
||||
|
||||
## 📝 Следующие шаги (Roadmap)
|
||||
|
||||
### Версия 2.0 - Краудсорсинг GPS
|
||||
- [ ] Endpoint для получения GPS от пользователей
|
||||
- [ ] Агрегация и фильтрация GPS данных
|
||||
- [ ] WebSocket для реалтайм-обновлений
|
||||
- [ ] Функция "Я еду в маршруте" в приложениях
|
||||
|
||||
### Версия 3.0 - Продвинутые функции
|
||||
- [ ] IoT GPS-трекеры интеграция
|
||||
- [ ] Машинное обучение для предсказания задержек
|
||||
- [ ] Push-уведомления
|
||||
- [ ] Аналитика и отчеты
|
||||
|
||||
## 🤝 Контакты и поддержка
|
||||
|
||||
Создано для города Полоцка 🇧🇾
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
MIT License - используйте свободно!
|
||||
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
CMD ["npm", "start"]
|
||||
187
backend/migrations/001_init.sql
Normal file
187
backend/migrations/001_init.sql
Normal file
@@ -0,0 +1,187 @@
|
||||
-- Enable PostGIS extension
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- API Keys table
|
||||
CREATE TABLE api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(64) UNIQUE NOT NULL,
|
||||
app_name VARCHAR(100) NOT NULL,
|
||||
rate_limit INTEGER DEFAULT 1000,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Routes table (маршруты)
|
||||
CREATE TABLE routes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_number VARCHAR(10) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('bus', 'minibus', 'trolleybus', 'tram')),
|
||||
color VARCHAR(7) DEFAULT '#0066CC',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Stops table (остановки) with geospatial support
|
||||
CREATE TABLE stops (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
location GEOGRAPHY(Point, 4326) NOT NULL,
|
||||
address VARCHAR(300),
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create spatial index for stops
|
||||
CREATE INDEX idx_stops_location ON stops USING GIST(location);
|
||||
|
||||
-- Route stops junction table (связь маршрутов и остановок)
|
||||
CREATE TABLE route_stops (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
time_offset_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(route_id, stop_id, sequence)
|
||||
);
|
||||
|
||||
-- Schedules table (расписание)
|
||||
CREATE TABLE schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||
day_type VARCHAR(20) NOT NULL CHECK (day_type IN ('weekday', 'saturday', 'sunday', 'holiday')),
|
||||
departure_times TIME[] NOT NULL,
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Vehicles table (транспортные средства)
|
||||
CREATE TABLE vehicles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vehicle_number VARCHAR(20) NOT NULL,
|
||||
registration VARCHAR(20),
|
||||
type VARCHAR(20) NOT NULL,
|
||||
capacity INTEGER,
|
||||
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Historical delays table (статистика задержек)
|
||||
CREATE TABLE historical_delays (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE,
|
||||
hour_of_day INTEGER NOT NULL CHECK (hour_of_day BETWEEN 0 AND 23),
|
||||
day_type VARCHAR(20) NOT NULL,
|
||||
avg_delay_minutes INTEGER NOT NULL,
|
||||
sample_count INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(route_id, stop_id, hour_of_day, day_type)
|
||||
);
|
||||
|
||||
-- Alerts table (уведомления и оповещения)
|
||||
CREATE TABLE alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_id INTEGER REFERENCES routes(id) ON DELETE CASCADE,
|
||||
alert_type VARCHAR(20) NOT NULL CHECK (alert_type IN ('delay', 'cancellation', 'detour', 'info')),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Sync log table (для offline синхронизации)
|
||||
CREATE TABLE sync_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
table_name VARCHAR(50) NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
action VARCHAR(20) NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_routes_number ON routes(route_number);
|
||||
CREATE INDEX idx_routes_active ON routes(is_active);
|
||||
CREATE INDEX idx_stops_name ON stops(name);
|
||||
CREATE INDEX idx_route_stops_route ON route_stops(route_id);
|
||||
CREATE INDEX idx_route_stops_stop ON route_stops(stop_id);
|
||||
CREATE INDEX idx_schedules_route ON schedules(route_id);
|
||||
CREATE INDEX idx_vehicles_route ON vehicles(route_id);
|
||||
CREATE INDEX idx_alerts_active ON alerts(is_active, start_time, end_time);
|
||||
CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp);
|
||||
|
||||
-- Create function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at
|
||||
CREATE TRIGGER update_routes_updated_at BEFORE UPDATE ON routes
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_stops_updated_at BEFORE UPDATE ON stops
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_schedules_updated_at BEFORE UPDATE ON schedules
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_vehicles_updated_at BEFORE UPDATE ON vehicles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Create function to log changes for sync
|
||||
CREATE OR REPLACE FUNCTION log_sync_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action)
|
||||
VALUES (TG_TABLE_NAME, OLD.id, 'delete');
|
||||
RETURN OLD;
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'update');
|
||||
RETURN NEW;
|
||||
ELSIF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO sync_log (table_name, record_id, action)
|
||||
VALUES (TG_TABLE_NAME, NEW.id, 'insert');
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create sync triggers for main tables
|
||||
CREATE TRIGGER sync_routes AFTER INSERT OR UPDATE OR DELETE ON routes
|
||||
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
|
||||
|
||||
CREATE TRIGGER sync_stops AFTER INSERT OR UPDATE OR DELETE ON stops
|
||||
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
|
||||
|
||||
CREATE TRIGGER sync_route_stops AFTER INSERT OR UPDATE OR DELETE ON route_stops
|
||||
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
|
||||
|
||||
CREATE TRIGGER sync_schedules AFTER INSERT OR UPDATE OR DELETE ON schedules
|
||||
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
|
||||
|
||||
-- Insert sample API key for development
|
||||
INSERT INTO api_keys (key, app_name, rate_limit)
|
||||
VALUES ('dev_key_12345678', 'Development App', 10000);
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO transit_admin;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO transit_admin;
|
||||
86
backend/migrations/002_holidays.sql
Normal file
86
backend/migrations/002_holidays.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- Holidays table (праздничные дни Беларуси)
|
||||
CREATE TABLE IF NOT EXISTS holidays (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT false, -- повторяется каждый год
|
||||
recurring_month INTEGER, -- месяц для повторяющихся
|
||||
recurring_day INTEGER, -- день для повторяющихся
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for fast date lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date);
|
||||
|
||||
-- Insert Belarus official holidays (recurring)
|
||||
-- Эти праздники повторяются каждый год в одну и ту же дату
|
||||
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES
|
||||
('2025-01-01', 'Новый год', true, 1, 1),
|
||||
('2025-01-07', 'Рождество (православное)', true, 1, 7),
|
||||
('2025-03-08', 'День женщин', true, 3, 8),
|
||||
('2025-05-01', 'Праздник труда', true, 5, 1),
|
||||
('2025-05-09', 'День Победы', true, 5, 9),
|
||||
('2025-07-03', 'День Независимости', true, 7, 3),
|
||||
('2025-11-07', 'День Октябрьской революции', true, 11, 7),
|
||||
('2025-12-25', 'Рождество (католическое)', true, 12, 25)
|
||||
ON CONFLICT (date) DO NOTHING;
|
||||
|
||||
-- Радуница (переходящая дата) - нужно добавлять вручную каждый год
|
||||
-- 2025: 29 апреля
|
||||
-- 2026: 21 апреля
|
||||
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES
|
||||
('2025-04-29', 'Радуница', false, NULL, NULL),
|
||||
('2026-04-21', 'Радуница', false, NULL, NULL)
|
||||
ON CONFLICT (date) DO NOTHING;
|
||||
|
||||
-- Function to check if a date is a holiday
|
||||
CREATE OR REPLACE FUNCTION is_holiday(check_date DATE)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
-- Check exact date match
|
||||
IF EXISTS (SELECT 1 FROM holidays WHERE date = check_date) THEN
|
||||
RETURN TRUE;
|
||||
END IF;
|
||||
|
||||
-- Check recurring holidays (for future years)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM holidays
|
||||
WHERE is_recurring = true
|
||||
AND recurring_month = EXTRACT(MONTH FROM check_date)
|
||||
AND recurring_day = EXTRACT(DAY FROM check_date)
|
||||
) THEN
|
||||
RETURN TRUE;
|
||||
END IF;
|
||||
|
||||
RETURN FALSE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get schedule type for a date
|
||||
-- Returns: 'weekday', 'saturday', 'sunday', or 'holiday'
|
||||
CREATE OR REPLACE FUNCTION get_schedule_type(check_date DATE)
|
||||
RETURNS VARCHAR(20) AS $$
|
||||
DECLARE
|
||||
day_of_week INTEGER;
|
||||
BEGIN
|
||||
-- Check if it's a holiday first (holidays use sunday schedule)
|
||||
IF is_holiday(check_date) THEN
|
||||
RETURN 'holiday';
|
||||
END IF;
|
||||
|
||||
-- Get day of week (0 = Sunday, 6 = Saturday)
|
||||
day_of_week := EXTRACT(DOW FROM check_date);
|
||||
|
||||
IF day_of_week = 0 THEN
|
||||
RETURN 'sunday';
|
||||
ELSIF day_of_week = 6 THEN
|
||||
RETURN 'saturday';
|
||||
ELSE
|
||||
RETURN 'weekday';
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add sync trigger for holidays
|
||||
CREATE TRIGGER sync_holidays AFTER INSERT OR UPDATE OR DELETE ON holidays
|
||||
FOR EACH ROW EXECUTE FUNCTION log_sync_changes();
|
||||
44
backend/migrations/003_users_auth.sql
Normal file
44
backend/migrations/003_users_auth.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Users table for authentication
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(100),
|
||||
email VARCHAR(100),
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')) DEFAULT 'user',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_login TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
|
||||
-- Update trigger
|
||||
CREATE OR REPLACE FUNCTION update_users_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_users_updated_at();
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE user_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
refresh_token VARCHAR(500) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX idx_sessions_token ON user_sessions(refresh_token);
|
||||
7
backend/migrations/004_users_avatars.sql
Normal file
7
backend/migrations/004_users_avatars.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add avatar_url column to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_avatar ON users(avatar_url);
|
||||
|
||||
COMMENT ON COLUMN users.avatar_url IS 'URL or path to user avatar image';
|
||||
57
backend/migrations/005_realtime_gps.sql
Normal file
57
backend/migrations/005_realtime_gps.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Realtime GPS telemetry storage
|
||||
CREATE TABLE IF NOT EXISTS telemetry_samples (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL,
|
||||
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'manual',
|
||||
source_ref VARCHAR(120),
|
||||
location GEOGRAPHY(Point, 4326) NOT NULL,
|
||||
speed_kmh NUMERIC(6, 2),
|
||||
heading NUMERIC(6, 2),
|
||||
accuracy_meters NUMERIC(6, 2),
|
||||
recorded_at TIMESTAMP NOT NULL,
|
||||
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_valid BOOLEAN DEFAULT true,
|
||||
raw_payload JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_vehicle_id ON telemetry_samples(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_route_id ON telemetry_samples(route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_recorded_at ON telemetry_samples(recorded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_samples_location ON telemetry_samples USING GIST(location);
|
||||
|
||||
-- Latest live position for each vehicle
|
||||
CREATE TABLE IF NOT EXISTS vehicle_live_state (
|
||||
vehicle_id INTEGER PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||
location GEOGRAPHY(Point, 4326) NOT NULL,
|
||||
speed_kmh NUMERIC(6, 2),
|
||||
heading NUMERIC(6, 2),
|
||||
accuracy_meters NUMERIC(6, 2),
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'manual',
|
||||
source_ref VARCHAR(120),
|
||||
last_seen_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_route_id ON vehicle_live_state(route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_last_seen_at ON vehicle_live_state(last_seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_location ON vehicle_live_state USING GIST(location);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_proc
|
||||
WHERE proname = 'update_updated_at_column'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_trigger
|
||||
WHERE tgname = 'update_vehicle_live_state_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER update_vehicle_live_state_updated_at
|
||||
BEFORE UPDATE ON vehicle_live_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
40
backend/package.json
Normal file
40
backend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "polotsk-transit-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Polotsk Public Transit API",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"migrate": "node src/utils/migrate.js",
|
||||
"seed": "node src/utils/seed.js"
|
||||
},
|
||||
"keywords": [
|
||||
"transit",
|
||||
"api",
|
||||
"polotsk"
|
||||
],
|
||||
"author": "Zakhar",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.10",
|
||||
"socket.io": "^4.7.2",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
29
backend/scripts/create-admin.js
Normal file
29
backend/scripts/create-admin.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const pool = require('../src/config/database');
|
||||
|
||||
async function createAdmin() {
|
||||
const username = 'admin';
|
||||
const password = 'admin123'; // Измените после первого входа!
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO users (username, password_hash, full_name, email, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password_hash = $2
|
||||
RETURNING id, username, role
|
||||
`, [username, passwordHash, 'Главный администратор', 'admin@polotsk-transit.local', 'admin', true]);
|
||||
|
||||
console.log('✅ Мастер-аккаунт создан:', result.rows[0]);
|
||||
console.log('Username: admin');
|
||||
console.log('Password: admin123');
|
||||
console.log('⚠️ ОБЯЗАТЕЛЬНО смените пароль после первого входа!');
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка:', error);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
createAdmin();
|
||||
25
backend/src/config/database.js
Normal file
25
backend/src/config/database.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
// Test connection
|
||||
pool.query('SELECT NOW()', (err, res) => {
|
||||
if (err) {
|
||||
console.error('Database connection failed:', err);
|
||||
} else {
|
||||
console.log('Database connected successfully');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
36
backend/src/config/logger.js
Normal file
36
backend/src/config/logger.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'polotsk-transit-api' },
|
||||
transports: [
|
||||
// Write all logs with level 'error' and below to error.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/error.log'),
|
||||
level: 'error'
|
||||
}),
|
||||
// Write all logs with level 'info' and below to combined.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/combined.log')
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// If we're not in production, log to console as well
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
21
backend/src/config/redis.js
Normal file
21
backend/src/config/redis.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const redis = require('redis');
|
||||
require('dotenv').config();
|
||||
|
||||
const client = redis.createClient({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('Redis connected successfully');
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
(async () => {
|
||||
await client.connect();
|
||||
})();
|
||||
|
||||
module.exports = client;
|
||||
232
backend/src/controllers/authController.js
Normal file
232
backend/src/controllers/authController.js
Normal file
@@ -0,0 +1,232 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const JWT_EXPIRES_IN = '24h';
|
||||
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
|
||||
|
||||
class AuthController {
|
||||
// Login
|
||||
async login(req, res) {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const userResult = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Verify password
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
|
||||
);
|
||||
|
||||
// Save refresh token
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Remove password hash from response
|
||||
delete user.password_hash;
|
||||
|
||||
logger.info(`User logged in: ${username}`);
|
||||
|
||||
res.json({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
async refresh(req, res) {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: 'Refresh token required' });
|
||||
}
|
||||
|
||||
// Verify refresh token
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(refreshToken, JWT_SECRET);
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
// Check if session exists and not expired
|
||||
const sessionResult = await pool.query(
|
||||
`SELECT s.*, u.username, u.role, u.is_active
|
||||
FROM user_sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.refresh_token = $1 AND s.expires_at > NOW()`,
|
||||
[refreshToken]
|
||||
);
|
||||
|
||||
if (sessionResult.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Session expired or invalid' });
|
||||
}
|
||||
|
||||
const session = sessionResult.rows[0];
|
||||
|
||||
if (!session.is_active) {
|
||||
return res.status(401).json({ error: 'User is inactive' });
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = jwt.sign(
|
||||
{ id: session.user_id, username: session.username, role: session.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
res.json({ accessToken });
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
async logout(req, res) {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
if (refreshToken) {
|
||||
await pool.query(
|
||||
'DELETE FROM user_sessions WHERE refresh_token = $1',
|
||||
[refreshToken]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get current user
|
||||
async me(req, res) {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, username, full_name, email, role, avatar_url, last_login, created_at FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(userResult.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Change password
|
||||
async changePassword(req, res) {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current and new password required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
// Get user
|
||||
const userResult = await pool.query(
|
||||
'SELECT password_hash FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(
|
||||
currentPassword,
|
||||
userResult.rows[0].password_hash
|
||||
);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $1 WHERE id = $2',
|
||||
[newPasswordHash, req.user.id]
|
||||
);
|
||||
|
||||
// Invalidate all sessions except current
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
await pool.query(
|
||||
'DELETE FROM user_sessions WHERE user_id = $1 AND refresh_token != $2',
|
||||
[req.user.id, refreshToken]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Password changed for user: ${req.user.username}`);
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Change password error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
271
backend/src/controllers/etaController.js
Normal file
271
backend/src/controllers/etaController.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class EtaController {
|
||||
// Calculate ETA for a stop on a route
|
||||
async calculateEta(req, res) {
|
||||
const { stopId, routeId } = req.body;
|
||||
const currentTime = new Date();
|
||||
|
||||
if (!stopId || !routeId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['stopId', 'routeId']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get schedule type for today (holiday-aware)
|
||||
const typeResult = await pool.query(
|
||||
'SELECT get_schedule_type(CURRENT_DATE) as schedule_type'
|
||||
);
|
||||
let scheduleType = typeResult.rows[0].schedule_type;
|
||||
|
||||
// Get schedule for this route
|
||||
let scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
FROM schedules
|
||||
WHERE route_id = $1
|
||||
AND day_type = $2
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
LIMIT 1
|
||||
`, [routeId, scheduleType]);
|
||||
|
||||
// If holiday and no specific holiday schedule, fall back to sunday
|
||||
if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') {
|
||||
scheduleType = 'sunday';
|
||||
scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
FROM schedules
|
||||
WHERE route_id = $1
|
||||
AND day_type = 'sunday'
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
LIMIT 1
|
||||
`, [routeId]);
|
||||
}
|
||||
|
||||
if (scheduleResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No schedule found',
|
||||
message: 'Schedule not available for this route and day type',
|
||||
scheduleType: scheduleType
|
||||
});
|
||||
}
|
||||
|
||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
||||
|
||||
// Get time offset for this stop
|
||||
const stopResult = await pool.query(`
|
||||
SELECT time_offset_minutes
|
||||
FROM route_stops
|
||||
WHERE route_id = $1 AND stop_id = $2
|
||||
`, [routeId, stopId]);
|
||||
|
||||
if (stopResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'Stop not found on route',
|
||||
message: 'This stop is not part of this route'
|
||||
});
|
||||
}
|
||||
|
||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
||||
|
||||
// Calculate ETAs
|
||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||
const etas = [];
|
||||
|
||||
for (const departureTime of departureTimes) {
|
||||
if (departureTime > currentTimeStr) {
|
||||
// Add time offset to get arrival time at this stop
|
||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
||||
|
||||
// Get historical delay for this hour and day type
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
scheduleType === 'holiday' ? 'sunday' : scheduleType
|
||||
);
|
||||
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay
|
||||
});
|
||||
|
||||
// Return next 3 arrivals
|
||||
if (etas.length >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (etas.length === 0) {
|
||||
return res.json({
|
||||
message: 'No more arrivals today',
|
||||
nextDay: departureTimes[0]
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime: currentTimeStr,
|
||||
dayType: scheduleType,
|
||||
arrivals: etas
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error calculating ETA:', error);
|
||||
res.status(500).json({ error: 'Failed to calculate ETA' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get multiple ETAs for a stop (all routes)
|
||||
async getStopEtas(req, res) {
|
||||
const { stopId } = req.params;
|
||||
|
||||
try {
|
||||
// Get all routes passing through this stop
|
||||
const routesResult = await pool.query(`
|
||||
SELECT DISTINCT r.id, r.route_number, r.name, r.type, r.color
|
||||
FROM routes r
|
||||
JOIN route_stops rs ON r.id = rs.route_id
|
||||
WHERE rs.stop_id = $1 AND r.is_active = true
|
||||
`, [stopId]);
|
||||
|
||||
const etaPromises = routesResult.rows.map(route =>
|
||||
this.calculateEtaForRoute(stopId, route.id)
|
||||
.then(etas => ({
|
||||
...route,
|
||||
arrivals: etas
|
||||
}))
|
||||
.catch(err => {
|
||||
logger.error(`Error calculating ETA for route ${route.id}:`, err);
|
||||
return {
|
||||
...route,
|
||||
arrivals: [],
|
||||
error: 'Could not calculate ETA'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(etaPromises);
|
||||
|
||||
res.json({
|
||||
stopId,
|
||||
routes: results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting stop ETAs:', error);
|
||||
res.status(500).json({ error: 'Failed to get stop ETAs' });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Calculate ETA for a specific route (internal use)
|
||||
async calculateEtaForRoute(stopId, routeId) {
|
||||
const currentTime = new Date();
|
||||
const dayType = this.getDayType(currentTime);
|
||||
|
||||
const scheduleResult = await pool.query(`
|
||||
SELECT departure_times
|
||||
FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
LIMIT 1
|
||||
`, [routeId, dayType]);
|
||||
|
||||
if (scheduleResult.rows.length === 0) return [];
|
||||
|
||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
||||
|
||||
const stopResult = await pool.query(`
|
||||
SELECT time_offset_minutes
|
||||
FROM route_stops
|
||||
WHERE route_id = $1 AND stop_id = $2
|
||||
`, [routeId, stopId]);
|
||||
|
||||
if (stopResult.rows.length === 0) return [];
|
||||
|
||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||
const etas = [];
|
||||
|
||||
for (const departureTime of departureTimes) {
|
||||
if (departureTime > currentTimeStr) {
|
||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
||||
const delay = await this.getHistoricalDelay(
|
||||
routeId,
|
||||
stopId,
|
||||
currentTime.getHours(),
|
||||
dayType
|
||||
);
|
||||
|
||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||
|
||||
etas.push({
|
||||
scheduledTime: arrival,
|
||||
estimatedTime: adjustedArrival,
|
||||
minutesUntil: minutesUntil,
|
||||
delay: delay
|
||||
});
|
||||
|
||||
if (etas.length >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
return etas;
|
||||
}
|
||||
|
||||
// Helper: Get historical delay
|
||||
async getHistoricalDelay(routeId, stopId, hour, dayType) {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT avg_delay_minutes
|
||||
FROM historical_delays
|
||||
WHERE route_id = $1
|
||||
AND stop_id = $2
|
||||
AND hour_of_day = $3
|
||||
AND day_type = $4
|
||||
`, [routeId, stopId, hour, dayType]);
|
||||
|
||||
return result.rows.length > 0 ? result.rows[0].avg_delay_minutes : 0;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching historical delay:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Determine day type
|
||||
getDayType(date) {
|
||||
const day = date.getDay();
|
||||
if (day === 0) return 'sunday';
|
||||
if (day === 6) return 'saturday';
|
||||
return 'weekday';
|
||||
}
|
||||
|
||||
// Helper: Add minutes to time string
|
||||
addMinutesToTime(timeStr, minutes) {
|
||||
const [hours, mins, secs] = timeStr.split(':').map(Number);
|
||||
const totalMins = hours * 60 + mins + minutes;
|
||||
const newHours = Math.floor(totalMins / 60) % 24;
|
||||
const newMins = totalMins % 60;
|
||||
return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Helper: Get minutes difference between two times
|
||||
getMinutesDifference(time1, time2) {
|
||||
const [h1, m1] = time1.split(':').map(Number);
|
||||
const [h2, m2] = time2.split(':').map(Number);
|
||||
return (h2 * 60 + m2) - (h1 * 60 + m1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EtaController();
|
||||
223
backend/src/controllers/holidaysController.js
Normal file
223
backend/src/controllers/holidaysController.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class HolidaysController {
|
||||
// Get all holidays
|
||||
async getAllHolidays(req, res) {
|
||||
const { year } = req.query;
|
||||
|
||||
try {
|
||||
let query = `
|
||||
SELECT * FROM holidays
|
||||
ORDER BY date
|
||||
`;
|
||||
let params = [];
|
||||
|
||||
if (year) {
|
||||
query = `
|
||||
SELECT * FROM holidays
|
||||
WHERE EXTRACT(YEAR FROM date) = $1
|
||||
OR is_recurring = true
|
||||
ORDER BY
|
||||
CASE WHEN EXTRACT(YEAR FROM date) = $1 THEN date
|
||||
ELSE MAKE_DATE($1::int, recurring_month, recurring_day)
|
||||
END
|
||||
`;
|
||||
params = [year];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching holidays:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch holidays' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get holidays for a specific year (with resolved recurring dates)
|
||||
async getHolidaysForYear(req, res) {
|
||||
const { year } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN is_recurring THEN MAKE_DATE($1::int, recurring_month, recurring_day)
|
||||
ELSE date
|
||||
END as date,
|
||||
name,
|
||||
is_recurring
|
||||
FROM holidays
|
||||
WHERE EXTRACT(YEAR FROM date) = $1
|
||||
OR is_recurring = true
|
||||
ORDER BY date
|
||||
`, [year]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching holidays for year:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch holidays' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a specific date is a holiday
|
||||
async checkHoliday(req, res) {
|
||||
const { date } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT is_holiday($1::date) as is_holiday',
|
||||
[date]
|
||||
);
|
||||
|
||||
const holidayInfo = await pool.query(`
|
||||
SELECT name FROM holidays
|
||||
WHERE date = $1
|
||||
OR (is_recurring = true
|
||||
AND recurring_month = EXTRACT(MONTH FROM $1::date)
|
||||
AND recurring_day = EXTRACT(DAY FROM $1::date))
|
||||
LIMIT 1
|
||||
`, [date]);
|
||||
|
||||
res.json({
|
||||
date: date,
|
||||
isHoliday: result.rows[0].is_holiday,
|
||||
holidayName: holidayInfo.rows[0]?.name || null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error checking holiday:', error);
|
||||
res.status(500).json({ error: 'Failed to check holiday' });
|
||||
}
|
||||
}
|
||||
|
||||
// Add a holiday
|
||||
async createHoliday(req, res) {
|
||||
const { date, name, isRecurring } = req.body;
|
||||
|
||||
if (!date || !name) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['date', 'name']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const dateObj = new Date(date);
|
||||
const month = dateObj.getMonth() + 1;
|
||||
const day = dateObj.getDate();
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (date) DO UPDATE SET name = $2
|
||||
RETURNING *
|
||||
`, [date, name, isRecurring || false, isRecurring ? month : null, isRecurring ? day : null]);
|
||||
|
||||
logger.info(`Holiday created: ${name} on ${date}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating holiday:', error);
|
||||
res.status(500).json({ error: 'Failed to create holiday' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update a holiday
|
||||
async updateHoliday(req, res) {
|
||||
const { id } = req.params;
|
||||
const { date, name, isRecurring } = req.body;
|
||||
|
||||
try {
|
||||
let month = null;
|
||||
let day = null;
|
||||
|
||||
if (date && isRecurring) {
|
||||
const dateObj = new Date(date);
|
||||
month = dateObj.getMonth() + 1;
|
||||
day = dateObj.getDate();
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE holidays
|
||||
SET
|
||||
date = COALESCE($1, date),
|
||||
name = COALESCE($2, name),
|
||||
is_recurring = COALESCE($3, is_recurring),
|
||||
recurring_month = $4,
|
||||
recurring_day = $5
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
`, [date, name, isRecurring, month, day, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Holiday not found' });
|
||||
}
|
||||
|
||||
logger.info(`Holiday ${id} updated`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating holiday:', error);
|
||||
res.status(500).json({ error: 'Failed to update holiday' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a holiday
|
||||
async deleteHoliday(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM holidays WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Holiday not found' });
|
||||
}
|
||||
|
||||
logger.info(`Holiday ${id} deleted`);
|
||||
res.json({ message: 'Holiday deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting holiday:', error);
|
||||
res.status(500).json({ error: 'Failed to delete holiday' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get upcoming holidays
|
||||
async getUpcomingHolidays(req, res) {
|
||||
const { limit = 5 } = req.query;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
WITH upcoming AS (
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
CASE
|
||||
WHEN is_recurring THEN
|
||||
CASE
|
||||
WHEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day) >= CURRENT_DATE
|
||||
THEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day)
|
||||
ELSE MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int + 1, recurring_month, recurring_day)
|
||||
END
|
||||
ELSE date
|
||||
END as next_date,
|
||||
is_recurring
|
||||
FROM holidays
|
||||
)
|
||||
SELECT * FROM upcoming
|
||||
WHERE next_date >= CURRENT_DATE
|
||||
ORDER BY next_date
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching upcoming holidays:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch upcoming holidays' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HolidaysController();
|
||||
282
backend/src/controllers/routeStopsController.js
Normal file
282
backend/src/controllers/routeStopsController.js
Normal file
@@ -0,0 +1,282 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class RouteStopsController {
|
||||
// Get all stops for a route (ordered by sequence)
|
||||
async getRouteStops(req, res) {
|
||||
const { routeId } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
rs.id as route_stop_id,
|
||||
rs.sequence,
|
||||
rs.time_offset_minutes,
|
||||
s.id as stop_id,
|
||||
s.name,
|
||||
s.address,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude
|
||||
FROM route_stops rs
|
||||
JOIN stops s ON rs.stop_id = s.id
|
||||
WHERE rs.route_id = $1
|
||||
ORDER BY rs.sequence
|
||||
`, [routeId]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching route stops:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch route stops' });
|
||||
}
|
||||
}
|
||||
|
||||
// Add stop to route
|
||||
async addStopToRoute(req, res) {
|
||||
const { routeId } = req.params;
|
||||
const { stopId, sequence, timeOffsetMinutes = 0 } = req.body;
|
||||
|
||||
if (!stopId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required field: stopId'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if route exists
|
||||
const routeCheck = await pool.query(
|
||||
'SELECT id FROM routes WHERE id = $1',
|
||||
[routeId]
|
||||
);
|
||||
if (routeCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
|
||||
// Check if stop exists
|
||||
const stopCheck = await pool.query(
|
||||
'SELECT id FROM stops WHERE id = $1',
|
||||
[stopId]
|
||||
);
|
||||
if (stopCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
// If sequence not provided, add to the end
|
||||
let actualSequence = sequence;
|
||||
if (actualSequence === undefined || actualSequence === null) {
|
||||
const maxSeq = await pool.query(
|
||||
'SELECT COALESCE(MAX(sequence), 0) + 1 as next_seq FROM route_stops WHERE route_id = $1',
|
||||
[routeId]
|
||||
);
|
||||
actualSequence = maxSeq.rows[0].next_seq;
|
||||
} else {
|
||||
// Shift existing stops if inserting in the middle
|
||||
await pool.query(`
|
||||
UPDATE route_stops
|
||||
SET sequence = sequence + 1
|
||||
WHERE route_id = $1 AND sequence >= $2
|
||||
`, [routeId, actualSequence]);
|
||||
}
|
||||
|
||||
// Insert the new route_stop
|
||||
const result = await pool.query(`
|
||||
INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (route_id, stop_id, sequence) DO UPDATE
|
||||
SET time_offset_minutes = $4
|
||||
RETURNING *
|
||||
`, [routeId, stopId, actualSequence, timeOffsetMinutes]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
|
||||
logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error adding stop to route:', error);
|
||||
res.status(500).json({ error: 'Failed to add stop to route' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update stop in route (change sequence or time offset)
|
||||
async updateRouteStop(req, res) {
|
||||
const { routeId, routeStopId } = req.params;
|
||||
const { sequence, timeOffsetMinutes } = req.body;
|
||||
|
||||
try {
|
||||
// Get current route_stop
|
||||
const current = await pool.query(
|
||||
'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2',
|
||||
[routeStopId, routeId]
|
||||
);
|
||||
|
||||
if (current.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route stop not found' });
|
||||
}
|
||||
|
||||
const currentStop = current.rows[0];
|
||||
|
||||
// If sequence is changing, reorder other stops
|
||||
if (sequence !== undefined && sequence !== currentStop.sequence) {
|
||||
if (sequence > currentStop.sequence) {
|
||||
// Moving down: shift intermediate stops up
|
||||
await pool.query(`
|
||||
UPDATE route_stops
|
||||
SET sequence = sequence - 1
|
||||
WHERE route_id = $1
|
||||
AND sequence > $2
|
||||
AND sequence <= $3
|
||||
`, [routeId, currentStop.sequence, sequence]);
|
||||
} else {
|
||||
// Moving up: shift intermediate stops down
|
||||
await pool.query(`
|
||||
UPDATE route_stops
|
||||
SET sequence = sequence + 1
|
||||
WHERE route_id = $1
|
||||
AND sequence >= $2
|
||||
AND sequence < $3
|
||||
`, [routeId, sequence, currentStop.sequence]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the route_stop
|
||||
const result = await pool.query(`
|
||||
UPDATE route_stops
|
||||
SET
|
||||
sequence = COALESCE($1, sequence),
|
||||
time_offset_minutes = COALESCE($2, time_offset_minutes)
|
||||
WHERE id = $3 AND route_id = $4
|
||||
RETURNING *
|
||||
`, [sequence, timeOffsetMinutes, routeStopId, routeId]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
|
||||
logger.info(`Route stop ${routeStopId} updated`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating route stop:', error);
|
||||
res.status(500).json({ error: 'Failed to update route stop' });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stop from route
|
||||
async removeStopFromRoute(req, res) {
|
||||
const { routeId, routeStopId } = req.params;
|
||||
|
||||
try {
|
||||
// Get the stop being removed to know its sequence
|
||||
const current = await pool.query(
|
||||
'SELECT sequence FROM route_stops WHERE id = $1 AND route_id = $2',
|
||||
[routeStopId, routeId]
|
||||
);
|
||||
|
||||
if (current.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route stop not found' });
|
||||
}
|
||||
|
||||
const removedSequence = current.rows[0].sequence;
|
||||
|
||||
// Delete the route_stop
|
||||
await pool.query(
|
||||
'DELETE FROM route_stops WHERE id = $1 AND route_id = $2',
|
||||
[routeStopId, routeId]
|
||||
);
|
||||
|
||||
// Reorder remaining stops
|
||||
await pool.query(`
|
||||
UPDATE route_stops
|
||||
SET sequence = sequence - 1
|
||||
WHERE route_id = $1 AND sequence > $2
|
||||
`, [routeId, removedSequence]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
|
||||
logger.info(`Stop removed from route ${routeId}`);
|
||||
res.json({ message: 'Stop removed from route successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error removing stop from route:', error);
|
||||
res.status(500).json({ error: 'Failed to remove stop from route' });
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk update route stops (reorder all at once)
|
||||
async reorderRouteStops(req, res) {
|
||||
const { routeId } = req.params;
|
||||
const { stops } = req.body; // Array of { stopId, sequence, timeOffsetMinutes }
|
||||
|
||||
if (!Array.isArray(stops) || stops.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request',
|
||||
message: 'stops must be a non-empty array'
|
||||
});
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Delete all existing route_stops for this route
|
||||
await client.query(
|
||||
'DELETE FROM route_stops WHERE route_id = $1',
|
||||
[routeId]
|
||||
);
|
||||
|
||||
// Insert new order
|
||||
for (const stop of stops) {
|
||||
await client.query(`
|
||||
INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [routeId, stop.stopId, stop.sequence, stop.timeOffsetMinutes || 0]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
await redisClient.del('routes:all');
|
||||
|
||||
logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`);
|
||||
res.json({ message: 'Route stops reordered successfully', count: stops.length });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Error reordering route stops:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder route stops' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Get available stops (not yet in this route)
|
||||
async getAvailableStops(req, res) {
|
||||
const { routeId } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.address,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude
|
||||
FROM stops s
|
||||
WHERE s.is_active = true
|
||||
AND s.id NOT IN (
|
||||
SELECT stop_id FROM route_stops WHERE route_id = $1
|
||||
)
|
||||
ORDER BY s.name
|
||||
`, [routeId]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching available stops:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch available stops' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RouteStopsController();
|
||||
181
backend/src/controllers/routesController.js
Normal file
181
backend/src/controllers/routesController.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
class RoutesController {
|
||||
// Get all routes
|
||||
async getAllRoutes(req, res) {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await redisClient.get('routes:all');
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
r.*,
|
||||
COUNT(DISTINCT rs.stop_id) as stops_count
|
||||
FROM routes r
|
||||
LEFT JOIN route_stops rs ON r.id = rs.route_id
|
||||
WHERE r.is_active = true
|
||||
GROUP BY r.id
|
||||
ORDER BY r.route_number
|
||||
`);
|
||||
|
||||
const routes = result.rows;
|
||||
|
||||
// Cache the result
|
||||
await redisClient.setEx('routes:all', CACHE_TTL, JSON.stringify(routes));
|
||||
|
||||
res.json(routes);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching routes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch routes' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get route by ID with stops
|
||||
async getRouteById(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const cacheKey = `route:${id}`;
|
||||
const cached = await redisClient.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
|
||||
// Get route info
|
||||
const routeResult = await pool.query(
|
||||
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (routeResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
|
||||
// Get stops for this route
|
||||
const stopsResult = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude,
|
||||
rs.sequence,
|
||||
rs.time_offset_minutes
|
||||
FROM stops s
|
||||
JOIN route_stops rs ON s.id = rs.stop_id
|
||||
WHERE rs.route_id = $1
|
||||
ORDER BY rs.sequence
|
||||
`, [id]);
|
||||
|
||||
const route = {
|
||||
...routeResult.rows[0],
|
||||
stops: stopsResult.rows
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(route));
|
||||
|
||||
res.json(route);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching route:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch route' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create new route
|
||||
async createRoute(req, res) {
|
||||
const { route_number, name, type, color, description } = req.body;
|
||||
|
||||
if (!route_number || !name || !type) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['route_number', 'name', 'type']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO routes (route_number, name, type, color, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
`, [route_number, name, type, color || '#0066CC', description]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del('routes:all');
|
||||
|
||||
logger.info(`Route created: ${route_number} - ${name}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating route:', error);
|
||||
res.status(500).json({ error: 'Failed to create route' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update route
|
||||
async updateRoute(req, res) {
|
||||
const { id } = req.params;
|
||||
const { route_number, name, type, color, description, is_active } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
UPDATE routes
|
||||
SET
|
||||
route_number = COALESCE($1, route_number),
|
||||
name = COALESCE($2, name),
|
||||
type = COALESCE($3, type),
|
||||
color = COALESCE($4, color),
|
||||
description = COALESCE($5, description),
|
||||
is_active = COALESCE($6, is_active)
|
||||
WHERE id = $7
|
||||
RETURNING *
|
||||
`, [route_number, name, type, color, description, is_active, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${id}`);
|
||||
|
||||
logger.info(`Route updated: ${id}`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating route:', error);
|
||||
res.status(500).json({ error: 'Failed to update route' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete route
|
||||
async deleteRoute(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM routes WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Route not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del('routes:all');
|
||||
await redisClient.del(`route:${id}`);
|
||||
|
||||
logger.info(`Route deleted: ${id}`);
|
||||
res.json({ message: 'Route deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting route:', error);
|
||||
res.status(500).json({ error: 'Failed to delete route' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RoutesController();
|
||||
348
backend/src/controllers/schedulesController.js
Normal file
348
backend/src/controllers/schedulesController.js
Normal file
@@ -0,0 +1,348 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class SchedulesController {
|
||||
// Get all schedules for a route
|
||||
async getRouteSchedules(req, res) {
|
||||
const { routeId } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
r.route_number,
|
||||
r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.route_id = $1
|
||||
ORDER BY
|
||||
CASE s.day_type
|
||||
WHEN 'weekday' THEN 1
|
||||
WHEN 'saturday' THEN 2
|
||||
WHEN 'sunday' THEN 3
|
||||
WHEN 'holiday' THEN 4
|
||||
END
|
||||
`, [routeId]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching route schedules:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch schedules' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get schedule by ID
|
||||
async getScheduleById(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
r.route_number,
|
||||
r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create schedule
|
||||
async createSchedule(req, res) {
|
||||
const { routeId, dayType, departureTimes, validFrom, validUntil } = req.body;
|
||||
|
||||
if (!routeId || !dayType || !departureTimes) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['routeId', 'dayType', 'departureTimes']
|
||||
});
|
||||
}
|
||||
|
||||
// Validate day type
|
||||
const validDayTypes = ['weekday', 'saturday', 'sunday', 'holiday'];
|
||||
if (!validDayTypes.includes(dayType)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid day type',
|
||||
valid: validDayTypes
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and sort departure times
|
||||
const sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||
if (!sortedTimes) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid departure times format',
|
||||
expected: 'Array of times in HH:MM or HH:MM:SS format'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if schedule already exists for this route and day type
|
||||
const existing = await pool.query(`
|
||||
SELECT id FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
`, [routeId, dayType]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.status(409).json({
|
||||
error: 'Schedule already exists',
|
||||
message: `A schedule for ${dayType} already exists for this route`,
|
||||
existingId: existing.rows[0].id
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO schedules (route_id, day_type, departure_times, valid_from, valid_until)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
`, [routeId, dayType, sortedTimes, validFrom || null, validUntil || null]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
|
||||
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to create schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update schedule
|
||||
async updateSchedule(req, res) {
|
||||
const { id } = req.params;
|
||||
const { departureTimes, validFrom, validUntil } = req.body;
|
||||
|
||||
try {
|
||||
// Get current schedule to find route_id
|
||||
const current = await pool.query('SELECT route_id FROM schedules WHERE id = $1', [id]);
|
||||
if (current.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
let sortedTimes = null;
|
||||
if (departureTimes) {
|
||||
sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||
if (!sortedTimes) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid departure times format'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE schedules
|
||||
SET
|
||||
departure_times = COALESCE($1, departure_times),
|
||||
valid_from = COALESCE($2, valid_from),
|
||||
valid_until = COALESCE($3, valid_until),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`, [sortedTimes, validFrom, validUntil, id]);
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${current.rows[0].route_id}`);
|
||||
|
||||
logger.info(`Schedule ${id} updated`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to update schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete schedule
|
||||
async deleteSchedule(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM schedules WHERE id = $1 RETURNING route_id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Schedule not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${result.rows[0].route_id}`);
|
||||
|
||||
logger.info(`Schedule ${id} deleted`);
|
||||
res.json({ message: 'Schedule deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to delete schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get schedule for today (or specific date) for a route
|
||||
async getTodaySchedule(req, res) {
|
||||
const { routeId } = req.params;
|
||||
const { date } = req.query; // Optional: specific date in YYYY-MM-DD format
|
||||
|
||||
try {
|
||||
const targetDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get schedule type for the date (considers holidays)
|
||||
const typeResult = await pool.query(
|
||||
'SELECT get_schedule_type($1::date) as schedule_type',
|
||||
[targetDate]
|
||||
);
|
||||
|
||||
let scheduleType = typeResult.rows[0].schedule_type;
|
||||
|
||||
// If holiday, use sunday schedule (as per Belarus rules)
|
||||
// But first check if there's a specific holiday schedule
|
||||
let result = await pool.query(`
|
||||
SELECT s.*, r.route_number, r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.route_id = $1
|
||||
AND s.day_type = $2
|
||||
AND (s.valid_from IS NULL OR s.valid_from <= $3::date)
|
||||
AND (s.valid_until IS NULL OR s.valid_until >= $3::date)
|
||||
LIMIT 1
|
||||
`, [routeId, scheduleType, targetDate]);
|
||||
|
||||
// If holiday and no specific holiday schedule, fall back to sunday
|
||||
if (result.rows.length === 0 && scheduleType === 'holiday') {
|
||||
result = await pool.query(`
|
||||
SELECT s.*, r.route_number, r.name as route_name
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE s.route_id = $1
|
||||
AND s.day_type = 'sunday'
|
||||
AND (s.valid_from IS NULL OR s.valid_from <= $2::date)
|
||||
AND (s.valid_until IS NULL OR s.valid_until >= $2::date)
|
||||
LIMIT 1
|
||||
`, [routeId, targetDate]);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No schedule found',
|
||||
date: targetDate,
|
||||
scheduleType: scheduleType
|
||||
});
|
||||
}
|
||||
|
||||
// Check if today is a holiday
|
||||
const holidayCheck = await pool.query(
|
||||
'SELECT name FROM holidays WHERE date = $1',
|
||||
[targetDate]
|
||||
);
|
||||
|
||||
res.json({
|
||||
date: targetDate,
|
||||
scheduleType: scheduleType,
|
||||
isHoliday: holidayCheck.rows.length > 0,
|
||||
holidayName: holidayCheck.rows[0]?.name || null,
|
||||
schedule: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching today schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Copy schedule from one day type to another
|
||||
async copySchedule(req, res) {
|
||||
const { routeId } = req.params;
|
||||
const { fromDayType, toDayType } = req.body;
|
||||
|
||||
if (!fromDayType || !toDayType) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['fromDayType', 'toDayType']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get source schedule
|
||||
const source = await pool.query(`
|
||||
SELECT departure_times FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, [routeId, fromDayType]);
|
||||
|
||||
if (source.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'Source schedule not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if target already exists
|
||||
const existing = await pool.query(`
|
||||
SELECT id FROM schedules
|
||||
WHERE route_id = $1 AND day_type = $2
|
||||
`, [routeId, toDayType]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update existing
|
||||
const result = await pool.query(`
|
||||
UPDATE schedules
|
||||
SET departure_times = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE route_id = $2 AND day_type = $3
|
||||
RETURNING *
|
||||
`, [source.rows[0].departure_times, routeId, toDayType]);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} else {
|
||||
// Create new
|
||||
const result = await pool.query(`
|
||||
INSERT INTO schedules (route_id, day_type, departure_times)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`, [routeId, toDayType, source.rows[0].departure_times]);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redisClient.del(`route:${routeId}`);
|
||||
|
||||
logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`);
|
||||
} catch (error) {
|
||||
logger.error('Error copying schedule:', error);
|
||||
res.status(500).json({ error: 'Failed to copy schedule' });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Validate and sort times
|
||||
validateAndSortTimes(times) {
|
||||
if (!Array.isArray(times)) return null;
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
|
||||
const validTimes = [];
|
||||
|
||||
for (const time of times) {
|
||||
if (!timeRegex.test(time)) return null;
|
||||
|
||||
// Normalize to HH:MM:SS format
|
||||
const parts = time.split(':');
|
||||
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
|
||||
validTimes.push(normalized);
|
||||
}
|
||||
|
||||
// Sort times
|
||||
validTimes.sort();
|
||||
return validTimes;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SchedulesController();
|
||||
237
backend/src/controllers/stopsController.js
Normal file
237
backend/src/controllers/stopsController.js
Normal file
@@ -0,0 +1,237 @@
|
||||
const pool = require('../config/database');
|
||||
const redisClient = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const CACHE_TTL = 3600;
|
||||
|
||||
class StopsController {
|
||||
// Get all stops
|
||||
async getAllStops(req, res) {
|
||||
try {
|
||||
const cached = await redisClient.get('stops:all');
|
||||
if (cached) {
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude,
|
||||
COUNT(DISTINCT rs.route_id) as routes_count
|
||||
FROM stops s
|
||||
LEFT JOIN route_stops rs ON s.id = rs.stop_id
|
||||
WHERE s.is_active = true
|
||||
GROUP BY s.id
|
||||
ORDER BY s.name
|
||||
`);
|
||||
|
||||
const stops = result.rows;
|
||||
await redisClient.setEx('stops:all', CACHE_TTL, JSON.stringify(stops));
|
||||
|
||||
res.json(stops);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching stops:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stops' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get stop by ID
|
||||
async getStopById(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude
|
||||
FROM stops s
|
||||
WHERE s.id = $1 AND s.is_active = true
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
// Get routes passing through this stop
|
||||
const routesResult = await pool.query(`
|
||||
SELECT
|
||||
r.*,
|
||||
rs.sequence,
|
||||
rs.time_offset_minutes
|
||||
FROM routes r
|
||||
JOIN route_stops rs ON r.id = rs.route_id
|
||||
WHERE rs.stop_id = $1 AND r.is_active = true
|
||||
ORDER BY r.route_number
|
||||
`, [id]);
|
||||
|
||||
const stop = {
|
||||
...result.rows[0],
|
||||
routes: routesResult.rows
|
||||
};
|
||||
|
||||
res.json(stop);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching stop:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stop' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get nearby stops (PostGIS query)
|
||||
async getNearbyStops(req, res) {
|
||||
const { lat, lon, radius = 500 } = req.query;
|
||||
|
||||
if (!lat || !lon) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required parameters',
|
||||
required: ['lat', 'lon']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
ST_Y(s.location::geometry) as latitude,
|
||||
ST_X(s.location::geometry) as longitude,
|
||||
ST_Distance(
|
||||
s.location,
|
||||
ST_MakePoint($2, $1)::geography
|
||||
) as distance_meters
|
||||
FROM stops s
|
||||
WHERE s.is_active = true
|
||||
AND ST_DWithin(
|
||||
s.location,
|
||||
ST_MakePoint($2, $1)::geography,
|
||||
$3
|
||||
)
|
||||
ORDER BY distance_meters
|
||||
LIMIT 20
|
||||
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching nearby stops:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch nearby stops' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create stop
|
||||
async createStop(req, res) {
|
||||
const { name, latitude, longitude, address, description } = req.body;
|
||||
|
||||
if (!name || !latitude || !longitude) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
required: ['name', 'latitude', 'longitude']
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO stops (name, location, address, description)
|
||||
VALUES (
|
||||
$1,
|
||||
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
|
||||
$4,
|
||||
$5
|
||||
)
|
||||
RETURNING
|
||||
*,
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
`, [name, latitude, longitude, address, description]);
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
|
||||
logger.info(`Stop created: ${name}`);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating stop:', error);
|
||||
res.status(500).json({ error: 'Failed to create stop' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update stop
|
||||
async updateStop(req, res) {
|
||||
const { id } = req.params;
|
||||
const { name, latitude, longitude, address, description, is_active } = req.body;
|
||||
|
||||
try {
|
||||
let query = 'UPDATE stops SET ';
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (name !== undefined) {
|
||||
query += `name = $${paramCount}, `;
|
||||
values.push(name);
|
||||
paramCount++;
|
||||
}
|
||||
if (latitude !== undefined && longitude !== undefined) {
|
||||
query += `location = ST_SetSRID(ST_MakePoint($${paramCount + 1}, $${paramCount}), 4326)::geography, `;
|
||||
values.push(latitude, longitude);
|
||||
paramCount += 2;
|
||||
}
|
||||
if (address !== undefined) {
|
||||
query += `address = $${paramCount}, `;
|
||||
values.push(address);
|
||||
paramCount++;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
query += `description = $${paramCount}, `;
|
||||
values.push(description);
|
||||
paramCount++;
|
||||
}
|
||||
if (is_active !== undefined) {
|
||||
query += `is_active = $${paramCount}, `;
|
||||
values.push(is_active);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
query = query.slice(0, -2); // Remove trailing comma
|
||||
query += ` WHERE id = $${paramCount} RETURNING *, ST_Y(location::geometry) as latitude, ST_X(location::geometry) as longitude`;
|
||||
values.push(id);
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
|
||||
logger.info(`Stop updated: ${id}`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating stop:', error);
|
||||
res.status(500).json({ error: 'Failed to update stop' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stop
|
||||
async deleteStop(req, res) {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM stops WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Stop not found' });
|
||||
}
|
||||
|
||||
await redisClient.del('stops:all');
|
||||
|
||||
logger.info(`Stop deleted: ${id}`);
|
||||
res.json({ message: 'Stop deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting stop:', error);
|
||||
res.status(500).json({ error: 'Failed to delete stop' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new StopsController();
|
||||
250
backend/src/controllers/syncController.js
Normal file
250
backend/src/controllers/syncController.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class SyncController {
|
||||
// Get all data or incremental updates
|
||||
async sync(req, res) {
|
||||
const { lastSync } = req.query;
|
||||
|
||||
try {
|
||||
if (!lastSync) {
|
||||
// Full sync - first time
|
||||
return await this.fullSync(req, res);
|
||||
} else {
|
||||
// Incremental sync
|
||||
return await this.incrementalSync(req, res, lastSync);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during sync:', error);
|
||||
res.status(500).json({ error: 'Sync failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Full synchronization (all data)
|
||||
async fullSync(req, res) {
|
||||
try {
|
||||
// Get all active routes
|
||||
const routesResult = await pool.query(`
|
||||
SELECT * FROM routes WHERE is_active = true
|
||||
`);
|
||||
|
||||
// Get all active stops with coordinates
|
||||
const stopsResult = await pool.query(`
|
||||
SELECT
|
||||
*,
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
FROM stops
|
||||
WHERE is_active = true
|
||||
`);
|
||||
|
||||
// Get route-stop relationships
|
||||
const routeStopsResult = await pool.query(`
|
||||
SELECT rs.*
|
||||
FROM route_stops rs
|
||||
JOIN routes r ON rs.route_id = r.id
|
||||
JOIN stops s ON rs.stop_id = s.id
|
||||
WHERE r.is_active = true AND s.is_active = true
|
||||
`);
|
||||
|
||||
// Get schedules
|
||||
const schedulesResult = await pool.query(`
|
||||
SELECT s.*
|
||||
FROM schedules s
|
||||
JOIN routes r ON s.route_id = r.id
|
||||
WHERE r.is_active = true
|
||||
AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE)
|
||||
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
|
||||
`);
|
||||
|
||||
// Get active alerts
|
||||
const alertsResult = await pool.query(`
|
||||
SELECT * FROM alerts
|
||||
WHERE is_active = true
|
||||
AND start_time <= CURRENT_TIMESTAMP
|
||||
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
// Get holidays for current and next year
|
||||
const holidaysResult = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN is_recurring THEN date
|
||||
ELSE date
|
||||
END as date,
|
||||
name,
|
||||
is_recurring,
|
||||
recurring_month,
|
||||
recurring_day
|
||||
FROM holidays
|
||||
WHERE EXTRACT(YEAR FROM date) >= EXTRACT(YEAR FROM CURRENT_DATE)
|
||||
OR is_recurring = true
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
const syncData = {
|
||||
syncType: 'full',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
data: {
|
||||
routes: routesResult.rows,
|
||||
stops: stopsResult.rows,
|
||||
routeStops: routeStopsResult.rows,
|
||||
schedules: schedulesResult.rows,
|
||||
alerts: alertsResult.rows,
|
||||
holidays: holidaysResult.rows
|
||||
},
|
||||
metadata: {
|
||||
routesCount: routesResult.rows.length,
|
||||
stopsCount: stopsResult.rows.length,
|
||||
schedulesCount: schedulesResult.rows.length,
|
||||
holidaysCount: holidaysResult.rows.length
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Full sync completed');
|
||||
res.json(syncData);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Full sync error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental synchronization (changes since lastSync)
|
||||
async incrementalSync(req, res, lastSync) {
|
||||
try {
|
||||
const lastSyncDate = new Date(lastSync);
|
||||
|
||||
// Get changes from sync_log
|
||||
const changesResult = await pool.query(`
|
||||
SELECT DISTINCT ON (table_name, record_id)
|
||||
table_name,
|
||||
record_id,
|
||||
action,
|
||||
timestamp
|
||||
FROM sync_log
|
||||
WHERE timestamp > $1
|
||||
ORDER BY table_name, record_id, timestamp DESC
|
||||
`, [lastSyncDate]);
|
||||
|
||||
const changes = {
|
||||
routes: { updated: [], deleted: [] },
|
||||
stops: { updated: [], deleted: [] },
|
||||
routeStops: { updated: [], deleted: [] },
|
||||
schedules: { updated: [], deleted: [] }
|
||||
};
|
||||
|
||||
// Process changes
|
||||
for (const change of changesResult.rows) {
|
||||
const { table_name, record_id, action } = change;
|
||||
|
||||
if (action === 'delete') {
|
||||
if (changes[table_name]) {
|
||||
changes[table_name].deleted.push(record_id);
|
||||
}
|
||||
} else {
|
||||
// Get updated record
|
||||
const record = await this.getRecord(table_name, record_id);
|
||||
if (record && changes[table_name]) {
|
||||
changes[table_name].updated.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get new alerts
|
||||
const alertsResult = await pool.query(`
|
||||
SELECT * FROM alerts
|
||||
WHERE is_active = true
|
||||
AND created_at > $1
|
||||
`, [lastSyncDate]);
|
||||
|
||||
const syncData = {
|
||||
syncType: 'incremental',
|
||||
timestamp: new Date().toISOString(),
|
||||
lastSync: lastSync,
|
||||
changes: changes,
|
||||
alerts: alertsResult.rows,
|
||||
metadata: {
|
||||
changesCount: changesResult.rows.length
|
||||
}
|
||||
};
|
||||
|
||||
logger.info(`Incremental sync completed: ${changesResult.rows.length} changes`);
|
||||
res.json(syncData);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Incremental sync error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get record from table
|
||||
async getRecord(tableName, recordId) {
|
||||
try {
|
||||
let query = '';
|
||||
|
||||
switch (tableName) {
|
||||
case 'routes':
|
||||
query = 'SELECT * FROM routes WHERE id = $1 AND is_active = true';
|
||||
break;
|
||||
case 'stops':
|
||||
query = `
|
||||
SELECT
|
||||
*,
|
||||
ST_Y(location::geometry) as latitude,
|
||||
ST_X(location::geometry) as longitude
|
||||
FROM stops
|
||||
WHERE id = $1 AND is_active = true
|
||||
`;
|
||||
break;
|
||||
case 'route_stops':
|
||||
query = 'SELECT * FROM route_stops WHERE id = $1';
|
||||
break;
|
||||
case 'schedules':
|
||||
query = `
|
||||
SELECT * FROM schedules
|
||||
WHERE id = $1
|
||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||
`;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await pool.query(query, [recordId]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error getting record from ${tableName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status
|
||||
async getSyncStatus(req, res) {
|
||||
try {
|
||||
const stats = await pool.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM routes WHERE is_active = true) as routes_count,
|
||||
(SELECT COUNT(*) FROM stops WHERE is_active = true) as stops_count,
|
||||
(SELECT COUNT(*) FROM schedules) as schedules_count,
|
||||
(SELECT MAX(timestamp) FROM sync_log) as last_change
|
||||
`);
|
||||
|
||||
res.json({
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: stats.rows[0]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting sync status:', error);
|
||||
res.status(500).json({ error: 'Failed to get sync status' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SyncController();
|
||||
84
backend/src/controllers/telemetryController.js
Normal file
84
backend/src/controllers/telemetryController.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const logger = require('../config/logger');
|
||||
const telemetryService = require('../services/telemetryService');
|
||||
const { broadcastToRoute } = require('../services/realtimeService');
|
||||
|
||||
function sendError(res, error, fallbackMessage) {
|
||||
if (error.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
|
||||
logger.error(fallbackMessage, error);
|
||||
return res.status(500).json({ error: fallbackMessage });
|
||||
}
|
||||
|
||||
class TelemetryController {
|
||||
async ingest(req, res) {
|
||||
try {
|
||||
const sourceType = req.body.sourceType || (req.user ? 'user' : 'api-key');
|
||||
const sourceRef = req.body.sourceRef || (
|
||||
req.user
|
||||
? `user:${req.user.id}`
|
||||
: req.apiKeyInfo
|
||||
? `api-key:${req.apiKeyInfo.id}`
|
||||
: null
|
||||
);
|
||||
|
||||
const vehicle = await telemetryService.ingestTelemetry({
|
||||
...req.body,
|
||||
sourceType,
|
||||
sourceRef,
|
||||
rawPayload: req.body
|
||||
});
|
||||
|
||||
broadcastToRoute(vehicle.route_id, {
|
||||
type: 'vehicle:position',
|
||||
routeId: vehicle.route_id,
|
||||
vehicle
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Telemetry ingested successfully',
|
||||
vehicle
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error, 'Failed to ingest telemetry');
|
||||
}
|
||||
}
|
||||
|
||||
async getRouteLiveVehicles(req, res) {
|
||||
try {
|
||||
const includeStale = req.query.includeStale === 'true';
|
||||
const staleAfterSeconds = req.query.staleAfterSeconds
|
||||
? Number.parseInt(req.query.staleAfterSeconds, 10)
|
||||
: undefined;
|
||||
|
||||
const vehicles = await telemetryService.getRouteLiveVehicles(req.params.routeId, {
|
||||
includeStale,
|
||||
staleAfterSeconds
|
||||
});
|
||||
|
||||
return res.json(vehicles);
|
||||
} catch (error) {
|
||||
return sendError(res, error, 'Failed to fetch route live vehicles');
|
||||
}
|
||||
}
|
||||
|
||||
async getVehicleLiveState(req, res) {
|
||||
try {
|
||||
const staleAfterSeconds = req.query.staleAfterSeconds
|
||||
? Number.parseInt(req.query.staleAfterSeconds, 10)
|
||||
: undefined;
|
||||
|
||||
const vehicle = await telemetryService.getVehicleLiveState(
|
||||
req.params.vehicleId,
|
||||
staleAfterSeconds
|
||||
);
|
||||
|
||||
return res.json(vehicle);
|
||||
} catch (error) {
|
||||
return sendError(res, error, 'Failed to fetch vehicle live state');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TelemetryController();
|
||||
143
backend/src/controllers/uploadController.js
Normal file
143
backend/src/controllers/uploadController.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class UploadController {
|
||||
async uploadAvatar(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
const file = req.file;
|
||||
|
||||
// Generate URL for the avatar
|
||||
const avatarUrl = `/uploads/avatars/${file.filename}`;
|
||||
|
||||
// Get old avatar to delete it
|
||||
const oldUserResult = await pool.query(
|
||||
'SELECT avatar_url FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Update user's avatar_url in database
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET avatar_url = $1 WHERE id = $2 RETURNING id, username, avatar_url',
|
||||
[avatarUrl, userId]
|
||||
);
|
||||
|
||||
// Delete old avatar if exists
|
||||
if (oldUserResult.rows[0]?.avatar_url) {
|
||||
const oldAvatarPath = path.join(__dirname, '../../public', oldUserResult.rows[0].avatar_url);
|
||||
try {
|
||||
await fs.unlink(oldAvatarPath);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete old avatar:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Avatar uploaded for user: ${result.rows[0].username}`);
|
||||
|
||||
res.json({
|
||||
message: 'Avatar uploaded successfully',
|
||||
avatar_url: avatarUrl,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Upload avatar error:', error);
|
||||
|
||||
// Clean up uploaded file on error
|
||||
if (req.file) {
|
||||
try {
|
||||
await fs.unlink(req.file.path);
|
||||
} catch (unlinkError) {
|
||||
logger.error('Failed to clean up file:', unlinkError);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAvatar(req, res) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get current avatar
|
||||
const userResult = await pool.query(
|
||||
'SELECT avatar_url FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!userResult.rows[0]?.avatar_url) {
|
||||
return res.status(404).json({ error: 'No avatar to delete' });
|
||||
}
|
||||
|
||||
const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url);
|
||||
|
||||
// Delete from database
|
||||
await pool.query(
|
||||
'UPDATE users SET avatar_url = NULL WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Delete file
|
||||
try {
|
||||
await fs.unlink(avatarPath);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete avatar file:', error.message);
|
||||
}
|
||||
|
||||
logger.info(`Avatar deleted for user ID: ${userId}`);
|
||||
|
||||
res.json({ message: 'Avatar deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Delete avatar error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin endpoint to delete any user's avatar
|
||||
async deleteUserAvatar(req, res) {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
const userResult = await pool.query(
|
||||
'SELECT avatar_url FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (!userResult.rows[0]?.avatar_url) {
|
||||
return res.status(404).json({ error: 'User has no avatar' });
|
||||
}
|
||||
|
||||
const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET avatar_url = NULL WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.unlink(avatarPath);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete avatar file:', error.message);
|
||||
}
|
||||
|
||||
logger.info(`Avatar deleted for user ID: ${userId} by admin: ${req.user.username}`);
|
||||
|
||||
res.json({ message: 'Avatar deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Delete user avatar error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UploadController();
|
||||
182
backend/src/controllers/usersController.js
Normal file
182
backend/src/controllers/usersController.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
class UsersController {
|
||||
// Get all users (admin only)
|
||||
async getAllUsers(req, res) {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, username, full_name, email, role, is_active, last_login, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
logger.error('Get users error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get user by ID
|
||||
async getUserById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT id, username, full_name, email, role, is_active, last_login, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create user (admin only)
|
||||
async createUser(req, res) {
|
||||
try {
|
||||
const { username, password, full_name, email, role = 'user' } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Username already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const result = await pool.query(`
|
||||
INSERT INTO users (username, password_hash, full_name, email, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
RETURNING id, username, full_name, email, role, is_active, created_at
|
||||
`, [username, passwordHash, full_name || null, email || null, role]);
|
||||
|
||||
logger.info(`User created: ${username} by ${req.user.username}`);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Create user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update user (admin only)
|
||||
async updateUser(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { full_name, email, role, is_active } = req.body;
|
||||
|
||||
// Don't allow deactivating yourself
|
||||
if (id == req.user.id && is_active === false) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate yourself' });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE users
|
||||
SET full_name = COALESCE($1, full_name),
|
||||
email = COALESCE($2, email),
|
||||
role = COALESCE($3, role),
|
||||
is_active = COALESCE($4, is_active)
|
||||
WHERE id = $5
|
||||
RETURNING id, username, full_name, email, role, is_active
|
||||
`, [full_name, email, role, is_active, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
logger.info(`User updated: ${result.rows[0].username} by ${req.user.username}`);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Update user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user (admin only)
|
||||
async deleteUser(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Don't allow deleting yourself
|
||||
if (id == req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete yourself' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM users WHERE id = $1 RETURNING username',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
logger.info(`User deleted: ${result.rows[0].username} by ${req.user.username}`);
|
||||
|
||||
res.json({ message: 'User deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Delete user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset user password (admin only)
|
||||
async resetPassword(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING username',
|
||||
[passwordHash, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Invalidate all user sessions
|
||||
await pool.query('DELETE FROM user_sessions WHERE user_id = $1', [id]);
|
||||
|
||||
logger.info(`Password reset for user: ${result.rows[0].username} by ${req.user.username}`);
|
||||
|
||||
res.json({ message: 'Password reset successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Reset password error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UsersController();
|
||||
155
backend/src/index.js
Normal file
155
backend/src/index.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
require('dotenv').config();
|
||||
|
||||
const logger = require('./config/logger');
|
||||
const apiRoutes = require('./routes/api');
|
||||
const { authenticateApiKey } = require('./middleware/auth');
|
||||
const createRateLimiter = require('./middleware/rateLimiter');
|
||||
const { ensureRealtimeSchema } = require('./services/realtimeSchema');
|
||||
const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
let server;
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: false,
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
// Static files for avatars
|
||||
const path = require('path');
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||
|
||||
// Logging middleware
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// Auth routes (no API key required)
|
||||
const authRouter = require('./routes/auth');
|
||||
app.use('/api/v1/auth',
|
||||
createRateLimiter(100, 3600000), // 100 requests per hour for auth
|
||||
authRouter
|
||||
);
|
||||
|
||||
// Upload routes (JWT auth required, no API key) - TEMPORARILY DISABLED
|
||||
// Uncomment after installing multer: npm install multer
|
||||
try {
|
||||
const uploadRouter = require('./routes/upload');
|
||||
app.use('/api/v1/upload',
|
||||
createRateLimiter(50, 3600000), // 50 uploads per hour
|
||||
uploadRouter
|
||||
);
|
||||
logger.info('Upload routes enabled');
|
||||
} catch (error) {
|
||||
logger.warn('Upload routes disabled - multer not installed. Run: npm install multer');
|
||||
}
|
||||
|
||||
// API routes with API key authentication and rate limiting
|
||||
app.use('/api/v1',
|
||||
authenticateApiKey,
|
||||
createRateLimiter(1000, 3600000), // 1000 requests per hour
|
||||
apiRoutes
|
||||
);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: `Route ${req.method} ${req.path} not found`
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
// Multer errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'File too large',
|
||||
message: 'Maximum file size is 5MB'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.message && err.message.includes('Only image files are allowed')) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid file type',
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
|
||||
});
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal) {
|
||||
logger.info('SIGTERM signal received: closing HTTP server');
|
||||
logger.info(`${signal} signal received: closing HTTP server`);
|
||||
|
||||
closeRealtimeServer();
|
||||
|
||||
if (!server) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
await ensureRealtimeSchema();
|
||||
|
||||
server = app.listen(PORT, () => {
|
||||
logger.info(`Server is running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
logger.info(`API Base: http://localhost:${PORT}/api/v1`);
|
||||
});
|
||||
|
||||
attachRealtimeServer(server);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
115
backend/src/middleware/auth.js
Normal file
115
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
function verifyJwtToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getApiKeyInfo(apiKey, touchLastUsed = false) {
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = touchLastUsed
|
||||
? `UPDATE api_keys
|
||||
SET last_used_at = CURRENT_TIMESTAMP
|
||||
WHERE key = $1 AND is_active = true
|
||||
RETURNING id, app_name, rate_limit`
|
||||
: `SELECT id, app_name, rate_limit
|
||||
FROM api_keys
|
||||
WHERE key = $1 AND is_active = true`;
|
||||
|
||||
const result = await pool.query(query, [apiKey]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
const user = verifyJwtToken(token);
|
||||
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function authenticateApiKey(req, res, next) {
|
||||
// Check for JWT token first
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
// If JWT token is present, verify it
|
||||
try {
|
||||
const user = verifyJwtToken(token);
|
||||
if (!user) {
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
} catch (error) {
|
||||
// JWT token is invalid, continue to API key check
|
||||
logger.warn('Invalid JWT token in request');
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to API key authentication
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Please provide either Authorization header with JWT token or X-API-Key header'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyInfo = await getApiKeyInfo(apiKey, true);
|
||||
|
||||
if (!apiKeyInfo) {
|
||||
logger.warn(`Invalid API key attempt: ${apiKey.substring(0, 8)}...`);
|
||||
return res.status(403).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'The provided API key is invalid or inactive'
|
||||
});
|
||||
}
|
||||
|
||||
req.apiKeyInfo = apiKeyInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
authenticateApiKey,
|
||||
verifyJwtToken,
|
||||
getApiKeyInfo
|
||||
};
|
||||
66
backend/src/middleware/rateLimiter.js
Normal file
66
backend/src/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const redisClient = require('../config/redis');
|
||||
|
||||
// In-memory fallback store if Redis is unavailable
|
||||
const memoryStore = new Map();
|
||||
|
||||
async function getRateLimitStore() {
|
||||
// Custom Redis store
|
||||
return {
|
||||
async increment(key) {
|
||||
try {
|
||||
const current = await redisClient.get(key);
|
||||
const count = current ? parseInt(current) + 1 : 1;
|
||||
await redisClient.setEx(key, 3600, count.toString()); // 1 hour TTL
|
||||
return { totalHits: count, resetTime: null };
|
||||
} catch (error) {
|
||||
// Fallback to memory store
|
||||
const count = (memoryStore.get(key) || 0) + 1;
|
||||
memoryStore.set(key, count);
|
||||
setTimeout(() => memoryStore.delete(key), 3600000); // 1 hour
|
||||
return { totalHits: count, resetTime: null };
|
||||
}
|
||||
},
|
||||
async decrement(key) {
|
||||
try {
|
||||
await redisClient.decr(key);
|
||||
} catch (error) {
|
||||
const count = memoryStore.get(key) || 0;
|
||||
memoryStore.set(key, Math.max(0, count - 1));
|
||||
}
|
||||
},
|
||||
async resetKey(key) {
|
||||
try {
|
||||
await redisClient.del(key);
|
||||
} catch (error) {
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createRateLimiter = (maxRequests = 1000, windowMs = 3600000) => {
|
||||
return rateLimit({
|
||||
windowMs, // 1 hour default
|
||||
max: maxRequests,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
// Use API key if available, otherwise IP
|
||||
return req.apiKeyInfo?.id || req.ip;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
retryAfter: Math.ceil(windowMs / 1000)
|
||||
});
|
||||
},
|
||||
skip: (req) => {
|
||||
// Skip rate limiting for health checks
|
||||
return req.path === '/health';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = createRateLimiter;
|
||||
47
backend/src/middleware/upload.js
Normal file
47
backend/src/middleware/upload.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure upload directories exist
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads/avatars');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Generate unique filename: userId-timestamp.ext
|
||||
const userId = req.user.id;
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `user-${userId}-${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter - only images
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (extname && mimetype) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed (jpeg, jpg, png, gif, webp)'));
|
||||
}
|
||||
};
|
||||
|
||||
// Configure multer
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
},
|
||||
fileFilter: fileFilter
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
209
backend/src/routes/alerts.js
Normal file
209
backend/src/routes/alerts.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const pool = require('../config/database');
|
||||
|
||||
// Get all alerts (with optional filters)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { route_id, is_active, alert_type } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT a.*, r.name as route_name, r.route_number, r.color
|
||||
FROM alerts a
|
||||
LEFT JOIN routes r ON a.route_id = r.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (route_id) {
|
||||
query += ` AND a.route_id = $${paramCount}`;
|
||||
params.push(route_id);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (is_active !== undefined) {
|
||||
query += ` AND a.is_active = $${paramCount}`;
|
||||
params.push(is_active === 'true');
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (alert_type) {
|
||||
query += ` AND a.alert_type = $${paramCount}`;
|
||||
params.push(alert_type);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
query += ' ORDER BY a.start_time DESC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching alerts:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get active alerts (current time between start_time and end_time)
|
||||
router.get('/active', async (req, res) => {
|
||||
try {
|
||||
const { route_id } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT a.*, r.name as route_name, r.route_number, r.color
|
||||
FROM alerts a
|
||||
LEFT JOIN routes r ON a.route_id = r.id
|
||||
WHERE a.is_active = true
|
||||
AND a.start_time <= NOW()
|
||||
AND (a.end_time IS NULL OR a.end_time >= NOW())
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (route_id) {
|
||||
query += ' AND a.route_id = $1';
|
||||
params.push(route_id);
|
||||
}
|
||||
|
||||
query += ' ORDER BY a.start_time DESC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching active alerts:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get alert by ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
`SELECT a.*, r.name as route_name, r.route_number, r.color
|
||||
FROM alerts a
|
||||
LEFT JOIN routes r ON a.route_id = r.id
|
||||
WHERE a.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Alert not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching alert:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new alert
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!alert_type || !title || !message || !start_time) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
|
||||
if (!validTypes.includes(alert_type)) {
|
||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating alert:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update alert
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||
|
||||
// Check if alert exists
|
||||
const checkResult = await pool.query('SELECT id FROM alerts WHERE id = $1', [id]);
|
||||
if (checkResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Alert not found' });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (alert_type) {
|
||||
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
|
||||
if (!validTypes.includes(alert_type)) {
|
||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE alerts
|
||||
SET route_id = COALESCE($1, route_id),
|
||||
alert_type = COALESCE($2, alert_type),
|
||||
title = COALESCE($3, title),
|
||||
message = COALESCE($4, message),
|
||||
start_time = COALESCE($5, start_time),
|
||||
end_time = $6,
|
||||
is_active = COALESCE($7, is_active)
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[route_id, alert_type, title, message, start_time, end_time, is_active, id]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating alert:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete alert
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query('DELETE FROM alerts WHERE id = $1 RETURNING id', [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Alert not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Alert deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Deactivate expired alerts (utility endpoint)
|
||||
router.post('/cleanup', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE alerts
|
||||
SET is_active = false
|
||||
WHERE is_active = true
|
||||
AND end_time IS NOT NULL
|
||||
AND end_time < NOW()
|
||||
RETURNING id`
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Cleanup completed',
|
||||
deactivated_count: result.rows.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up alerts:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
78
backend/src/routes/api.js
Normal file
78
backend/src/routes/api.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Controllers
|
||||
const routesController = require('../controllers/routesController');
|
||||
const stopsController = require('../controllers/stopsController');
|
||||
const routeStopsController = require('../controllers/routeStopsController');
|
||||
const schedulesController = require('../controllers/schedulesController');
|
||||
const holidaysController = require('../controllers/holidaysController');
|
||||
const etaController = require('../controllers/etaController');
|
||||
const syncController = require('../controllers/syncController');
|
||||
const telemetryController = require('../controllers/telemetryController');
|
||||
|
||||
// Routes
|
||||
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
||||
router.get('/routes/:id', routesController.getRouteById.bind(routesController));
|
||||
router.post('/routes', routesController.createRoute.bind(routesController));
|
||||
router.put('/routes/:id', routesController.updateRoute.bind(routesController));
|
||||
router.delete('/routes/:id', routesController.deleteRoute.bind(routesController));
|
||||
|
||||
// Route Stops (привязка остановок к маршрутам)
|
||||
router.get('/routes/:routeId/stops', routeStopsController.getRouteStops.bind(routeStopsController));
|
||||
router.get('/routes/:routeId/available-stops', routeStopsController.getAvailableStops.bind(routeStopsController));
|
||||
router.post('/routes/:routeId/stops', routeStopsController.addStopToRoute.bind(routeStopsController));
|
||||
router.put('/routes/:routeId/stops/:routeStopId', routeStopsController.updateRouteStop.bind(routeStopsController));
|
||||
router.delete('/routes/:routeId/stops/:routeStopId', routeStopsController.removeStopFromRoute.bind(routeStopsController));
|
||||
router.put('/routes/:routeId/stops-reorder', routeStopsController.reorderRouteStops.bind(routeStopsController));
|
||||
|
||||
// Route Schedules (расписания маршрутов)
|
||||
router.get('/routes/:routeId/schedules', schedulesController.getRouteSchedules.bind(schedulesController));
|
||||
router.get('/routes/:routeId/schedule/today', schedulesController.getTodaySchedule.bind(schedulesController));
|
||||
router.post('/routes/:routeId/schedules/copy', schedulesController.copySchedule.bind(schedulesController));
|
||||
|
||||
// Schedules (общие)
|
||||
router.get('/schedules/:id', schedulesController.getScheduleById.bind(schedulesController));
|
||||
router.post('/schedules', schedulesController.createSchedule.bind(schedulesController));
|
||||
router.put('/schedules/:id', schedulesController.updateSchedule.bind(schedulesController));
|
||||
router.delete('/schedules/:id', schedulesController.deleteSchedule.bind(schedulesController));
|
||||
|
||||
// Holidays (праздничные дни)
|
||||
router.get('/holidays', holidaysController.getAllHolidays.bind(holidaysController));
|
||||
router.get('/holidays/upcoming', holidaysController.getUpcomingHolidays.bind(holidaysController));
|
||||
router.get('/holidays/year/:year', holidaysController.getHolidaysForYear.bind(holidaysController));
|
||||
router.get('/holidays/check/:date', holidaysController.checkHoliday.bind(holidaysController));
|
||||
router.post('/holidays', holidaysController.createHoliday.bind(holidaysController));
|
||||
router.put('/holidays/:id', holidaysController.updateHoliday.bind(holidaysController));
|
||||
router.delete('/holidays/:id', holidaysController.deleteHoliday.bind(holidaysController));
|
||||
|
||||
// Stops
|
||||
router.get('/stops', stopsController.getAllStops.bind(stopsController));
|
||||
router.get('/stops/nearby', stopsController.getNearbyStops.bind(stopsController));
|
||||
router.get('/stops/:id', stopsController.getStopById.bind(stopsController));
|
||||
router.post('/stops', stopsController.createStop.bind(stopsController));
|
||||
router.put('/stops/:id', stopsController.updateStop.bind(stopsController));
|
||||
router.delete('/stops/:id', stopsController.deleteStop.bind(stopsController));
|
||||
|
||||
// ETA
|
||||
router.post('/eta/calculate', etaController.calculateEta.bind(etaController));
|
||||
router.get('/eta/stop/:stopId', etaController.getStopEtas.bind(etaController));
|
||||
|
||||
// Realtime GPS / Telemetry
|
||||
router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryController));
|
||||
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));
|
||||
router.get('/realtime/vehicles/:vehicleId', telemetryController.getVehicleLiveState.bind(telemetryController));
|
||||
|
||||
// Sync
|
||||
router.get('/sync', syncController.sync.bind(syncController));
|
||||
router.get('/sync/status', syncController.getSyncStatus.bind(syncController));
|
||||
|
||||
// Alerts
|
||||
const alertsRouter = require('./alerts');
|
||||
router.use('/alerts', alertsRouter);
|
||||
|
||||
// Users management (requires JWT auth, handled in routes/users.js)
|
||||
const usersRouter = require('./users');
|
||||
router.use('/users', usersRouter);
|
||||
|
||||
module.exports = router;
|
||||
15
backend/src/routes/auth.js
Normal file
15
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
// Public routes
|
||||
router.post('/login', authController.login.bind(authController));
|
||||
router.post('/refresh', authController.refresh.bind(authController));
|
||||
router.post('/logout', authController.logout.bind(authController));
|
||||
|
||||
// Protected routes
|
||||
router.get('/me', authenticateToken, authController.me.bind(authController));
|
||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||
|
||||
module.exports = router;
|
||||
29
backend/src/routes/upload.js
Normal file
29
backend/src/routes/upload.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const uploadController = require('../controllers/uploadController');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const upload = require('../middleware/upload');
|
||||
|
||||
// User routes - authenticated users can upload/delete their own avatar
|
||||
router.post(
|
||||
'/avatar',
|
||||
authenticateToken,
|
||||
upload.single('avatar'),
|
||||
uploadController.uploadAvatar.bind(uploadController)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/avatar',
|
||||
authenticateToken,
|
||||
uploadController.deleteAvatar.bind(uploadController)
|
||||
);
|
||||
|
||||
// Admin routes - admins can delete any user's avatar
|
||||
router.delete(
|
||||
'/avatar/:userId',
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
uploadController.deleteUserAvatar.bind(uploadController)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
17
backend/src/routes/users.js
Normal file
17
backend/src/routes/users.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const usersController = require('../controllers/usersController');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// All routes require authentication and admin role
|
||||
router.use(authenticateToken);
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get('/', usersController.getAllUsers.bind(usersController));
|
||||
router.get('/:id', usersController.getUserById.bind(usersController));
|
||||
router.post('/', usersController.createUser.bind(usersController));
|
||||
router.put('/:id', usersController.updateUser.bind(usersController));
|
||||
router.delete('/:id', usersController.deleteUser.bind(usersController));
|
||||
router.post('/:id/reset-password', usersController.resetPassword.bind(usersController));
|
||||
|
||||
module.exports = router;
|
||||
30
backend/src/services/realtimeSchema.js
Normal file
30
backend/src/services/realtimeSchema.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
let ensurePromise = null;
|
||||
|
||||
async function ensureRealtimeSchema() {
|
||||
if (ensurePromise) {
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
ensurePromise = (async () => {
|
||||
const migrationPath = path.join(__dirname, '../../migrations/005_realtime_gps.sql');
|
||||
const sql = await fs.readFile(migrationPath, 'utf8');
|
||||
|
||||
await pool.query(sql);
|
||||
logger.info('Realtime GPS schema ensured');
|
||||
})().catch((error) => {
|
||||
ensurePromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureRealtimeSchema
|
||||
};
|
||||
304
backend/src/services/realtimeService.js
Normal file
304
backend/src/services/realtimeService.js
Normal file
@@ -0,0 +1,304 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const logger = require('../config/logger');
|
||||
const telemetryService = require('./telemetryService');
|
||||
const { verifyJwtToken, getApiKeyInfo } = require('../middleware/auth');
|
||||
|
||||
const routeSubscriptions = new Map();
|
||||
|
||||
let webSocketServer = null;
|
||||
let heartbeatInterval = null;
|
||||
|
||||
function sendMessage(ws, payload) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function addRouteSubscription(ws, routeId) {
|
||||
const routeKey = String(routeId);
|
||||
|
||||
if (!routeSubscriptions.has(routeKey)) {
|
||||
routeSubscriptions.set(routeKey, new Set());
|
||||
}
|
||||
|
||||
routeSubscriptions.get(routeKey).add(ws);
|
||||
ws.subscriptions.add(routeKey);
|
||||
}
|
||||
|
||||
function removeRouteSubscription(ws, routeId) {
|
||||
const routeKey = String(routeId);
|
||||
const subscribers = routeSubscriptions.get(routeKey);
|
||||
|
||||
if (!subscribers) {
|
||||
return;
|
||||
}
|
||||
|
||||
subscribers.delete(ws);
|
||||
ws.subscriptions.delete(routeKey);
|
||||
|
||||
if (subscribers.size === 0) {
|
||||
routeSubscriptions.delete(routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSocket(ws) {
|
||||
if (!ws.subscriptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const routeId of ws.subscriptions) {
|
||||
removeRouteSubscription(ws, routeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthMessage(ws, payload) {
|
||||
const token = typeof payload.token === 'string' ? payload.token.trim() : '';
|
||||
const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey.trim() : '';
|
||||
|
||||
if (!token && !apiKey) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'Provide token or apiKey in auth message'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const user = verifyJwtToken(token);
|
||||
|
||||
if (!user) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'Invalid or expired token'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ws.auth = {
|
||||
type: 'jwt',
|
||||
user
|
||||
};
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'auth:ok',
|
||||
authType: 'jwt',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKeyInfo = await getApiKeyInfo(apiKey, true);
|
||||
|
||||
if (!apiKeyInfo) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'Invalid API key'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ws.auth = {
|
||||
type: 'apiKey',
|
||||
apiKeyInfo
|
||||
};
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'auth:ok',
|
||||
authType: 'apiKey',
|
||||
appName: apiKeyInfo.app_name
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscribeRoute(ws, payload) {
|
||||
if (!ws.auth) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'Authenticate first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const routeId = Number.parseInt(payload.routeId, 10);
|
||||
|
||||
if (!Number.isInteger(routeId) || routeId <= 0) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'routeId must be a positive integer'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addRouteSubscription(ws, routeId);
|
||||
|
||||
const vehicles = await telemetryService.getRouteLiveVehicles(routeId).catch((error) => {
|
||||
logger.error('Failed to build realtime route snapshot:', error);
|
||||
return [];
|
||||
});
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'subscription:ok',
|
||||
routeId
|
||||
});
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'snapshot:route',
|
||||
routeId,
|
||||
vehicles
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnsubscribeRoute(ws, payload) {
|
||||
const routeId = Number.parseInt(payload.routeId, 10);
|
||||
|
||||
if (!Number.isInteger(routeId) || routeId <= 0) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'routeId must be a positive integer'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
removeRouteSubscription(ws, routeId);
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'subscription:removed',
|
||||
routeId
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastToRoute(routeId, payload) {
|
||||
const subscribers = routeSubscriptions.get(String(routeId));
|
||||
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ws of subscribers) {
|
||||
sendMessage(ws, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function attachRealtimeServer(server) {
|
||||
if (webSocketServer) {
|
||||
return webSocketServer;
|
||||
}
|
||||
|
||||
webSocketServer = new WebSocket.Server({
|
||||
server,
|
||||
path: '/ws/realtime'
|
||||
});
|
||||
|
||||
webSocketServer.on('connection', (ws, request) => {
|
||||
ws.isAlive = true;
|
||||
ws.subscriptions = new Set();
|
||||
ws.auth = null;
|
||||
|
||||
logger.info(`Realtime websocket connected from ${request.socket.remoteAddress}`);
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'welcome',
|
||||
message: 'Realtime GPS websocket connected'
|
||||
});
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
ws.on('message', async (rawMessage) => {
|
||||
try {
|
||||
const payload = JSON.parse(rawMessage.toString());
|
||||
|
||||
switch (payload.type) {
|
||||
case 'auth':
|
||||
await handleAuthMessage(ws, payload);
|
||||
break;
|
||||
case 'subscribe:route':
|
||||
await handleSubscribeRoute(ws, payload);
|
||||
break;
|
||||
case 'unsubscribe:route':
|
||||
handleUnsubscribeRoute(ws, payload);
|
||||
break;
|
||||
case 'ping':
|
||||
sendMessage(ws, { type: 'pong', timestamp: new Date().toISOString() });
|
||||
break;
|
||||
default:
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: `Unsupported message type: ${payload.type}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Realtime websocket message error:', error);
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: 'Invalid realtime message payload'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSocket(ws);
|
||||
logger.info('Realtime websocket disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('Realtime websocket error:', error);
|
||||
cleanupSocket(ws);
|
||||
});
|
||||
});
|
||||
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (!webSocketServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
webSocketServer.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) {
|
||||
cleanupSocket(ws);
|
||||
return ws.terminate();
|
||||
}
|
||||
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
webSocketServer.on('close', () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Realtime websocket server attached at /ws/realtime');
|
||||
|
||||
return webSocketServer;
|
||||
}
|
||||
|
||||
function closeRealtimeServer() {
|
||||
if (!webSocketServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
|
||||
webSocketServer.close();
|
||||
webSocketServer = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachRealtimeServer,
|
||||
broadcastToRoute,
|
||||
closeRealtimeServer
|
||||
};
|
||||
386
backend/src/services/telemetryService.js
Normal file
386
backend/src/services/telemetryService.js
Normal file
@@ -0,0 +1,386 @@
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const { ensureRealtimeSchema } = require('./realtimeSchema');
|
||||
|
||||
const DEFAULT_STALE_AFTER_SECONDS = 180;
|
||||
|
||||
function createHttpError(status, message) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function parseCoordinate(value, fieldName) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw createHttpError(400, `${fieldName} must be a valid number`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value, fieldName) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(value);
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw createHttpError(400, `${fieldName} must be a valid number`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
class TelemetryService {
|
||||
async ingestTelemetry(payload) {
|
||||
await ensureRealtimeSchema();
|
||||
|
||||
const lat = parseCoordinate(payload.lat, 'lat');
|
||||
const lon = parseCoordinate(payload.lon, 'lon');
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
throw createHttpError(400, 'lat must be between -90 and 90');
|
||||
}
|
||||
|
||||
if (lon < -180 || lon > 180) {
|
||||
throw createHttpError(400, 'lon must be between -180 and 180');
|
||||
}
|
||||
|
||||
const speedKmh = parseOptionalNumber(payload.speedKmh, 'speedKmh');
|
||||
const heading = parseOptionalNumber(payload.heading, 'heading');
|
||||
const accuracyMeters = parseOptionalNumber(payload.accuracyMeters, 'accuracyMeters');
|
||||
|
||||
if (heading !== null && (heading < 0 || heading > 360)) {
|
||||
throw createHttpError(400, 'heading must be between 0 and 360');
|
||||
}
|
||||
|
||||
if (!payload.vehicleId && !payload.vehicleNumber) {
|
||||
throw createHttpError(400, 'vehicleId or vehicleNumber is required');
|
||||
}
|
||||
|
||||
const route = await this.getRoute(payload.routeId);
|
||||
const vehicle = await this.resolveVehicle({
|
||||
vehicleId: payload.vehicleId,
|
||||
vehicleNumber: payload.vehicleNumber,
|
||||
vehicleType: payload.vehicleType,
|
||||
route
|
||||
});
|
||||
|
||||
const effectiveRouteId = route?.id || vehicle.route_id;
|
||||
|
||||
if (!effectiveRouteId) {
|
||||
throw createHttpError(400, 'routeId is required when the vehicle is not already assigned to a route');
|
||||
}
|
||||
|
||||
if (vehicle.route_id !== effectiveRouteId) {
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET route_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[effectiveRouteId, vehicle.id]
|
||||
);
|
||||
}
|
||||
|
||||
const recordedAt = payload.recordedAt ? new Date(payload.recordedAt) : new Date();
|
||||
|
||||
if (Number.isNaN(recordedAt.getTime())) {
|
||||
throw createHttpError(400, 'recordedAt must be a valid ISO datetime string');
|
||||
}
|
||||
|
||||
const sourceType = payload.sourceType || 'manual';
|
||||
const sourceRef = payload.sourceRef || null;
|
||||
const rawPayload = payload.rawPayload || payload;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO telemetry_samples (
|
||||
vehicle_id,
|
||||
route_id,
|
||||
source_type,
|
||||
source_ref,
|
||||
location,
|
||||
speed_kmh,
|
||||
heading,
|
||||
accuracy_meters,
|
||||
recorded_at,
|
||||
raw_payload
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
ST_SetSRID(ST_MakePoint($6, $5), 4326)::geography,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11::jsonb
|
||||
)`,
|
||||
[
|
||||
vehicle.id,
|
||||
effectiveRouteId,
|
||||
sourceType,
|
||||
sourceRef,
|
||||
lat,
|
||||
lon,
|
||||
speedKmh,
|
||||
heading,
|
||||
accuracyMeters,
|
||||
recordedAt.toISOString(),
|
||||
JSON.stringify(rawPayload)
|
||||
]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO vehicle_live_state (
|
||||
vehicle_id,
|
||||
route_id,
|
||||
location,
|
||||
speed_kmh,
|
||||
heading,
|
||||
accuracy_meters,
|
||||
source_type,
|
||||
source_ref,
|
||||
last_seen_at
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
ST_SetSRID(ST_MakePoint($4, $3), 4326)::geography,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10
|
||||
)
|
||||
ON CONFLICT (vehicle_id) DO UPDATE SET
|
||||
route_id = EXCLUDED.route_id,
|
||||
location = EXCLUDED.location,
|
||||
speed_kmh = EXCLUDED.speed_kmh,
|
||||
heading = EXCLUDED.heading,
|
||||
accuracy_meters = EXCLUDED.accuracy_meters,
|
||||
source_type = EXCLUDED.source_type,
|
||||
source_ref = EXCLUDED.source_ref,
|
||||
last_seen_at = EXCLUDED.last_seen_at,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
vehicle.id,
|
||||
effectiveRouteId,
|
||||
lat,
|
||||
lon,
|
||||
speedKmh,
|
||||
heading,
|
||||
accuracyMeters,
|
||||
sourceType,
|
||||
sourceRef,
|
||||
recordedAt.toISOString()
|
||||
]
|
||||
);
|
||||
|
||||
const liveState = await this.getVehicleLiveState(vehicle.id, DEFAULT_STALE_AFTER_SECONDS);
|
||||
|
||||
logger.info(`Telemetry ingested for vehicle ${vehicle.id} on route ${effectiveRouteId}`);
|
||||
|
||||
return liveState;
|
||||
}
|
||||
|
||||
async getRouteLiveVehicles(routeId, options = {}) {
|
||||
await ensureRealtimeSchema();
|
||||
|
||||
const routeIdInt = Number.parseInt(routeId, 10);
|
||||
|
||||
if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) {
|
||||
throw createHttpError(400, 'routeId must be a positive integer');
|
||||
}
|
||||
|
||||
const staleAfterSeconds = Number.parseInt(
|
||||
options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS,
|
||||
10
|
||||
);
|
||||
const includeStale = Boolean(options.includeStale);
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
vls.vehicle_id,
|
||||
v.vehicle_number,
|
||||
v.registration,
|
||||
v.type AS vehicle_type,
|
||||
v.capacity,
|
||||
vls.route_id,
|
||||
r.route_number,
|
||||
r.name AS route_name,
|
||||
r.color AS route_color,
|
||||
ST_Y(vls.location::geometry) AS latitude,
|
||||
ST_X(vls.location::geometry) AS longitude,
|
||||
vls.speed_kmh,
|
||||
vls.heading,
|
||||
vls.accuracy_meters,
|
||||
vls.source_type,
|
||||
vls.source_ref,
|
||||
vls.last_seen_at,
|
||||
vls.updated_at,
|
||||
CASE
|
||||
WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true
|
||||
ELSE false
|
||||
END AS is_online,
|
||||
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update,
|
||||
CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving
|
||||
FROM vehicle_live_state vls
|
||||
JOIN vehicles v ON v.id = vls.vehicle_id
|
||||
JOIN routes r ON r.id = vls.route_id
|
||||
WHERE vls.route_id = $1
|
||||
AND v.is_active = true
|
||||
`;
|
||||
|
||||
if (!includeStale) {
|
||||
query += `
|
||||
AND vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second')
|
||||
`;
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY vls.last_seen_at DESC, v.vehicle_number ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [routeIdInt, staleAfterSeconds]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getVehicleLiveState(vehicleId, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
|
||||
await ensureRealtimeSchema();
|
||||
|
||||
const vehicleIdInt = Number.parseInt(vehicleId, 10);
|
||||
|
||||
if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) {
|
||||
throw createHttpError(400, 'vehicleId must be a positive integer');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
vls.vehicle_id,
|
||||
v.vehicle_number,
|
||||
v.registration,
|
||||
v.type AS vehicle_type,
|
||||
v.capacity,
|
||||
vls.route_id,
|
||||
r.route_number,
|
||||
r.name AS route_name,
|
||||
r.color AS route_color,
|
||||
ST_Y(vls.location::geometry) AS latitude,
|
||||
ST_X(vls.location::geometry) AS longitude,
|
||||
vls.speed_kmh,
|
||||
vls.heading,
|
||||
vls.accuracy_meters,
|
||||
vls.source_type,
|
||||
vls.source_ref,
|
||||
vls.last_seen_at,
|
||||
vls.updated_at,
|
||||
CASE
|
||||
WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true
|
||||
ELSE false
|
||||
END AS is_online,
|
||||
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update,
|
||||
CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving
|
||||
FROM vehicle_live_state vls
|
||||
JOIN vehicles v ON v.id = vls.vehicle_id
|
||||
JOIN routes r ON r.id = vls.route_id
|
||||
WHERE vls.vehicle_id = $1
|
||||
LIMIT 1`,
|
||||
[vehicleIdInt, staleAfterSeconds]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createHttpError(404, 'Live state not found for this vehicle');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async getRoute(routeId) {
|
||||
if (routeId === undefined || routeId === null || routeId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routeIdInt = Number.parseInt(routeId, 10);
|
||||
|
||||
if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) {
|
||||
throw createHttpError(400, 'routeId must be a positive integer');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
|
||||
[routeIdInt]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createHttpError(404, 'Route not found');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async resolveVehicle({ vehicleId, vehicleNumber, vehicleType, route }) {
|
||||
if (vehicleId !== undefined && vehicleId !== null && vehicleId !== '') {
|
||||
const vehicleIdInt = Number.parseInt(vehicleId, 10);
|
||||
|
||||
if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) {
|
||||
throw createHttpError(400, 'vehicleId must be a positive integer');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM vehicles WHERE id = $1',
|
||||
[vehicleIdInt]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw createHttpError(404, 'Vehicle not found');
|
||||
}
|
||||
|
||||
if (!result.rows[0].is_active) {
|
||||
throw createHttpError(400, 'Vehicle is inactive');
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
const cleanedVehicleNumber = typeof vehicleNumber === 'string'
|
||||
? vehicleNumber.trim()
|
||||
: '';
|
||||
|
||||
if (!cleanedVehicleNumber) {
|
||||
throw createHttpError(400, 'vehicleNumber must be a non-empty string');
|
||||
}
|
||||
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM vehicles WHERE vehicle_number = $1 LIMIT 1',
|
||||
[cleanedVehicleNumber]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
if (!existing.rows[0].is_active) {
|
||||
throw createHttpError(400, 'Vehicle is inactive');
|
||||
}
|
||||
|
||||
return existing.rows[0];
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
throw createHttpError(400, 'routeId is required to auto-create a vehicle by vehicleNumber');
|
||||
}
|
||||
|
||||
const resolvedType = vehicleType || route.type || 'bus';
|
||||
|
||||
const created = await pool.query(
|
||||
`INSERT INTO vehicles (vehicle_number, type, route_id, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING *`,
|
||||
[cleanedVehicleNumber, resolvedType, route.id]
|
||||
);
|
||||
|
||||
logger.info(`Auto-created vehicle ${cleanedVehicleNumber} for route ${route.id}`);
|
||||
|
||||
return created.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TelemetryService();
|
||||
34
backend/src/utils/migrate.js
Normal file
34
backend/src/utils/migrate.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const pool = require('../config/database');
|
||||
|
||||
async function runMigrations() {
|
||||
const migrationsDir = path.join(__dirname, '../../migrations');
|
||||
const migrationFiles = (await fs.readdir(migrationsDir))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
for (const file of migrationFiles) {
|
||||
const fullPath = path.join(migrationsDir, file);
|
||||
const sql = await fs.readFile(fullPath, 'utf8');
|
||||
|
||||
console.log(`Applying migration: ${file}`);
|
||||
await client.query(sql);
|
||||
}
|
||||
|
||||
console.log('All migrations applied successfully');
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations().catch(async (error) => {
|
||||
console.error('Migration failed:', error);
|
||||
await pool.end();
|
||||
process.exit(1);
|
||||
});
|
||||
96
docker-compose.yml
Normal file
96
docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:15-3.3
|
||||
container_name: polotsk-transit-db
|
||||
environment:
|
||||
POSTGRES_DB: polotsk_transit
|
||||
POSTGRES_USER: transit_admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: polotsk-transit-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: polotsk-transit-api
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://transit_admin:${DB_PASSWORD:-change_me_in_production}@postgres:5432/polotsk_transit
|
||||
REDIS_URL: redis://redis:6379
|
||||
API_KEY_SALT: ${API_KEY_SALT:-generate_random_salt}
|
||||
JWT_SECRET: ${JWT_SECRET:-change_me_in_production_use_random_string}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./backend/logs:/app/logs
|
||||
- avatars_data:/app/public/uploads/avatars
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
|
||||
container_name: polotsk-transit-admin
|
||||
environment:
|
||||
VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
|
||||
ports:
|
||||
- "3001:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: polotsk-transit-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- transit-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
avatars_data:
|
||||
|
||||
networks:
|
||||
transit-network:
|
||||
driver: bridge
|
||||
1554
docs/api-documentation.md
Normal file
1554
docs/api-documentation.md
Normal file
File diff suppressed because it is too large
Load Diff
2944
docs/openapi-draft.yaml
Normal file
2944
docs/openapi-draft.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:3000/api/v1
|
||||
VITE_API_KEY=dev_key_12345678
|
||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_URL=http://localhost:3000/api/v1
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/bus-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Polotsk Transit Admin Panel" />
|
||||
<title>Polotsk Transit Admin</title>
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
frontend/nginx.conf
Normal file
34
frontend/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1000;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Always revalidate the HTML shell so the app picks up the newest hashed assets
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
43
frontend/package.json
Normal file
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "polotsk-transit-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "Admin panel for Polotsk Transit API",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
281
frontend/src/App.jsx
Normal file
281
frontend/src/App.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
|
||||
import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { Avatar } from './components/AvatarUpload';
|
||||
|
||||
// Pages
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import RoutesPage from './pages/RoutesPage';
|
||||
import RouteEditorPage from './pages/RouteEditorPage';
|
||||
import StopsPage from './pages/StopsPage';
|
||||
import SchedulesPage from './pages/SchedulesPage';
|
||||
import HolidaysPage from './pages/HolidaysPage';
|
||||
import AlertsPage from './pages/AlertsPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8 pb-20 lg:pb-8">
|
||||
<AnimatedRoutes />
|
||||
</main>
|
||||
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<div key={location.pathname} className="page-transition">
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/routes" element={<ProtectedRoute><RoutesPage /></ProtectedRoute>} />
|
||||
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
||||
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
||||
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
||||
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
||||
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
{isAdmin && (
|
||||
<Route path="/users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-14 sm:h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex-shrink-0 flex items-center group">
|
||||
<Bus className="h-7 w-7 sm:h-8 sm:w-8 text-primary-600 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="ml-2 text-lg sm:text-xl font-bold text-gray-900 hidden xs:inline">
|
||||
Polotsk Transit
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:ml-6 lg:flex lg:space-x-6">
|
||||
<NavLink to="/" icon={<Settings className="h-4 w-4" />}>Dashboard</NavLink>
|
||||
<NavLink to="/routes" icon={<Bus className="h-4 w-4" />}>Маршруты</NavLink>
|
||||
<NavLink to="/stops" icon={<MapPin className="h-4 w-4" />}>Остановки</NavLink>
|
||||
<NavLink to="/schedules" icon={<Calendar className="h-4 w-4" />}>Расписания</NavLink>
|
||||
<NavLink to="/alerts" icon={<AlertCircle className="h-4 w-4" />}>Уведомления</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink to="/users" icon={<Users className="h-4 w-4" />}>Пользователи</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop User Menu */}
|
||||
<div className="hidden lg:flex items-center">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Avatar src={user?.avatar_url} alt={user?.username} size="sm" />
|
||||
<span className="text-sm font-medium text-gray-700">{user?.username}</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<UserCircle className="h-4 w-4" />
|
||||
Профиль
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { logout(); setShowUserMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div className="relative w-6 h-6">
|
||||
<X className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'
|
||||
}`} />
|
||||
<Menu className={`h-6 w-6 absolute transition-all duration-300 ${
|
||||
mobileMenuOpen ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'
|
||||
}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu dropdown */}
|
||||
<div className={`lg:hidden border-t border-gray-200 bg-white overflow-hidden transition-all duration-300 ease-out ${
|
||||
mobileMenuOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<MobileNavLink to="/" icon={<Settings className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={0}>
|
||||
Dashboard
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/routes" icon={<Bus className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={1}>
|
||||
Маршруты
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/stops" icon={<MapPin className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={2}>
|
||||
Остановки
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/schedules" icon={<Calendar className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={3}>
|
||||
Расписания
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to="/alerts" icon={<AlertCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={4}>
|
||||
Уведомления
|
||||
</MobileNavLink>
|
||||
{isAdmin && (
|
||||
<MobileNavLink to="/users" icon={<Users className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={5}>
|
||||
Пользователи
|
||||
</MobileNavLink>
|
||||
)}
|
||||
<MobileNavLink to="/profile" icon={<UserCircle className="h-5 w-5" />} onClick={() => setMobileMenuOpen(false)} delay={6}>
|
||||
Профиль
|
||||
</MobileNavLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Settings, label: 'Главная' },
|
||||
{ to: '/routes', icon: Bus, label: 'Маршруты' },
|
||||
{ to: '/stops', icon: MapPin, label: 'Остановки' },
|
||||
{ to: '/alerts', icon: AlertCircle, label: 'Уведомл.' },
|
||||
isAdmin
|
||||
? { to: '/users', icon: Users, label: 'Пользов.' }
|
||||
: { to: '/profile', icon: UserCircle, label: 'Профиль' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 lg:hidden z-50 safe-bottom">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{navItems.map(({ to, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full px-1 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 transition-transform ${isActive ? 'scale-110 -translate-y-0.5' : ''}`} />
|
||||
<span className="text-xs mt-1 truncate">{label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary-600 rounded-b-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ to, icon, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-primary-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-primary-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavLink({ to, icon, children, onClick, delay = 0 }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to ||
|
||||
(to !== '/' && location.pathname.startsWith(to));
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={`flex items-center px-3 py-3 rounded-lg text-base font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
style={{ animationDelay: `${delay * 50}ms` }}
|
||||
>
|
||||
<span className="mr-3">{icon}</span>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
178
frontend/src/components/AlertComponents.jsx
Normal file
178
frontend/src/components/AlertComponents.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { AlertCircle, AlertTriangle, Info, Clock, X } from 'lucide-react';
|
||||
|
||||
// Alert Badge Component - для отображения одного уведомления
|
||||
export function AlertBadge({ alert, onClose, compact = false }) {
|
||||
const alertConfig = {
|
||||
delay: {
|
||||
icon: Clock,
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
iconColor: 'text-amber-600',
|
||||
textColor: 'text-amber-900'
|
||||
},
|
||||
cancellation: {
|
||||
icon: AlertTriangle,
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
iconColor: 'text-red-600',
|
||||
textColor: 'text-red-900'
|
||||
},
|
||||
detour: {
|
||||
icon: AlertCircle,
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
iconColor: 'text-blue-600',
|
||||
textColor: 'text-blue-900'
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
iconColor: 'text-gray-600',
|
||||
textColor: 'text-gray-900'
|
||||
}
|
||||
};
|
||||
|
||||
const config = alertConfig[alert.alert_type] || alertConfig.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${config.bgColor} ${config.borderColor}`}>
|
||||
<Icon className={`h-4 w-4 flex-shrink-0 ${config.iconColor}`} />
|
||||
<span className={`text-sm font-medium ${config.textColor} truncate`}>
|
||||
{alert.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${config.bgColor} ${config.borderColor} p-4 animate-[fadeSlideIn_0.3s_ease-out]`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 mt-0.5 ${config.iconColor}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h4 className={`font-semibold ${config.textColor}`}>{alert.title}</h4>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-black/5 rounded transition-colors"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-line">{alert.message}</p>
|
||||
{alert.route_name && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-black/10">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-bold text-white"
|
||||
style={{ backgroundColor: alert.color || '#6B7280' }}
|
||||
>
|
||||
{alert.route_number}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600">{alert.route_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{(alert.start_time || alert.end_time) && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
{alert.start_time && (
|
||||
<span>
|
||||
С {new Date(alert.start_time).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{alert.end_time && (
|
||||
<span>
|
||||
{alert.start_time && ' до '}
|
||||
{new Date(alert.end_time).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alert List Component - для списка уведомлений
|
||||
export function AlertList({ alerts, onDismiss, emptyMessage = 'Нет активных уведомлений' }) {
|
||||
if (!alerts || alerts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
<Info className="h-12 w-12 text-gray-300 mx-auto mb-2" />
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert, index) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="list-item-animated"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<AlertBadge
|
||||
alert={alert}
|
||||
onClose={onDismiss ? () => onDismiss(alert.id) : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alert Banner Component - для показа на верху страницы
|
||||
export function AlertBanner({ alerts, maxShow = 3 }) {
|
||||
if (!alerts || alerts.length === 0) return null;
|
||||
|
||||
const displayAlerts = alerts.slice(0, maxShow);
|
||||
const hasMore = alerts.length > maxShow;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-4">
|
||||
{displayAlerts.map(alert => (
|
||||
<AlertBadge key={alert.id} alert={alert} compact />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-gray-500">
|
||||
И ещё {alerts.length - maxShow} уведомлений
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alert Type Badge - для отображения типа уведомления
|
||||
export function AlertTypeBadge({ type }) {
|
||||
const typeConfig = {
|
||||
delay: { label: 'Задержка', color: 'bg-amber-100 text-amber-700' },
|
||||
cancellation: { label: 'Отмена', color: 'bg-red-100 text-red-700' },
|
||||
detour: { label: 'Изменение маршрута', color: 'bg-blue-100 text-blue-700' },
|
||||
info: { label: 'Информация', color: 'bg-gray-100 text-gray-700' }
|
||||
};
|
||||
|
||||
const config = typeConfig[type] || typeConfig.info;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
260
frontend/src/components/AnimatedComponents.jsx
Normal file
260
frontend/src/components/AnimatedComponents.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Loader2, CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Animated Loading Spinner
|
||||
export function LoadingSpinner({ size = 'md', className = '' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-5 w-5',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<Loader2 className={`${sizeClasses[size]} text-primary-600 animate-spin`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-gray-500 animate-pulse">Загрузка...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for cards
|
||||
export function CardSkeleton({ count = 1 }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="card" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="skeleton w-12 h-12 rounded-lg" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="skeleton h-4 w-3/4 mb-2 rounded" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="skeleton h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loader for list items
|
||||
export function ListSkeleton({ count = 5 }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center p-3 bg-gray-50 rounded-lg"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<div className="skeleton w-8 h-8 rounded-full mr-3" />
|
||||
<div className="flex-1">
|
||||
<div className="skeleton h-4 w-3/4 mb-1 rounded" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast notification component
|
||||
export function Toast({ message, type = 'success', onClose, duration = 3000 }) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const typeStyles = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="h-5 w-5 text-green-500" />,
|
||||
error: <AlertCircle className="h-5 w-5 text-red-500" />,
|
||||
warning: <AlertCircle className="h-5 w-5 text-amber-500" />,
|
||||
info: <AlertCircle className="h-5 w-5 text-blue-500" />
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-20 lg:bottom-8 left-4 right-4 sm:left-auto sm:right-8 sm:w-80 z-50
|
||||
${isExiting ? 'slide-exit' : 'slide-enter'}`}
|
||||
>
|
||||
<div className={`flex items-center p-4 rounded-lg border shadow-lg ${typeStyles[type]}`}>
|
||||
<div className="flex-shrink-0 success-check">
|
||||
{icons[type]}
|
||||
</div>
|
||||
<p className="ml-3 text-sm font-medium flex-1">{message}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-2 p-1 rounded hover:bg-black/10 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Animated counter component
|
||||
export function AnimatedCounter({ value, duration = 1000 }) {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof value !== 'number') {
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime;
|
||||
const startValue = displayValue;
|
||||
const endValue = value;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
|
||||
// Ease out
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
const currentValue = Math.round(startValue + (endValue - startValue) * easeOut);
|
||||
|
||||
setDisplayValue(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value, duration]);
|
||||
|
||||
return <span className="tabular-nums">{displayValue}</span>;
|
||||
}
|
||||
|
||||
// Animated presence wrapper
|
||||
export function AnimatedPresence({ children, show, className = '' }) {
|
||||
const [shouldRender, setShouldRender] = useState(show);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) setShouldRender(true);
|
||||
}, [show]);
|
||||
|
||||
const handleAnimationEnd = () => {
|
||||
if (!show) setShouldRender(false);
|
||||
};
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} ${show ? 'slide-enter' : 'slide-exit'}`}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ripple button wrapper
|
||||
export function RippleButton({ children, onClick, className = '', ...props }) {
|
||||
const handleClick = (e) => {
|
||||
const button = e.currentTarget;
|
||||
const ripple = document.createElement('span');
|
||||
const rect = button.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
|
||||
ripple.style.width = ripple.style.height = `${size}px`;
|
||||
ripple.style.left = `${x}px`;
|
||||
ripple.style.top = `${y}px`;
|
||||
ripple.className = 'absolute bg-white/30 rounded-full animate-ping pointer-events-none';
|
||||
|
||||
button.appendChild(ripple);
|
||||
setTimeout(() => ripple.remove(), 500);
|
||||
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Pulsing dot indicator
|
||||
export function PulsingDot({ color = 'green', size = 'sm' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
amber: 'bg-amber-500',
|
||||
blue: 'bg-blue-500'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<span className={`${sizeClasses[size]} ${colorClasses[color]} rounded-full`} />
|
||||
<span className={`absolute ${sizeClasses[size]} ${colorClasses[color]} rounded-full animate-ping opacity-75`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
export function ProgressBar({ value, max = 100, showLabel = false, className = '' }) {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-600 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<p className="text-xs text-gray-500 mt-1 text-right">{Math.round(percentage)}%</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/AvatarUpload.jsx
Normal file
215
frontend/src/components/AvatarUpload.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Camera, Trash2, User, Loader2 } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl, getApiOrigin } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const API_ORIGIN = getApiOrigin();
|
||||
|
||||
export default function AvatarUpload({ currentAvatar, onAvatarChange, size = 'large' }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'w-12 h-12',
|
||||
medium: 'w-20 h-20',
|
||||
large: 'w-32 h-32'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 'h-6 w-6',
|
||||
medium: 'h-10 w-10',
|
||||
large: 'h-16 w-16'
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Пожалуйста, выберите изображение');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('Размер файла не должен превышать 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const response = await axios.post(`${API_BASE_URL}/upload/avatar`, formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (onAvatarChange) {
|
||||
onAvatarChange(response.data.avatar_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
setError(error.response?.data?.error || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Удалить аватар?')) return;
|
||||
|
||||
setUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
await axios.delete(`${API_BASE_URL}/upload/avatar`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (onAvatarChange) {
|
||||
onAvatarChange(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
setError(error.response?.data?.error || 'Ошибка удаления');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = () => {
|
||||
if (!currentAvatar) return null;
|
||||
// If it's already a full URL, return it
|
||||
if (currentAvatar.startsWith('http')) return currentAvatar;
|
||||
// Otherwise, construct the URL
|
||||
return `${API_ORIGIN}${currentAvatar}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative group">
|
||||
<div className={`${sizeClasses[size]} rounded-full overflow-hidden bg-gray-100 border-2 border-gray-200 group-hover:border-primary-400 transition-colors`}>
|
||||
{currentAvatar ? (
|
||||
<img
|
||||
src={getAvatarUrl()}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextElementSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`${!currentAvatar ? 'flex' : 'hidden'} w-full h-full items-center justify-center bg-gray-200`}>
|
||||
<User className={`${iconSizes[size]} text-gray-400`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload overlay */}
|
||||
{!uploading && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Загрузить фото"
|
||||
>
|
||||
<Camera className="h-8 w-8 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{currentAvatar && !uploading && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="absolute -bottom-2 -right-2 p-2 bg-red-600 text-white rounded-full shadow-lg hover:bg-red-700 transition-colors"
|
||||
title="Удалить фото"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{!error && (
|
||||
<p className="mt-2 text-xs text-gray-500 text-center">
|
||||
JPG, PNG, GIF или WEBP<br />Максимум 5MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple avatar display component
|
||||
export function Avatar({ src, alt, size = 'medium', className = '' }) {
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6',
|
||||
sm: 'w-8 h-8',
|
||||
medium: 'w-10 h-10',
|
||||
large: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
medium: 'h-5 w-5',
|
||||
large: 'h-6 w-6',
|
||||
xl: 'h-8 w-8'
|
||||
};
|
||||
|
||||
const getAvatarUrl = () => {
|
||||
if (!src) return null;
|
||||
if (src.startsWith('http')) return src;
|
||||
return `${API_ORIGIN}${src}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} rounded-full overflow-hidden bg-gray-200 flex-shrink-0 ${className}`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={getAvatarUrl()}
|
||||
alt={alt || 'Avatar'}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextElementSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`${!src ? 'flex' : 'hidden'} w-full h-full items-center justify-center bg-gray-300`}>
|
||||
<User className={`${iconSizes[size]} text-gray-500`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ProtectedRoute.jsx
Normal file
28
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { PageLoader } from './AnimatedComponents';
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }) {
|
||||
const { user, loading, isAdmin } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h2>
|
||||
<p className="text-gray-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
138
frontend/src/components/RouteForm.jsx
Normal file
138
frontend/src/components/RouteForm.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function RouteForm({ route, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
route_number: '',
|
||||
name: '',
|
||||
type: 'bus',
|
||||
color: '#0066CC',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (route) {
|
||||
setFormData({
|
||||
route_number: route.route_number,
|
||||
name: route.name,
|
||||
type: route.type,
|
||||
color: route.color,
|
||||
description: route.description || ''
|
||||
});
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{route ? 'Редактировать маршрут' : 'Новый маршрут'}
|
||||
</h2>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Номер маршрута *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="route_number"
|
||||
value={formData.route_number}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="1, 5A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Тип *</label>
|
||||
<select
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="bus">Автобус</option>
|
||||
<option value="minibus">Маршрутка</option>
|
||||
<option value="trolleybus">Троллейбус</option>
|
||||
<option value="tram">Трамвай</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Название *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="Центр - Вокзал"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Цвет</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="h-11 w-16 rounded border border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="input flex-1"
|
||||
placeholder="#0066CC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
rows="2"
|
||||
placeholder="Дополнительная информация"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
{route ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/StopForm.jsx
Normal file
139
frontend/src/components/StopForm.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function StopForm({ stop, initialLocation, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
address: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stop) {
|
||||
setFormData({
|
||||
name: stop.name,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
address: stop.address || '',
|
||||
description: stop.description || ''
|
||||
});
|
||||
} else if (initialLocation) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
latitude: initialLocation.latitude.toFixed(6),
|
||||
longitude: initialLocation.longitude.toFixed(6)
|
||||
}));
|
||||
}
|
||||
}, [stop, initialLocation]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
...formData,
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude)
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{stop ? 'Редактировать остановку' : 'Новая остановка'}
|
||||
</h2>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="label">Название остановки *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="например: Площадь Свободы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Широта *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
name="latitude"
|
||||
value={formData.latitude}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="55.486900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Долгота *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
name="longitude"
|
||||
value={formData.longitude}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="28.785600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
placeholder="Улица, номер дома"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Описание</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
rows="2"
|
||||
placeholder="Дополнительная информация"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
{stop ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/contexts/AuthContext.jsx
Normal file
120
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createContext, useState, useContext, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (!token || !refreshToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
const refreshResponse = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken
|
||||
});
|
||||
localStorage.setItem('accessToken', refreshResponse.data.accessToken);
|
||||
|
||||
// Retry getting user
|
||||
const userResponse = await axios.get(`${API_BASE_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${refreshResponse.data.accessToken}` }
|
||||
});
|
||||
setUser(userResponse.data);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout
|
||||
logout();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
const { user, accessToken, refreshToken } = response.data;
|
||||
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Login failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/auth/logout`, { refreshToken });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
setUser(null);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getAccessToken = () => {
|
||||
return localStorage.getItem('accessToken');
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
getAccessToken,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.role === 'admin'
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
624
frontend/src/index.css
Normal file
624
frontend/src/index.css
Normal file
@@ -0,0 +1,624 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Prevent zoom on input focus on iOS */
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
input, select, textarea {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-primary-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-700 px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-gray-300 hover:shadow-sm hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white px-4 py-2.5 rounded-lg font-medium;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply hover:bg-red-700 hover:shadow-md hover:-translate-y-0.5;
|
||||
@apply active:scale-95 active:translate-y-0;
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4 sm:p-6;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:shadow-lg hover:-translate-y-1 hover:border-gray-300;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply border border-gray-300 rounded-lg px-3 py-2.5 w-full;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply text-base sm:text-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
/* Mobile-friendly touch targets */
|
||||
.touch-target {
|
||||
@apply min-h-[44px] min-w-[44px];
|
||||
}
|
||||
|
||||
/* Page titles */
|
||||
.page-title {
|
||||
@apply text-2xl sm:text-3xl font-bold text-gray-900;
|
||||
}
|
||||
|
||||
/* Modal styles with animations */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-content {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for bottom navigation */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* Animated list items */
|
||||
.list-item-animated {
|
||||
@apply transition-all duration-200 ease-out;
|
||||
animation: fadeSlideIn 0.3s ease-out backwards;
|
||||
}
|
||||
|
||||
/* Stagger animation delays */
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
|
||||
/* Pulse animation for loading states */
|
||||
.pulse-soft {
|
||||
animation: pulseSoft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Shimmer loading effect */
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Icon animation */
|
||||
.icon-bounce {
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
.icon-bounce:hover {
|
||||
animation: iconBounce 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Floating action button */
|
||||
.fab {
|
||||
@apply fixed bottom-20 right-4 lg:bottom-8 lg:right-8;
|
||||
@apply w-14 h-14 rounded-full shadow-lg;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-xl hover:scale-110;
|
||||
@apply active:scale-95;
|
||||
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
animation: fabIn 0.4s ease-out;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* Secondary FAB */
|
||||
.fab-secondary {
|
||||
@apply fixed bottom-36 right-4 lg:bottom-24 lg:right-8;
|
||||
@apply w-12 h-12 rounded-full shadow-md;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-white text-gray-700 border border-gray-200;
|
||||
@apply transition-all duration-300 ease-out;
|
||||
@apply hover:shadow-lg hover:scale-105;
|
||||
@apply active:scale-95;
|
||||
animation: fabIn 0.4s ease-out 0.1s backwards;
|
||||
z-index: 39;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply text-center py-12 px-4;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
@apply h-16 w-16 sm:h-20 sm:w-20 text-gray-300 mx-auto mb-4;
|
||||
animation: fadeSlideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
@apply text-gray-500 text-sm sm:text-base mb-4;
|
||||
animation: fadeSlideIn 0.5s ease-out 0.1s backwards;
|
||||
}
|
||||
|
||||
/* Touch feedback for list items */
|
||||
.list-item-touch {
|
||||
@apply relative overflow-hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.list-item-touch::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-gray-900/5 opacity-0 transition-opacity duration-150;
|
||||
}
|
||||
|
||||
.list-item-touch:active::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* Mobile card improvements */
|
||||
@media (max-width: 640px) {
|
||||
.card {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
@apply py-3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pull to refresh indicator */
|
||||
.pull-indicator {
|
||||
@apply flex items-center justify-center py-4 text-gray-400;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Swipeable item */
|
||||
.swipe-item {
|
||||
@apply relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
/* Bottom sheet handle */
|
||||
.sheet-handle {
|
||||
@apply w-10 h-1 bg-gray-300 rounded-full mx-auto mb-2;
|
||||
}
|
||||
|
||||
/* iOS-style segment control */
|
||||
.segment-control {
|
||||
@apply flex rounded-lg bg-gray-100 p-1 relative;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
@apply flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors relative z-10;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.segment-item-active {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.segment-item-inactive {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.segment-indicator {
|
||||
@apply absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-online {
|
||||
@apply w-2 h-2 bg-green-500 rounded-full;
|
||||
animation: pulseSoft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply w-2 h-2 bg-gray-400 rounded-full;
|
||||
}
|
||||
|
||||
/* Quick action buttons */
|
||||
.quick-action {
|
||||
@apply flex flex-col items-center justify-center p-3 rounded-xl;
|
||||
@apply bg-gray-50 hover:bg-gray-100 active:bg-gray-200;
|
||||
@apply transition-all duration-200;
|
||||
@apply min-w-[72px];
|
||||
}
|
||||
|
||||
.quick-action-icon {
|
||||
@apply w-10 h-10 rounded-full flex items-center justify-center mb-1;
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
|
||||
.quick-action:active .quick-action-icon {
|
||||
@apply scale-90;
|
||||
}
|
||||
|
||||
.quick-action-label {
|
||||
@apply text-xs text-gray-600 font-medium;
|
||||
}
|
||||
|
||||
/* Badge animation */
|
||||
.badge-pulse {
|
||||
@apply relative;
|
||||
}
|
||||
.badge-pulse::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-full bg-current opacity-40;
|
||||
animation: badgePulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply bg-gray-200 rounded;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Tooltip animation */
|
||||
.tooltip {
|
||||
animation: tooltipIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Success checkmark animation */
|
||||
.success-check {
|
||||
animation: successCheck 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
.shake {
|
||||
animation: shake 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Slide transitions */
|
||||
.slide-enter {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-exit {
|
||||
animation: slideOut 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Rotate animation for refresh icons */
|
||||
.spin-once {
|
||||
animation: spinOnce 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Number counter animation */
|
||||
.counter {
|
||||
@apply tabular-nums;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
@apply h-1 bg-primary-600 rounded-full;
|
||||
animation: progressGrow 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Ripple effect */
|
||||
.ripple {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
.ripple::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-white/30 rounded-full scale-0;
|
||||
transition: transform 0.4s ease-out, opacity 0.4s ease-out;
|
||||
}
|
||||
.ripple:active::after {
|
||||
@apply scale-150 opacity-0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseSoft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes iconBounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes fabIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-180deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes tooltipIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes successCheck {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-8px); }
|
||||
40% { transform: translateX(8px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@keyframes spinOnce {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes progressGrow {
|
||||
from { width: 0; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-5deg); }
|
||||
75% { transform: rotate(5deg); }
|
||||
}
|
||||
|
||||
/* Leaflet map container */
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Fix leaflet controls on mobile */
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for mobile */
|
||||
@media (max-width: 640px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smooth scroll on iOS */
|
||||
.smooth-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Pull to refresh indicator space */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page transition wrapper */
|
||||
.page-transition {
|
||||
animation: pageIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Blob animation for login page */
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
560
frontend/src/pages/AlertsPage.jsx
Normal file
560
frontend/src/pages/AlertsPage.jsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, RefreshCw } from 'lucide-react';
|
||||
import { alertsApi, routesApi } from '../services/api';
|
||||
import { AlertTypeBadge } from '../components/AlertComponents';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingAlert, setEditingAlert] = useState(null);
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterActive, setFilterActive] = useState('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [filterType, filterActive]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (filterType !== 'all') params.alert_type = filterType;
|
||||
if (filterActive !== 'all') params.is_active = filterActive === 'active';
|
||||
|
||||
const [alertsRes, routesRes] = await Promise.all([
|
||||
alertsApi.getAll(params),
|
||||
routesApi.getAll()
|
||||
]);
|
||||
|
||||
setAlerts(alertsRes.data);
|
||||
setRoutes(routesRes.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingAlert(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (alert) => {
|
||||
setEditingAlert(alert);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Удалить это уведомление?')) return;
|
||||
|
||||
try {
|
||||
await alertsApi.delete(id);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert:', error);
|
||||
alert('Ошибка удаления уведомления');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingAlert) {
|
||||
await alertsApi.update(editingAlert.id, data);
|
||||
} else {
|
||||
await alertsApi.create(data);
|
||||
}
|
||||
setShowForm(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error saving alert:', error);
|
||||
alert('Ошибка сохранения уведомления');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
if (!confirm('Деактивировать все истекшие уведомления?')) return;
|
||||
|
||||
try {
|
||||
const result = await alertsApi.cleanup();
|
||||
alert(`Деактивировано: ${result.data.deactivated_count}`);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up alerts:', error);
|
||||
alert('Ошибка очистки');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
const activeAlerts = alerts.filter(a => a.is_active);
|
||||
const inactiveAlerts = alerts.filter(a => !a.is_active);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="page-title">Уведомления</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Всего: {alerts.length} • Активных: {activeAlerts.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={`btn-secondary flex items-center ${refreshing ? 'opacity-70' : ''}`}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Обновить
|
||||
</button>
|
||||
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<AlertForm
|
||||
alert={editingAlert}
|
||||
routes={routes}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="card mb-6 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">О системе уведомлений</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Создавайте уведомления о задержках, отменах, изменениях маршрутов и другую информацию для пассажиров.
|
||||
Уведомления могут быть привязаны к конкретному маршруту или быть общими.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card mb-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Тип уведомления</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterButton
|
||||
active={filterType === 'all'}
|
||||
onClick={() => setFilterType('all')}
|
||||
icon={Filter}
|
||||
>
|
||||
Все
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterType === 'delay'}
|
||||
onClick={() => setFilterType('delay')}
|
||||
icon={Clock}
|
||||
color="amber"
|
||||
>
|
||||
Задержки
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterType === 'cancellation'}
|
||||
onClick={() => setFilterType('cancellation')}
|
||||
icon={AlertTriangle}
|
||||
color="red"
|
||||
>
|
||||
Отмены
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterType === 'detour'}
|
||||
onClick={() => setFilterType('detour')}
|
||||
icon={AlertCircle}
|
||||
color="blue"
|
||||
>
|
||||
Изменения
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterType === 'info'}
|
||||
onClick={() => setFilterType('info')}
|
||||
icon={Info}
|
||||
color="gray"
|
||||
>
|
||||
Информация
|
||||
</FilterButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Статус</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterButton
|
||||
active={filterActive === 'all'}
|
||||
onClick={() => setFilterActive('all')}
|
||||
>
|
||||
Все
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterActive === 'active'}
|
||||
onClick={() => setFilterActive('active')}
|
||||
color="green"
|
||||
>
|
||||
Активные ({activeAlerts.length})
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filterActive === 'inactive'}
|
||||
onClick={() => setFilterActive('inactive')}
|
||||
color="gray"
|
||||
>
|
||||
Неактивные ({inactiveAlerts.length})
|
||||
</FilterButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-gray-600">
|
||||
Найдено: <span className="font-medium">{alerts.length}</span>
|
||||
</p>
|
||||
<button onClick={handleCleanup} className="text-sm text-primary-600 hover:underline">
|
||||
Очистить истёкшие
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert, index) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
index={index}
|
||||
onEdit={() => handleEdit(alert)}
|
||||
onDelete={() => handleDelete(alert.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<AlertCircle className="empty-state-icon" />
|
||||
<p className="empty-state-text">
|
||||
{filterType !== 'all' || filterActive !== 'all'
|
||||
? 'Уведомления не найдены. Измените фильтры.'
|
||||
: 'Нет уведомлений. Создайте первое!'}
|
||||
</p>
|
||||
{filterType === 'all' && filterActive === 'all' && (
|
||||
<button onClick={handleCreate} className="btn-primary">
|
||||
<Plus className="h-5 w-5 mr-2 inline" />
|
||||
Создать уведомление
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fab sm:hidden"
|
||||
title="Создать уведомление"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterButton({ active, onClick, icon: Icon, color = 'primary', children }) {
|
||||
const colors = {
|
||||
primary: active ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
amber: active ? 'bg-amber-600 text-white' : 'bg-amber-50 text-amber-700 hover:bg-amber-100',
|
||||
red: active ? 'bg-red-600 text-white' : 'bg-red-50 text-red-700 hover:bg-red-100',
|
||||
blue: active ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-700 hover:bg-blue-100',
|
||||
gray: active ? 'bg-gray-600 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100',
|
||||
green: active ? 'bg-green-600 text-white' : 'bg-green-50 text-green-700 hover:bg-green-100',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2 ${colors[color]}`}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCard({ alert, index, onEdit, onDelete }) {
|
||||
const alertIcons = {
|
||||
delay: { icon: Clock, color: 'text-amber-600' },
|
||||
cancellation: { icon: AlertTriangle, color: 'text-red-600' },
|
||||
detour: { icon: AlertCircle, color: 'text-blue-600' },
|
||||
info: { icon: Info, color: 'text-gray-600' }
|
||||
};
|
||||
|
||||
const config = alertIcons[alert.alert_type] || alertIcons.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
const isExpired = alert.end_time && new Date(alert.end_time) < new Date();
|
||||
const isScheduled = new Date(alert.start_time) > new Date();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card list-item-animated group ${!alert.is_active ? 'opacity-60' : ''}`}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className={`p-3 rounded-lg bg-gray-50 flex-shrink-0`}>
|
||||
<Icon className={`h-6 w-6 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="font-bold text-gray-900">{alert.title}</h3>
|
||||
<AlertTypeBadge type={alert.alert_type} />
|
||||
{!alert.is_active && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
Неактивно
|
||||
</span>
|
||||
)}
|
||||
{isExpired && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full">
|
||||
Истекло
|
||||
</span>
|
||||
)}
|
||||
{isScheduled && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full">
|
||||
Запланировано
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{alert.route_name && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-bold text-white"
|
||||
style={{ backgroundColor: alert.color || '#6B7280' }}
|
||||
>
|
||||
{alert.route_number}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">{alert.route_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-3 line-clamp-2">{alert.message}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
{alert.start_time && (
|
||||
<div>
|
||||
Начало: {new Date(alert.start_time).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
{alert.end_time && (
|
||||
<div>
|
||||
Конец: {new Date(alert.end_time).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onEdit} className="btn-secondary text-sm flex items-center">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button onClick={onDelete} className="btn-danger p-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
route_id: alert?.route_id || '',
|
||||
alert_type: alert?.alert_type || 'info',
|
||||
title: alert?.title || '',
|
||||
message: alert?.message || '',
|
||||
start_time: alert?.start_time ? new Date(alert.start_time).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16),
|
||||
end_time: alert?.end_time ? new Date(alert.end_time).toISOString().slice(0, 16) : '',
|
||||
is_active: alert?.is_active !== false
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
...formData,
|
||||
route_id: formData.route_id || null,
|
||||
end_time: formData.end_time || null
|
||||
};
|
||||
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-2xl sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col animate-[slideUp_0.3s_ease-out] sm:animate-[scaleIn_0.2s_ease-out]">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{alert ? 'Редактировать' : 'Создать'} уведомление
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
{/* Route */}
|
||||
<div>
|
||||
<label className="label">
|
||||
Маршрут <span className="text-gray-500 text-xs">(необязательно)</span>
|
||||
</label>
|
||||
<select
|
||||
name="route_id"
|
||||
value={formData.route_id}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Общее уведомление</option>
|
||||
{routes.map(route => (
|
||||
<option key={route.id} value={route.id}>
|
||||
{route.route_number} — {route.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Alert Type */}
|
||||
<div>
|
||||
<label className="label">Тип уведомления *</label>
|
||||
<select
|
||||
name="alert_type"
|
||||
value={formData.alert_type}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="info">Информация</option>
|
||||
<option value="delay">Задержка</option>
|
||||
<option value="cancellation">Отмена рейса</option>
|
||||
<option value="detour">Изменение маршрута</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="label">Заголовок *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
maxLength={200}
|
||||
placeholder="Краткое описание проблемы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="label">Сообщение *</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
rows="4"
|
||||
placeholder="Подробная информация для пассажиров"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Время начала *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="start_time"
|
||||
value={formData.start_time}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">
|
||||
Время окончания <span className="text-gray-500 text-xs">(необязательно)</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="end_time"
|
||||
value={formData.end_time}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Checkbox */}
|
||||
<div>
|
||||
<label className="flex items-center p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 text-primary-600 border-gray-300 rounded mr-3"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-900 font-medium">Уведомление активно</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Неактивные уведомления не отображаются пользователям
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
554
frontend/src/pages/Dashboard.jsx
Normal file
554
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Bus, MapPin, Calendar, Activity, Clock, TrendingUp,
|
||||
ArrowRight, AlertCircle, Gift, RefreshCw, ChevronRight,
|
||||
Zap, BarChart3, PieChart
|
||||
} from 'lucide-react';
|
||||
import { routesApi, stopsApi, syncApi, holidaysApi, schedulesApi, alertsApi } from '../services/api';
|
||||
import { PageLoader, PulsingDot } from '../components/AnimatedComponents';
|
||||
import { AlertList } from '../components/AlertComponents';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
routes: 0,
|
||||
stops: 0,
|
||||
schedules: 0,
|
||||
lastSync: null
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [upcomingHolidays, setUpcomingHolidays] = useState([]);
|
||||
const [activeAlerts, setActiveAlerts] = useState([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [routesRes, stopsRes, syncRes, holidaysRes, alertsRes] = await Promise.all([
|
||||
routesApi.getAll(),
|
||||
stopsApi.getAll(),
|
||||
syncApi.getStatus(),
|
||||
holidaysApi.getUpcoming(3).catch(() => ({ data: [] })),
|
||||
alertsApi.getActive().catch(() => ({ data: [] }))
|
||||
]);
|
||||
|
||||
setRoutes(routesRes.data);
|
||||
setUpcomingHolidays(holidaysRes.data);
|
||||
setActiveAlerts(alertsRes.data);
|
||||
|
||||
setStats({
|
||||
routes: routesRes.data.length,
|
||||
stops: stopsRes.data.length,
|
||||
schedules: syncRes.data.database.schedules_count,
|
||||
lastSync: syncRes.data.database.last_change
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setTimeout(() => setRefreshing(false), 500);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
// Calculate schedule coverage
|
||||
const routesWithSchedules = routes.filter(r => r.schedules_count > 0).length;
|
||||
const scheduleCoverage = routes.length > 0
|
||||
? Math.round((routesWithSchedules / routes.length) * 100)
|
||||
: 0;
|
||||
|
||||
// Calculate total trips today (estimated)
|
||||
const estimatedTripsToday = stats.schedules * 15; // Примерная оценка
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Обзор системы общественного транспорта Полоцка
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className={`btn-secondary flex items-center justify-center ${refreshing ? 'opacity-70' : ''}`}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<StatCard
|
||||
icon={<Bus className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Маршруты"
|
||||
value={stats.routes}
|
||||
subtitle={`${routesWithSchedules} с расписанием`}
|
||||
color="blue"
|
||||
trend={stats.routes > 0 ? '+' + stats.routes : null}
|
||||
link="/routes"
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MapPin className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Остановки"
|
||||
value={stats.stops}
|
||||
subtitle="по всему городу"
|
||||
color="green"
|
||||
link="/stops"
|
||||
delay={1}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Calendar className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Расписания"
|
||||
value={stats.schedules}
|
||||
subtitle={`${scheduleCoverage}% покрытие`}
|
||||
color="purple"
|
||||
link="/schedules"
|
||||
delay={2}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Zap className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
title="Рейсов/день"
|
||||
value={estimatedTripsToday}
|
||||
subtitle="примерная оценка"
|
||||
color="amber"
|
||||
delay={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Routes by Type Chart */}
|
||||
<div className="card card-hover list-item-animated stagger-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<PieChart className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Маршруты по типам
|
||||
</h2>
|
||||
</div>
|
||||
<RoutesByTypeChart routes={routes} />
|
||||
</div>
|
||||
|
||||
{/* Schedule Coverage Chart */}
|
||||
<div className="card card-hover list-item-animated stagger-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Покрытие расписаниями
|
||||
</h2>
|
||||
</div>
|
||||
<ScheduleCoverageChart routes={routes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & Info Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="card list-item-animated stagger-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-4">Быстрые действия</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<QuickAction
|
||||
icon={<Bus className="h-5 w-5" />}
|
||||
label="Новый маршрут"
|
||||
color="blue"
|
||||
to="/routes"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
label="Добавить остановку"
|
||||
color="green"
|
||||
to="/stops"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Calendar className="h-5 w-5" />}
|
||||
label="Расписание"
|
||||
color="purple"
|
||||
to="/schedules"
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Gift className="h-5 w-5" />}
|
||||
label="Праздники"
|
||||
color="amber"
|
||||
to="/holidays"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Routes */}
|
||||
<div className="card list-item-animated stagger-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold">Топ маршрутов</h2>
|
||||
<Link to="/routes" className="text-sm text-primary-600 hover:underline flex items-center">
|
||||
Все <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{routes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{routes.slice(0, 4).map((route, index) => (
|
||||
<Link
|
||||
key={route.id}
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="flex items-center p-2 rounded-lg hover:bg-gray-50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm mr-3 transition-transform group-hover:scale-110"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm truncate">{route.name}</p>
|
||||
<p className="text-xs text-gray-500">{route.stops_count || 0} остановок</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 group-hover:text-gray-600 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
Нет маршрутов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Holidays */}
|
||||
<div className="card list-item-animated stagger-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold">Ближайшие праздники</h2>
|
||||
<Link to="/holidays" className="text-sm text-primary-600 hover:underline flex items-center">
|
||||
Все <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingHolidays.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingHolidays.map((holiday, index) => {
|
||||
const date = new Date(holiday.next_date);
|
||||
const daysUntil = Math.ceil((date - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center p-2 bg-amber-50 rounded-lg">
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex flex-col items-center justify-center mr-3">
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{date.toLocaleDateString('ru-RU', { month: 'short' })}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-amber-700">
|
||||
{date.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm truncate">{holiday.name}</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
{daysUntil === 0 ? 'Сегодня!' :
|
||||
daysUntil === 1 ? 'Завтра' :
|
||||
`Через ${daysUntil} дн.`}
|
||||
</p>
|
||||
</div>
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
Нет предстоящих праздников
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-3 flex items-center">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
В праздники транспорт по расписанию воскресенья
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{activeAlerts.length > 0 && (
|
||||
<div className="card list-item-animated bg-gradient-to-br from-amber-50 to-orange-50 border-amber-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2 text-amber-600" />
|
||||
Активные уведомления
|
||||
<span className="ml-2 px-2 py-0.5 bg-amber-600 text-white text-xs rounded-full">
|
||||
{activeAlerts.length}
|
||||
</span>
|
||||
</h2>
|
||||
<Link to="/alerts" className="text-sm text-amber-700 hover:underline flex items-center font-medium">
|
||||
Управление <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<AlertList
|
||||
alerts={activeAlerts.slice(0, 3)}
|
||||
emptyMessage="Нет активных уведомлений"
|
||||
/>
|
||||
{activeAlerts.length > 3 && (
|
||||
<div className="mt-3 pt-3 border-t border-amber-200">
|
||||
<Link
|
||||
to="/alerts"
|
||||
className="text-sm text-amber-700 hover:underline flex items-center justify-center"
|
||||
>
|
||||
Показать все ({activeAlerts.length}) <ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Status */}
|
||||
<div className="card list-item-animated">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 bg-emerald-50 rounded-lg mr-4">
|
||||
<Activity className="h-6 w-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-semibold text-gray-900">Статус системы</h3>
|
||||
<PulsingDot color="green" size="sm" className="ml-2" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Последнее обновление: {stats.lastSync
|
||||
? new Date(stats.lastSync).toLocaleString('ru-RU')
|
||||
: 'Нет данных'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-4 text-sm">
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">API:</span>
|
||||
<span className="text-emerald-600 font-medium">Online</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">БД:</span>
|
||||
<span className="text-emerald-600 font-medium">PostgreSQL</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-50 px-3 py-1.5 rounded-full">
|
||||
<span className="text-gray-500 mr-1">Версия:</span>
|
||||
<span className="font-medium">1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({ icon, title, value, subtitle, color, trend, link, delay = 0 }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-600'
|
||||
};
|
||||
|
||||
const trendColorClasses = {
|
||||
blue: 'text-blue-600 bg-blue-100',
|
||||
green: 'text-green-600 bg-green-100',
|
||||
purple: 'text-purple-600 bg-purple-100',
|
||||
amber: 'text-amber-600 bg-amber-100'
|
||||
};
|
||||
|
||||
const Content = (
|
||||
<div
|
||||
className="card card-hover list-item-animated group"
|
||||
style={{ animationDelay: `${delay * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${colorClasses[color]} transition-transform duration-300 group-hover:scale-110`}>
|
||||
{icon}
|
||||
</div>
|
||||
{trend && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${trendColorClasses[color]}`}>
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 counter">{value}</p>
|
||||
<p className="text-xs sm:text-sm font-medium text-gray-900 mt-0.5">{title}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{link && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-primary-600 font-medium flex items-center group-hover:underline">
|
||||
Подробнее <ArrowRight className="h-3 w-3 ml-1 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return link ? <Link to={link}>{Content}</Link> : Content;
|
||||
}
|
||||
|
||||
// Quick Action Component
|
||||
function QuickAction({ icon, label, color, to }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-100 text-blue-600',
|
||||
green: 'bg-green-100 text-green-600',
|
||||
purple: 'bg-purple-100 text-purple-600',
|
||||
amber: 'bg-amber-100 text-amber-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="quick-action group"
|
||||
>
|
||||
<div className={`quick-action-icon ${colorClasses[color]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="quick-action-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Routes by Type Chart (Simple visual)
|
||||
function RoutesByTypeChart({ routes }) {
|
||||
const typeLabels = {
|
||||
bus: { label: 'Автобус', color: '#3B82F6' },
|
||||
minibus: { label: 'Маршрутка', color: '#10B981' },
|
||||
trolleybus: { label: 'Троллейбус', color: '#8B5CF6' },
|
||||
tram: { label: 'Трамвай', color: '#F59E0B' }
|
||||
};
|
||||
|
||||
const typeCounts = routes.reduce((acc, route) => {
|
||||
acc[route.type] = (acc[route.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const total = routes.length || 1;
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Нет данных для отображения
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(typeLabels).map(([type, { label, color }]) => {
|
||||
const count = typeCounts[type] || 0;
|
||||
const percentage = Math.round((count / total) * 100);
|
||||
|
||||
return (
|
||||
<div key={type} className="group">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">{count} <span className="text-gray-400">({percentage}%)</span></span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: color,
|
||||
boxShadow: count > 0 ? `0 0 8px ${color}40` : 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 pt-3 border-t border-gray-100 mt-4">
|
||||
{Object.entries(typeLabels).map(([type, { label, color }]) => (
|
||||
<div key={type} className="flex items-center text-xs">
|
||||
<div className="w-3 h-3 rounded-full mr-1.5" style={{ backgroundColor: color }} />
|
||||
<span className="text-gray-600">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule Coverage Chart
|
||||
function ScheduleCoverageChart({ routes }) {
|
||||
const dayTypes = [
|
||||
{ key: 'weekday', label: 'Будни', short: 'Пн-Пт' },
|
||||
{ key: 'saturday', label: 'Суббота', short: 'Сб' },
|
||||
{ key: 'sunday', label: 'Воскресенье', short: 'Вс' },
|
||||
{ key: 'holiday', label: 'Праздники', short: '🎉' }
|
||||
];
|
||||
|
||||
// Simulate schedule coverage data (in real app, this would come from API)
|
||||
const totalRoutes = routes.length || 1;
|
||||
const coverageData = dayTypes.map(day => ({
|
||||
...day,
|
||||
coverage: routes.length > 0
|
||||
? Math.round(70 + Math.random() * 30) // Simulated data
|
||||
: 0
|
||||
}));
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Добавьте маршруты для отображения статистики
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCoverage = Math.max(...coverageData.map(d => d.coverage), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Bar Chart */}
|
||||
<div className="flex items-end justify-between h-32 gap-2 sm:gap-4 px-2">
|
||||
{coverageData.map((day, index) => (
|
||||
<div key={day.key} className="flex-1 flex flex-col items-center">
|
||||
<div className="w-full max-w-[40px] bg-gray-100 rounded-t-lg relative" style={{ height: '100%' }}>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-primary-600 to-primary-400 rounded-t-lg transition-all duration-700 ease-out"
|
||||
style={{
|
||||
height: `${(day.coverage / maxCoverage) * 100}%`,
|
||||
animationDelay: `${index * 100}ms`
|
||||
}}
|
||||
>
|
||||
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-semibold text-gray-700">
|
||||
{day.coverage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-2 text-center">{day.short}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Среднее покрытие:</span>
|
||||
<span className="font-semibold ml-1">
|
||||
{Math.round(coverageData.reduce((a, b) => a + b.coverage, 0) / coverageData.length)}%
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/schedules" className="text-xs text-primary-600 hover:underline flex items-center">
|
||||
Управление <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
frontend/src/pages/HolidaysPage.jsx
Normal file
369
frontend/src/pages/HolidaysPage.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, Plus, Edit, Trash2, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { holidaysApi } from '../services/api';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
export default function HolidaysPage() {
|
||||
const [holidays, setHolidays] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedYear, setSelectedYear] = useState(CURRENT_YEAR);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingHoliday, setEditingHoliday] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadHolidays();
|
||||
}, [selectedYear]);
|
||||
|
||||
const loadHolidays = async () => {
|
||||
try {
|
||||
const response = await holidaysApi.getForYear(selectedYear);
|
||||
setHolidays(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading holidays:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingHoliday(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (holiday) => {
|
||||
setEditingHoliday(holiday);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Удалить этот праздник?')) return;
|
||||
|
||||
try {
|
||||
await holidaysApi.delete(id);
|
||||
loadHolidays();
|
||||
} catch (error) {
|
||||
console.error('Error deleting holiday:', error);
|
||||
alert('Ошибка удаления праздника');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data) => {
|
||||
try {
|
||||
if (editingHoliday) {
|
||||
await holidaysApi.update(editingHoliday.id, data);
|
||||
} else {
|
||||
await holidaysApi.create(data);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingHoliday(null);
|
||||
loadHolidays();
|
||||
} catch (error) {
|
||||
console.error('Error saving holiday:', error);
|
||||
alert('Ошибка сохранения праздника');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
weekday: 'short'
|
||||
});
|
||||
};
|
||||
|
||||
const getDayOfWeek = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
return days[date.getDay()];
|
||||
};
|
||||
|
||||
const isWeekend = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4 sm:mb-6">
|
||||
<h1 className="page-title">Праздничные дни</h1>
|
||||
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center justify-center">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить праздник
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="card mb-4 sm:mb-6 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="h-5 w-5 text-blue-600 mr-2 sm:mr-3 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800 text-sm sm:text-base">Как это работает</h3>
|
||||
<p className="text-xs sm:text-sm text-blue-700">
|
||||
В праздники транспорт работает по расписанию воскресенья.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Year Selector */}
|
||||
<div className="flex items-center space-x-2 sm:space-x-4 mb-4 sm:mb-6 overflow-x-auto hide-scrollbar">
|
||||
<span className="text-gray-600 text-sm flex-shrink-0">Год:</span>
|
||||
<div className="flex space-x-1 sm:space-x-2">
|
||||
{[CURRENT_YEAR - 1, CURRENT_YEAR, CURRENT_YEAR + 1].map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
className={`px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedYear === year
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holidays List */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base sm:text-lg font-semibold">
|
||||
Праздники {selectedYear} ({holidays.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const btn = document.querySelector('.refresh-btn');
|
||||
btn?.classList.add('spin-once');
|
||||
setTimeout(() => btn?.classList.remove('spin-once'), 500);
|
||||
loadHolidays();
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 p-1 refresh-btn transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{holidays.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{holidays.map((holiday, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between p-3 sm:p-4 rounded-lg border list-item-animated transition-all duration-200 hover:shadow-md ${
|
||||
isWeekend(holiday.date)
|
||||
? 'bg-gray-50 border-gray-200 hover:border-gray-300'
|
||||
: 'bg-amber-50 border-amber-200 hover:border-amber-300'
|
||||
}`}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="flex items-center min-w-0">
|
||||
<div className={`w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex flex-col items-center justify-center mr-3 flex-shrink-0 ${
|
||||
isWeekend(holiday.date)
|
||||
? 'bg-gray-200 text-gray-600'
|
||||
: 'bg-amber-200 text-amber-800'
|
||||
}`}>
|
||||
<span className="text-[10px] sm:text-xs font-medium">
|
||||
{getDayOfWeek(holiday.date)}
|
||||
</span>
|
||||
<span className="text-sm sm:text-lg font-bold">
|
||||
{new Date(holiday.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base truncate">{holiday.name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(holiday.date).toLocaleDateString('ru-RU', { month: 'short' })}
|
||||
</span>
|
||||
{holiday.is_recurring && (
|
||||
<span className="text-[10px] sm:text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
Ежегодный
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1 flex-shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => handleEdit(holiday)}
|
||||
className="text-gray-500 hover:text-gray-700 p-2"
|
||||
>
|
||||
<Edit className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(holiday.id)}
|
||||
className="text-red-500 hover:text-red-700 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-sm">Нет праздников на {selectedYear}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference - hidden on mobile */}
|
||||
<div className="card mt-6 hidden sm:block">
|
||||
<h3 className="font-semibold mb-4">Официальные праздники Беларуси</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">1 января</span>
|
||||
<span>Новый год</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">7 января</span>
|
||||
<span>Рождество (прав.)</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">8 марта</span>
|
||||
<span>День женщин</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">1 мая</span>
|
||||
<span>Праздник труда</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">9 мая</span>
|
||||
<span>День Победы</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">3 июля</span>
|
||||
<span>День Независимости</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">7 ноября</span>
|
||||
<span>День Октябрьской рев.</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-20 font-medium">25 декабря</span>
|
||||
<span>Рождество (кат.)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fab sm:hidden"
|
||||
title="Добавить праздник"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Holiday Form Modal */}
|
||||
{showForm && (
|
||||
<HolidayForm
|
||||
holiday={editingHoliday}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowForm(false);
|
||||
setEditingHoliday(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HolidayForm({ holiday, onSave, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
date: holiday?.date?.split('T')[0] || '',
|
||||
name: holiday?.name || '',
|
||||
isRecurring: holiday?.is_recurring || false
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-md sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col animate-[slideUp_0.3s_ease-out] sm:animate-[scaleIn_0.2s_ease-out]">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{holiday ? 'Редактировать' : 'Добавить'} праздник
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="label">Дата *</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Название *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
placeholder="например: День города"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRecurring"
|
||||
checked={formData.isRecurring}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 text-primary-600 border-gray-300 rounded mr-3"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-900 font-medium">Повторяется ежегодно</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Праздник будет применяться каждый год
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/pages/LoginPage.jsx
Normal file
132
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Bus, Lock, User, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative">
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center mb-8 animate-[fadeSlideIn_0.6s_ease-out]">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl shadow-lg mb-4 animate-[bounce_1s_ease-in-out]">
|
||||
<Bus className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Polotsk Transit</h1>
|
||||
<p className="text-gray-600">Административная панель</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl p-8 border border-gray-100 animate-[scaleIn_0.4s_ease-out_0.2s_backwards]">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">Вход в систему</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-[shake_0.4s_ease-out]">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">Ошибка входа</p>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="label">Имя пользователя</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="admin"
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="label">Пароль</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pl-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-3 text-base font-semibold relative overflow-hidden group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin inline" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative z-10">Войти</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-700 to-primary-600 transform scale-x-0 group-hover:scale-x-100 transition-transform origin-left"></div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Мастер-аккаунт по умолчанию:
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded">admin / admin123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-gray-500 mt-6 animate-[fadeIn_0.6s_ease-out_0.4s_backwards]">
|
||||
© 2024 Polotsk Transit API • v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
frontend/src/pages/ProfilePage.jsx
Normal file
309
frontend/src/pages/ProfilePage.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { User, Mail, Shield, Calendar, Key, LogOut, Loader2 } from 'lucide-react';
|
||||
import AvatarUpload from '../components/AvatarUpload';
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user: authUser, logout, getAccessToken } = useAuth();
|
||||
const [user, setUser] = useState(authUser);
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Синхронизация с AuthContext
|
||||
useEffect(() => {
|
||||
setUser(authUser);
|
||||
}, [authUser]);
|
||||
|
||||
// Refresh user data when avatar changes
|
||||
const refreshUserData = async () => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await axios.get(`${API_BASE_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUser(response.data);
|
||||
|
||||
// ВАЖНО: обновить также user в localStorage и перезагрузить страницу
|
||||
// чтобы AuthContext обновился
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = async (newAvatarUrl) => {
|
||||
// Сначала обновим локальный state для мгновенной реакции
|
||||
setUser(prev => ({ ...prev, avatar_url: newAvatarUrl }));
|
||||
|
||||
// Затем перезагрузим данные пользователя и обновим контекст
|
||||
await refreshUserData();
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
setError('Новые пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordData.newPassword.length < 6) {
|
||||
setError('Пароль должен быть не менее 6 символов');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
await axios.post(
|
||||
`${API_BASE_URL}/auth/change-password`,
|
||||
{
|
||||
currentPassword: passwordData.currentPassword,
|
||||
newPassword: passwordData.newPassword,
|
||||
refreshToken
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
setSuccess('Пароль успешно изменён');
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setShowPasswordForm(false);
|
||||
} catch (error) {
|
||||
setError(error.response?.data?.error || 'Ошибка изменения пароля');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (confirm('Вы уверены, что хотите выйти?')) {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="page-title mb-6">Профиль</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* User Info Card */}
|
||||
<div className="lg:col-span-2 card">
|
||||
<h2 className="text-lg font-semibold mb-6">Информация о пользователе</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Avatar and Basic Info */}
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pb-6 border-b border-gray-200">
|
||||
<AvatarUpload
|
||||
currentAvatar={user.avatar_url}
|
||||
onAvatarChange={handleAvatarChange}
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-1">
|
||||
{user.full_name || user.username}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">@{user.username}</p>
|
||||
<span className={`inline-block text-xs px-3 py-1 rounded-full font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<User className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Имя пользователя</p>
|
||||
<p className="font-medium">{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.email && (
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<Mail className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Email</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<Shield className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Роль</p>
|
||||
<p className="font-medium">
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.last_login && (
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<Calendar className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Последний вход</p>
|
||||
<p className="font-medium">
|
||||
{new Date(user.last_login).toLocaleString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.created_at && (
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<Calendar className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Дата создания</p>
|
||||
<p className="font-medium">
|
||||
{new Date(user.created_at).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Card */}
|
||||
<div className="space-y-4">
|
||||
{/* Security */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Безопасность</h3>
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="w-full btn-secondary flex items-center justify-center"
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Изменить пароль
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4">Сессия</h3>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full btn-danger flex items-center justify-center"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Выйти из аккаунта
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Password Form */}
|
||||
{showPasswordForm && (
|
||||
<div className="card mt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Изменить пароль</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Текущий пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||
className="input"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Новый пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||
className="input"
|
||||
required
|
||||
minLength={6}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Подтвердите новый пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) => setPasswordData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
className="input"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}}
|
||||
className="btn-secondary flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary flex-1 flex items-center justify-center"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
'Изменить пароль'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
705
frontend/src/pages/RouteEditorPage.jsx
Normal file
705
frontend/src/pages/RouteEditorPage.jsx
Normal file
@@ -0,0 +1,705 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline } from 'react-leaflet';
|
||||
import { ArrowLeft, Plus, Trash2, Clock, MapPin, Bus, Radio, Send } from 'lucide-react';
|
||||
import { routesApi, routeStopsApi, realtimeApi, telemetryApi } from '../services/api';
|
||||
import { RouteRealtimeClient } from '../services/realtime';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix default marker icon
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Custom marker icons
|
||||
const createNumberedIcon = (number, color) => {
|
||||
return L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div style="
|
||||
background-color: ${color};
|
||||
color: white;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
">${number}</div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const createVehicleIcon = (vehicle, color) => {
|
||||
const label = escapeHtml(vehicle.vehicle_number || vehicle.registration || `#${vehicle.vehicle_id}`);
|
||||
const statusColor = vehicle.is_online ? '#16a34a' : '#94a3b8';
|
||||
|
||||
return L.divIcon({
|
||||
className: 'vehicle-marker',
|
||||
html: `<div style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: white;
|
||||
color: ${color};
|
||||
border: 2px solid ${color};
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18);
|
||||
white-space: nowrap;
|
||||
">
|
||||
<span style="
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: ${statusColor};
|
||||
flex-shrink: 0;
|
||||
"></span>
|
||||
<span>${label}</span>
|
||||
</div>`,
|
||||
iconSize: [72, 28],
|
||||
iconAnchor: [36, 14]
|
||||
});
|
||||
};
|
||||
|
||||
const upsertVehicle = (vehicles, nextVehicle) => {
|
||||
const index = vehicles.findIndex((vehicle) => vehicle.vehicle_id === nextVehicle.vehicle_id);
|
||||
|
||||
if (index === -1) {
|
||||
return [...vehicles, nextVehicle].sort((left, right) =>
|
||||
String(left.vehicle_number || left.vehicle_id).localeCompare(String(right.vehicle_number || right.vehicle_id))
|
||||
);
|
||||
}
|
||||
|
||||
return vehicles.map((vehicle, currentIndex) =>
|
||||
currentIndex === index ? nextVehicle : vehicle
|
||||
);
|
||||
};
|
||||
|
||||
const getRealtimeStatusLabel = (state) => {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return 'Подключение к realtime...';
|
||||
case 'authenticating':
|
||||
return 'Авторизация в realtime...';
|
||||
case 'authenticated':
|
||||
return 'Подписка на маршрут...';
|
||||
case 'connected':
|
||||
return 'Realtime подключен';
|
||||
case 'reconnecting':
|
||||
return 'Realtime переподключается...';
|
||||
case 'error':
|
||||
return 'Ошибка realtime';
|
||||
case 'disconnected':
|
||||
return 'Realtime отключен';
|
||||
default:
|
||||
return 'Ожидание подключения';
|
||||
}
|
||||
};
|
||||
|
||||
const formatVehicleAge = (secondsSinceUpdate) => {
|
||||
if (secondsSinceUpdate == null) {
|
||||
return 'нет данных';
|
||||
}
|
||||
|
||||
if (secondsSinceUpdate < 60) {
|
||||
return `${secondsSinceUpdate} сек назад`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(secondsSinceUpdate / 60);
|
||||
return `${minutes} мин назад`;
|
||||
};
|
||||
|
||||
const POLOTSK_CENTER = [55.4869, 28.7856];
|
||||
|
||||
export default function RouteEditorPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
const [route, setRoute] = useState(null);
|
||||
const [routeStops, setRouteStops] = useState([]);
|
||||
const [availableStops, setAvailableStops] = useState([]);
|
||||
const [liveVehicles, setLiveVehicles] = useState([]);
|
||||
const [realtimeStatus, setRealtimeStatus] = useState({ state: 'idle' });
|
||||
const [realtimeError, setRealtimeError] = useState(null);
|
||||
const [sendingDemo, setSendingDemo] = useState(false);
|
||||
const [demoStep, setDemoStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedStopForAdd, setSelectedStopForAdd] = useState(null);
|
||||
const [timeOffsetInput, setTimeOffsetInput] = useState(0);
|
||||
|
||||
const [mobileView, setMobileView] = useState('map'); // 'map' or 'list'
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [routeRes, stopsRes, availableRes, liveVehiclesRes] = await Promise.all([
|
||||
routesApi.getById(id),
|
||||
routeStopsApi.getRouteStops(id),
|
||||
routeStopsApi.getAvailableStops(id),
|
||||
realtimeApi.getRouteVehicles(id).catch(() => ({ data: [] }))
|
||||
]);
|
||||
|
||||
setRoute(routeRes.data);
|
||||
setRouteStops(stopsRes.data);
|
||||
setAvailableStops(availableRes.data);
|
||||
setLiveVehicles(liveVehiclesRes.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading route data:', error);
|
||||
alert('Ошибка загрузки данных маршрута');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const routeId = Number.parseInt(id, 10);
|
||||
|
||||
if (!Number.isInteger(routeId) || routeId <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const realtimeClient = new RouteRealtimeClient({
|
||||
routeId,
|
||||
token: localStorage.getItem('accessToken'),
|
||||
apiKey: import.meta.env.VITE_API_KEY || 'dev_key_12345678',
|
||||
onStatusChange: (status) => {
|
||||
setRealtimeStatus(status);
|
||||
|
||||
if (status.state === 'connected') {
|
||||
setRealtimeError(null);
|
||||
}
|
||||
},
|
||||
onSnapshot: (vehicles) => {
|
||||
setLiveVehicles(Array.isArray(vehicles) ? vehicles : []);
|
||||
},
|
||||
onVehiclePosition: (vehicle) => {
|
||||
setLiveVehicles((currentVehicles) => upsertVehicle(currentVehicles, vehicle));
|
||||
},
|
||||
onError: (message) => {
|
||||
setRealtimeError(message);
|
||||
}
|
||||
});
|
||||
|
||||
realtimeClient.connect();
|
||||
|
||||
return () => {
|
||||
realtimeClient.disconnect();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const handleAddStop = async () => {
|
||||
if (!selectedStopForAdd) return;
|
||||
|
||||
try {
|
||||
await routeStopsApi.addStop(id, {
|
||||
stopId: selectedStopForAdd.id,
|
||||
timeOffsetMinutes: timeOffsetInput
|
||||
});
|
||||
|
||||
setShowAddModal(false);
|
||||
setSelectedStopForAdd(null);
|
||||
setTimeOffsetInput(0);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Error adding stop:', error);
|
||||
alert('Ошибка добавления остановки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStop = async (routeStopId) => {
|
||||
if (!confirm('Удалить остановку из маршрута?')) return;
|
||||
|
||||
try {
|
||||
await routeStopsApi.removeStop(id, routeStopId);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Error removing stop:', error);
|
||||
alert('Ошибка удаления остановки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTimeOffset = async (routeStopId, newOffset) => {
|
||||
try {
|
||||
await routeStopsApi.updateStop(id, routeStopId, {
|
||||
timeOffsetMinutes: parseInt(newOffset)
|
||||
});
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Error updating time offset:', error);
|
||||
alert('Ошибка обновления времени');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveStop = async (index, direction) => {
|
||||
const newStops = [...routeStops];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= newStops.length) return;
|
||||
|
||||
// Swap stops
|
||||
[newStops[index], newStops[targetIndex]] = [newStops[targetIndex], newStops[index]];
|
||||
|
||||
// Update sequences
|
||||
const reorderedStops = newStops.map((stop, idx) => ({
|
||||
stopId: stop.stop_id,
|
||||
sequence: idx + 1,
|
||||
timeOffsetMinutes: stop.time_offset_minutes
|
||||
}));
|
||||
|
||||
try {
|
||||
await routeStopsApi.reorderStops(id, reorderedStops);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Error reordering stops:', error);
|
||||
alert('Ошибка изменения порядка');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendDemoTelemetry = async () => {
|
||||
if (!route || routeStops.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStop = routeStops[demoStep % routeStops.length];
|
||||
|
||||
try {
|
||||
setSendingDemo(true);
|
||||
setRealtimeError(null);
|
||||
|
||||
await telemetryApi.ingest({
|
||||
routeId: route.id,
|
||||
vehicleNumber: `DEMO-${route.route_number}`,
|
||||
vehicleType: route.type || 'bus',
|
||||
lat: nextStop.latitude,
|
||||
lon: nextStop.longitude,
|
||||
speedKmh: 18 + (demoStep % 4) * 6,
|
||||
heading: (demoStep * 45) % 360,
|
||||
accuracyMeters: 5,
|
||||
recordedAt: new Date().toISOString(),
|
||||
sourceType: 'admin-demo',
|
||||
sourceRef: 'route-editor-page'
|
||||
});
|
||||
|
||||
setDemoStep((currentStep) => currentStep + 1);
|
||||
} catch (error) {
|
||||
console.error('Error sending demo telemetry:', error);
|
||||
setRealtimeError(error.response?.data?.error || 'Не удалось отправить demo GPS');
|
||||
} finally {
|
||||
setSendingDemo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate route path for polyline
|
||||
const routePath = routeStops.map(stop => [stop.latitude, stop.longitude]);
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Маршрут не найден</p>
|
||||
<Link to="/routes" className="text-primary-600 hover:underline">
|
||||
Вернуться к списку
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex items-center">
|
||||
<Link to="/routes" className="mr-3 sm:mr-4 text-gray-500 hover:text-gray-700 p-1">
|
||||
<ArrowLeft className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg mr-3"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-2xl font-bold text-gray-900">{route.name}</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-500">Редактирование остановок</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary hidden sm:flex items-center justify-center w-full sm:w-auto"
|
||||
disabled={availableStops.length === 0}
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить остановку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for adding stop */}
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="fab sm:hidden"
|
||||
disabled={availableStops.length === 0}
|
||||
title="Добавить остановку"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Mobile: Tabs for Map/List view */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<div className="flex rounded-lg bg-gray-100 p-1 relative">
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: 'calc(50% - 4px)',
|
||||
left: mobileView === 'map' ? '4px' : 'calc(50% + 0px)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setMobileView('map')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-md text-sm font-medium transition-colors relative z-10 flex items-center justify-center ${
|
||||
mobileView === 'map' ? 'text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-1.5" />
|
||||
Карта
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileView('list')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-md text-sm font-medium transition-colors relative z-10 flex items-center justify-center ${
|
||||
mobileView === 'list' ? 'text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Остановки ({routeStops.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Map */}
|
||||
<div className={`card overflow-hidden flex flex-col ${mobileView === 'list' ? 'hidden lg:flex' : ''}`}
|
||||
style={{ height: 'calc(100vh - 280px)', minHeight: '350px' }}>
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center text-sm font-medium text-gray-900">
|
||||
<Radio className="h-4 w-4 mr-2 text-primary-600" />
|
||||
{getRealtimeStatusLabel(realtimeStatus.state)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Живых машин на карте: <strong>{liveVehicles.length}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendDemoTelemetry}
|
||||
disabled={sendingDemo || routeStops.length === 0}
|
||||
className="btn-secondary inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{sendingDemo ? 'Отправка GPS...' : 'Демо GPS'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{realtimeError && (
|
||||
<p className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
{realtimeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<MapContainer
|
||||
center={routeStops.length > 0
|
||||
? [routeStops[0].latitude, routeStops[0].longitude]
|
||||
: POLOTSK_CENTER}
|
||||
zoom={14}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap'
|
||||
/>
|
||||
|
||||
{/* Route polyline */}
|
||||
{routePath.length > 1 && (
|
||||
<Polyline
|
||||
positions={routePath}
|
||||
color={route.color}
|
||||
weight={4}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Route stops markers */}
|
||||
{routeStops.map((stop, index) => (
|
||||
<Marker
|
||||
key={stop.route_stop_id}
|
||||
position={[stop.latitude, stop.longitude]}
|
||||
icon={createNumberedIcon(index + 1, route.color)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<h3 className="font-semibold text-sm">{stop.name}</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
Порядок: {stop.sequence}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Время: {stop.time_offset_minutes} мин.
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Available stops (faded) */}
|
||||
{availableStops.map(stop => (
|
||||
<Marker
|
||||
key={`available-${stop.id}`}
|
||||
position={[stop.latitude, stop.longitude]}
|
||||
opacity={0.5}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<h3 className="font-semibold text-sm">{stop.name}</h3>
|
||||
<p className="text-xs text-gray-500">Не в маршруте</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedStopForAdd(stop);
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="mt-2 text-xs bg-primary-600 text-white px-2 py-1.5 rounded hover:bg-primary-700"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Live vehicles */}
|
||||
{liveVehicles.map((vehicle) => (
|
||||
<Marker
|
||||
key={`vehicle-${vehicle.vehicle_id}`}
|
||||
position={[vehicle.latitude, vehicle.longitude]}
|
||||
icon={createVehicleIcon(vehicle, route.color)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[180px] p-1">
|
||||
<div className="flex items-center text-sm font-semibold text-gray-900">
|
||||
<Bus className="mr-2 h-4 w-4 text-primary-600" />
|
||||
{vehicle.vehicle_number || vehicle.registration || `Транспорт #${vehicle.vehicle_id}`}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">
|
||||
Статус: {vehicle.is_online ? 'online' : 'offline'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Скорость: {vehicle.speed_kmh != null ? `${vehicle.speed_kmh} км/ч` : 'нет данных'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Последнее обновление: {formatVehicleAge(vehicle.seconds_since_update)}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops List */}
|
||||
<div className={`card overflow-hidden flex flex-col ${mobileView === 'map' ? 'hidden lg:flex' : ''}`}
|
||||
style={{ height: 'calc(100vh - 280px)', minHeight: '350px' }}>
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-3 flex items-center flex-shrink-0">
|
||||
<MapPin className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Остановки ({routeStops.length})
|
||||
</h2>
|
||||
|
||||
{routeStops.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 flex-1 flex flex-col items-center justify-center">
|
||||
<MapPin className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">Нет остановок</p>
|
||||
<p className="text-xs">Добавьте остановки в маршрут</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 overflow-y-auto flex-1 smooth-scroll">
|
||||
{routeStops.map((stop, index) => (
|
||||
<div
|
||||
key={stop.route_stop_id}
|
||||
className="flex items-center p-2 sm:p-3 bg-gray-50 rounded-lg border border-gray-200 list-item-animated list-item-touch transition-all duration-200 hover:shadow-md hover:border-gray-300"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{/* Sequence number */}
|
||||
<div
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-white font-bold text-xs sm:text-sm mr-2 sm:mr-3 flex-shrink-0"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="flex-grow min-w-0 mr-2">
|
||||
<h3 className="font-medium text-gray-900 truncate text-sm">{stop.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Time offset */}
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<Clock className="h-3 w-3 sm:h-4 sm:w-4 text-gray-400 mr-1" />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={stop.time_offset_minutes}
|
||||
onChange={(e) => handleUpdateTimeOffset(stop.route_stop_id, e.target.value)}
|
||||
className="w-12 sm:w-14 text-center text-xs sm:text-sm border border-gray-300 rounded px-1 py-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 ml-1 hidden sm:inline">мин</span>
|
||||
</div>
|
||||
|
||||
{/* Move buttons */}
|
||||
<div className="flex flex-col mx-1 sm:mx-2">
|
||||
<button
|
||||
onClick={() => handleMoveStop(index, 'up')}
|
||||
disabled={index === 0}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 p-0.5"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveStop(index, 'down')}
|
||||
disabled={index === routeStops.length - 1}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 p-0.5"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => handleRemoveStop(stop.route_stop_id)}
|
||||
className="text-red-500 hover:text-red-700 p-1 flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{routeStops.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 flex-shrink-0">
|
||||
<div className="text-xs sm:text-sm text-gray-600 flex justify-between">
|
||||
<span>Всего: <strong>{routeStops.length}</strong></span>
|
||||
<span>
|
||||
Время: <strong>
|
||||
{routeStops.length > 0
|
||||
? routeStops[routeStops.length - 1].time_offset_minutes
|
||||
: 0} мин
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Stop Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-md sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">Добавить остановку</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<label className="label">Выберите остановку</label>
|
||||
<select
|
||||
value={selectedStopForAdd?.id || ''}
|
||||
onChange={(e) => {
|
||||
const stop = availableStops.find(s => s.id === parseInt(e.target.value));
|
||||
setSelectedStopForAdd(stop);
|
||||
}}
|
||||
className="input"
|
||||
>
|
||||
<option value="">-- Выберите остановку --</option>
|
||||
{availableStops.map(stop => (
|
||||
<option key={stop.id} value={stop.id}>
|
||||
{stop.name} {stop.address ? `(${stop.address})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Время от начала (мин)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={timeOffsetInput}
|
||||
onChange={(e) => setTimeOffsetInput(parseInt(e.target.value) || 0)}
|
||||
className="input"
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
Сколько минут от начала маршрута до этой остановки
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setSelectedStopForAdd(null);
|
||||
setTimeOffsetInput(0);
|
||||
}}
|
||||
className="btn-secondary w-full sm:w-auto"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddStop}
|
||||
disabled={!selectedStopForAdd}
|
||||
className="btn-primary w-full sm:w-auto disabled:opacity-50"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
450
frontend/src/pages/RoutesPage.jsx
Normal file
450
frontend/src/pages/RoutesPage.jsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Edit, Trash2, Bus, MapPin, Search, Filter, Grid3x3, List, Clock } from 'lucide-react';
|
||||
import { routesApi } from '../services/api';
|
||||
import RouteForm from '../components/RouteForm';
|
||||
import { PageLoader, CardSkeleton } from '../components/AnimatedComponents';
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [filteredRoutes, setFilteredRoutes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [viewMode, setViewMode] = useState('grid'); // 'grid' or 'list'
|
||||
|
||||
useEffect(() => {
|
||||
loadRoutes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterRoutes();
|
||||
}, [routes, searchQuery, typeFilter]);
|
||||
|
||||
const loadRoutes = async () => {
|
||||
try {
|
||||
const response = await routesApi.getAll();
|
||||
setRoutes(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading routes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterRoutes = () => {
|
||||
let filtered = routes;
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(route => route.type === typeFilter);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(route =>
|
||||
route.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
route.route_number.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredRoutes(filtered);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingRoute(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (route) => {
|
||||
setEditingRoute(route);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот маршрут?')) return;
|
||||
|
||||
try {
|
||||
await routesApi.delete(id);
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting route:', error);
|
||||
alert('Ошибка удаления маршрута');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingRoute) {
|
||||
await routesApi.update(editingRoute.id, data);
|
||||
} else {
|
||||
await routesApi.create(data);
|
||||
}
|
||||
setShowForm(false);
|
||||
loadRoutes();
|
||||
} catch (error) {
|
||||
console.error('Error saving route:', error);
|
||||
alert('Ошибка сохранения маршрута');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div className="skeleton h-9 w-40 rounded" />
|
||||
<div className="skeleton h-10 w-44 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<CardSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const typeLabels = {
|
||||
all: { label: 'Все', icon: Bus, count: routes.length },
|
||||
bus: { label: 'Автобусы', icon: Bus, count: routes.filter(r => r.type === 'bus').length },
|
||||
minibus: { label: 'Маршрутки', icon: Bus, count: routes.filter(r => r.type === 'minibus').length },
|
||||
trolleybus: { label: 'Троллейбусы', icon: Bus, count: routes.filter(r => r.type === 'trolleybus').length },
|
||||
tram: { label: 'Трамваи', icon: Bus, count: routes.filter(r => r.type === 'tram').length },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="page-title">Маршруты</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{filteredRoutes.length} {filteredRoutes.length === 1 ? 'маршрут' : 'маршрутов'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center justify-center">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<RouteForm
|
||||
route={editingRoute}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters and Search Bar */}
|
||||
<div className="card mb-6 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию или номеру..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input pl-10 pr-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(typeLabels).map(([type, { label, count }]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(type)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
typeFilter === type
|
||||
? 'bg-primary-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`ml-1.5 ${typeFilter === type ? 'text-primary-100' : 'text-gray-500'}`}>
|
||||
({count})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Отображение: <span className="font-medium">{filteredRoutes.length}</span> из {routes.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title="Сетка"
|
||||
>
|
||||
<Grid3x3 className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title="Список"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routes Grid/List */}
|
||||
{filteredRoutes.length > 0 ? (
|
||||
<div className={viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{filteredRoutes.map((route, index) => (
|
||||
viewMode === 'grid' ? (
|
||||
<RouteCard
|
||||
key={route.id}
|
||||
route={route}
|
||||
index={index}
|
||||
onEdit={() => handleEdit(route)}
|
||||
onDelete={() => handleDelete(route.id)}
|
||||
/>
|
||||
) : (
|
||||
<RouteListItem
|
||||
key={route.id}
|
||||
route={route}
|
||||
index={index}
|
||||
onEdit={() => handleEdit(route)}
|
||||
onDelete={() => handleDelete(route.id)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<Bus className="empty-state-icon" />
|
||||
<p className="empty-state-text">
|
||||
{searchQuery || typeFilter !== 'all'
|
||||
? 'Маршруты не найдены. Попробуйте изменить фильтры.'
|
||||
: 'Нет маршрутов. Создайте первый!'}
|
||||
</p>
|
||||
{!searchQuery && typeFilter === 'all' && (
|
||||
<button onClick={handleCreate} className="btn-primary">
|
||||
<Plus className="h-5 w-5 mr-2 inline" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB */}
|
||||
{routes.length > 0 && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fab sm:hidden"
|
||||
title="Добавить маршрут"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({ route, index, onEdit, onDelete }) {
|
||||
const typeLabels = {
|
||||
bus: 'Автобус',
|
||||
minibus: 'Маршрутка',
|
||||
trolleybus: 'Троллейбус',
|
||||
tram: 'Трамвай'
|
||||
};
|
||||
|
||||
// Lighten color for gradient
|
||||
const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
const rgb = hexToRgb(route.color);
|
||||
const lightColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.1)` : '#f3f4f6';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card overflow-hidden list-item-animated group relative"
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
background: `linear-gradient(135deg, ${lightColor} 0%, white 100%)`
|
||||
}}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1"
|
||||
style={{ backgroundColor: route.color }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4 pt-2">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center text-white font-bold text-2xl shadow-lg transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: route.color,
|
||||
boxShadow: `0 4px 12px ${route.color}40`
|
||||
}}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="ml-4 min-w-0 flex-1">
|
||||
<h3 className="font-bold text-gray-900 text-lg leading-tight mb-1 truncate">
|
||||
{route.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 flex items-center">
|
||||
<Bus className="h-3.5 w-3.5 mr-1" />
|
||||
{typeLabels[route.type]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex items-center text-gray-500 text-xs mb-1">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1" />
|
||||
Остановок
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{route.stops_count || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white/60 backdrop-blur-sm rounded-lg p-3 border border-gray-100">
|
||||
<div className="flex items-center text-gray-500 text-xs mb-1">
|
||||
<Clock className="h-3.5 w-3.5 mr-1" />
|
||||
Расписаний
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{route.schedules_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="btn-primary flex items-center justify-center text-sm group/btn"
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-2 transition-transform group-hover/btn:scale-110" />
|
||||
Редактировать остановки
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="btn-secondary flex-1 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="btn-danger flex items-center justify-center px-4"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteListItem({ route, index, onEdit, onDelete }) {
|
||||
const typeLabels = {
|
||||
bus: 'Автобус',
|
||||
minibus: 'Маршрутка',
|
||||
trolleybus: 'Троллейбус',
|
||||
tram: 'Трамвай'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card list-item-animated group hover:shadow-lg transition-all duration-200"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Route Number Badge */}
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-white font-bold text-2xl shadow-md transition-transform duration-300 group-hover:scale-110 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: route.color,
|
||||
boxShadow: `0 4px 12px ${route.color}40`
|
||||
}}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
|
||||
{/* Route Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1 truncate">{route.name}</h3>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<Bus className="h-4 w-4 mr-1" />
|
||||
{typeLabels[route.type]}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{route.stops_count || 0} остановок
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
{route.schedules_count || 0} расписаний
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden sm:flex items-center gap-2 flex-shrink-0">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="btn-primary flex items-center text-sm"
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
Остановки
|
||||
</Link>
|
||||
<button onClick={onEdit} className="btn-secondary flex items-center text-sm">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button onClick={onDelete} className="btn-danger p-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex sm:hidden flex-col gap-2 flex-shrink-0">
|
||||
<Link
|
||||
to={`/routes/${route.id}/edit`}
|
||||
className="p-2 bg-primary-100 text-primary-600 rounded-lg hover:bg-primary-200 transition-colors"
|
||||
>
|
||||
<MapPin className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
585
frontend/src/pages/SchedulesPage.jsx
Normal file
585
frontend/src/pages/SchedulesPage.jsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, Clock, Plus, Edit, Trash2, Copy, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
||||
import { routesApi, schedulesApi, holidaysApi } from '../services/api';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
|
||||
const DAY_TYPES = [
|
||||
{ value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' },
|
||||
{ value: 'saturday', label: 'Суббота', description: 'Сб' },
|
||||
{ value: 'sunday', label: 'Воскресенье', description: 'Вс' },
|
||||
{ value: 'holiday', label: 'Праздники', description: 'По расписанию воскресенья' }
|
||||
];
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [selectedRoute, setSelectedRoute] = useState(null);
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState(null);
|
||||
const [upcomingHolidays, setUpcomingHolidays] = useState([]);
|
||||
const [expandedSchedule, setExpandedSchedule] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoutes();
|
||||
loadUpcomingHolidays();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoute) {
|
||||
loadSchedules(selectedRoute.id);
|
||||
}
|
||||
}, [selectedRoute]);
|
||||
|
||||
const loadRoutes = async () => {
|
||||
try {
|
||||
const response = await routesApi.getAll();
|
||||
setRoutes(response.data);
|
||||
if (response.data.length > 0) {
|
||||
setSelectedRoute(response.data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading routes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedules = async (routeId) => {
|
||||
try {
|
||||
const response = await schedulesApi.getRouteSchedules(routeId);
|
||||
setSchedules(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading schedules:', error);
|
||||
setSchedules([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUpcomingHolidays = async () => {
|
||||
try {
|
||||
const response = await holidaysApi.getUpcoming(5);
|
||||
setUpcomingHolidays(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading holidays:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSchedule = (dayType) => {
|
||||
setEditingSchedule({
|
||||
routeId: selectedRoute.id,
|
||||
dayType: dayType,
|
||||
departureTimes: []
|
||||
});
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEditSchedule = (schedule) => {
|
||||
setEditingSchedule(schedule);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (id) => {
|
||||
if (!confirm('Удалить это расписание?')) return;
|
||||
|
||||
try {
|
||||
await schedulesApi.delete(id);
|
||||
loadSchedules(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
alert('Ошибка удаления расписания');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySchedule = async (fromDayType, toDayType) => {
|
||||
try {
|
||||
await schedulesApi.copy(selectedRoute.id, fromDayType, toDayType);
|
||||
loadSchedules(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error copying schedule:', error);
|
||||
alert('Ошибка копирования расписания');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSchedule = async (data) => {
|
||||
try {
|
||||
if (editingSchedule.id) {
|
||||
await schedulesApi.update(editingSchedule.id, data);
|
||||
} else {
|
||||
await schedulesApi.create(data);
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingSchedule(null);
|
||||
loadSchedules(selectedRoute.id);
|
||||
} catch (error) {
|
||||
console.error('Error saving schedule:', error);
|
||||
alert(error.response?.data?.message || 'Ошибка сохранения расписания');
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleForDayType = (dayType) => {
|
||||
return schedules.find(s => s.day_type === dayType);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4 sm:mb-6">
|
||||
<h1 className="page-title">Расписания</h1>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Holidays Info */}
|
||||
{upcomingHolidays.length > 0 && (
|
||||
<div className="card mb-4 sm:mb-6 bg-amber-50 border-amber-200">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 mr-2 sm:mr-3 mt-0.5 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-amber-800 text-sm sm:text-base">Ближайшие праздники</h3>
|
||||
<p className="text-xs sm:text-sm text-amber-700 mb-2">
|
||||
Транспорт идет по расписанию воскресенья
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
{upcomingHolidays.slice(0, 3).map((holiday, idx) => (
|
||||
<span key={idx} className="bg-amber-100 text-amber-800 px-2 py-0.5 sm:py-1 rounded text-xs">
|
||||
{new Date(holiday.next_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })} — {holiday.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: Route selector dropdown */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<label className="label">Выберите маршрут</label>
|
||||
<select
|
||||
value={selectedRoute?.id || ''}
|
||||
onChange={(e) => {
|
||||
const route = routes.find(r => r.id === parseInt(e.target.value));
|
||||
setSelectedRoute(route);
|
||||
}}
|
||||
className="input"
|
||||
>
|
||||
{routes.map(route => (
|
||||
<option key={route.id} value={route.id}>
|
||||
{route.route_number} — {route.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
{/* Routes List - Desktop only */}
|
||||
<div className="card lg:col-span-1 hidden lg:block">
|
||||
<h2 className="text-lg font-semibold mb-4">Маршруты</h2>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{routes.map(route => (
|
||||
<button
|
||||
key={route.id}
|
||||
onClick={() => setSelectedRoute(route)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedRoute?.id === route.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm mr-2"
|
||||
style={{ backgroundColor: route.color }}
|
||||
>
|
||||
{route.route_number}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-gray-900 truncate">{route.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{route.stops_count || 0} остановок
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedules Grid */}
|
||||
<div className="lg:col-span-3">
|
||||
{selectedRoute ? (
|
||||
<>
|
||||
<div className="flex items-center mb-4">
|
||||
<div
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center text-white font-bold text-sm sm:text-base mr-2 sm:mr-3"
|
||||
style={{ backgroundColor: selectedRoute.color }}
|
||||
>
|
||||
{selectedRoute.route_number}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-xl font-semibold">{selectedRoute.name}</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500">Расписание движения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
{DAY_TYPES.map((dayType, index) => {
|
||||
const schedule = getScheduleForDayType(dayType.value);
|
||||
const isExpanded = expandedSchedule === dayType.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayType.value}
|
||||
className="card card-hover list-item-animated"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{dayType.label}</h3>
|
||||
<p className="text-sm text-gray-500">{dayType.description}</p>
|
||||
</div>
|
||||
{schedule ? (
|
||||
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
||||
{schedule.departure_times?.length || 0} рейсов
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">
|
||||
Не задано
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{schedule ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpandedSchedule(isExpanded ? null : dayType.value)}
|
||||
className="w-full text-left text-sm text-gray-600 hover:text-gray-900 flex items-center justify-between py-2 border-t border-gray-100"
|
||||
>
|
||||
<span>Показать расписание</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg max-h-48 overflow-y-auto animate-[fadeSlideIn_0.2s_ease-out]">
|
||||
<div className="grid grid-cols-4 gap-1 text-sm">
|
||||
{schedule.departure_times?.map((time, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-gray-700 list-item-animated"
|
||||
style={{ animationDelay: `${idx * 10}ms` }}
|
||||
>
|
||||
{time.substring(0, 5)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleEditSchedule(schedule)}
|
||||
className="btn-secondary flex-1 flex items-center justify-center text-sm py-1"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSchedule(schedule.id)}
|
||||
className="btn-danger px-2 py-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy to other day types */}
|
||||
{dayType.value !== 'holiday' && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500 mb-1">Копировать в:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{DAY_TYPES.filter(dt =>
|
||||
dt.value !== dayType.value &&
|
||||
!getScheduleForDayType(dt.value)
|
||||
).map(dt => (
|
||||
<button
|
||||
key={dt.value}
|
||||
onClick={() => handleCopySchedule(dayType.value, dt.value)}
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded"
|
||||
>
|
||||
{dt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleCreateSchedule(dayType.value)}
|
||||
className="w-full btn-primary flex items-center justify-center mt-2"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Создать расписание
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Выберите маршрут для просмотра расписания</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Editor Modal */}
|
||||
{showEditor && editingSchedule && (
|
||||
<ScheduleEditor
|
||||
schedule={editingSchedule}
|
||||
routeName={selectedRoute?.name}
|
||||
onSave={handleSaveSchedule}
|
||||
onCancel={() => {
|
||||
setShowEditor(false);
|
||||
setEditingSchedule(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
||||
const [times, setTimes] = useState(
|
||||
schedule.departure_times?.map(t => t.substring(0, 5)) || []
|
||||
);
|
||||
const [newTime, setNewTime] = useState('');
|
||||
const [bulkInput, setBulkInput] = useState('');
|
||||
const [showBulkInput, setShowBulkInput] = useState(false);
|
||||
|
||||
const dayTypeLabel = DAY_TYPES.find(dt => dt.value === schedule.dayType || dt.value === schedule.day_type)?.label;
|
||||
|
||||
const handleAddTime = () => {
|
||||
if (!newTime) return;
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/;
|
||||
if (!timeRegex.test(newTime)) {
|
||||
alert('Неверный формат времени. Используйте ЧЧ:ММ');
|
||||
return;
|
||||
}
|
||||
|
||||
if (times.includes(newTime)) {
|
||||
alert('Это время уже добавлено');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimes = [...times, newTime].sort();
|
||||
setTimes(newTimes);
|
||||
setNewTime('');
|
||||
};
|
||||
|
||||
const handleRemoveTime = (index) => {
|
||||
const newTimes = times.filter((_, i) => i !== index);
|
||||
setTimes(newTimes);
|
||||
};
|
||||
|
||||
const handleBulkAdd = () => {
|
||||
// Parse bulk input (comma, space, or newline separated)
|
||||
const inputTimes = bulkInput
|
||||
.split(/[,\s\n]+/)
|
||||
.map(t => t.trim())
|
||||
.filter(t => t);
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/;
|
||||
const validTimes = [];
|
||||
const invalidTimes = [];
|
||||
|
||||
for (const time of inputTimes) {
|
||||
if (timeRegex.test(time)) {
|
||||
// Normalize time format
|
||||
const parts = time.split(':');
|
||||
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}`;
|
||||
if (!times.includes(normalized) && !validTimes.includes(normalized)) {
|
||||
validTimes.push(normalized);
|
||||
}
|
||||
} else {
|
||||
invalidTimes.push(time);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidTimes.length > 0) {
|
||||
alert(`Неверный формат: ${invalidTimes.join(', ')}`);
|
||||
}
|
||||
|
||||
if (validTimes.length > 0) {
|
||||
const newTimes = [...times, ...validTimes].sort();
|
||||
setTimes(newTimes);
|
||||
setBulkInput('');
|
||||
setShowBulkInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (times.length === 0) {
|
||||
alert('Добавьте хотя бы одно время отправления');
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
routeId: schedule.routeId || schedule.route_id,
|
||||
dayType: schedule.dayType || schedule.day_type,
|
||||
departureTimes: times.map(t => `${t}:00`)
|
||||
});
|
||||
};
|
||||
|
||||
// Generate quick add times
|
||||
const generateQuickTimes = (startHour, endHour, interval) => {
|
||||
const generatedTimes = [];
|
||||
for (let h = startHour; h <= endHour; h++) {
|
||||
for (let m = 0; m < 60; m += interval) {
|
||||
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
if (!times.includes(time)) {
|
||||
generatedTimes.push(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newTimes = [...times, ...generatedTimes].sort();
|
||||
setTimes(newTimes);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-2xl sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[95vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{schedule.id ? 'Редактировать' : 'Создать'} расписание
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{routeName} — {dayTypeLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 smooth-scroll">
|
||||
{/* Quick Generation */}
|
||||
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium mb-2 text-sm sm:text-base">Быстрое создание</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => generateQuickTimes(6, 22, 30)}
|
||||
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
|
||||
>
|
||||
06-22 × 30мин
|
||||
</button>
|
||||
<button
|
||||
onClick={() => generateQuickTimes(6, 22, 20)}
|
||||
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
|
||||
>
|
||||
06-22 × 20мин
|
||||
</button>
|
||||
<button
|
||||
onClick={() => generateQuickTimes(7, 20, 60)}
|
||||
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
|
||||
>
|
||||
07-20 × 60мин
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimes([])}
|
||||
className="text-xs bg-red-50 text-red-600 border border-red-200 px-2 sm:px-3 py-1.5 rounded hover:bg-red-100"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Single Time */}
|
||||
<div className="mb-4">
|
||||
<label className="label">Добавить время</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="time"
|
||||
value={newTime}
|
||||
onChange={(e) => setNewTime(e.target.value)}
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button onClick={handleAddTime} className="btn-primary px-3">
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkInput(!showBulkInput)}
|
||||
className="btn-secondary text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Массовый</span>
|
||||
<span className="sm:hidden">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Input */}
|
||||
{showBulkInput && (
|
||||
<div className="mb-4 p-3 sm:p-4 bg-blue-50 rounded-lg">
|
||||
<label className="label">Массовый ввод</label>
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
Введите время через запятую (ЧЧ:ММ)
|
||||
</p>
|
||||
<textarea
|
||||
value={bulkInput}
|
||||
onChange={(e) => setBulkInput(e.target.value)}
|
||||
className="input mb-2"
|
||||
rows="2"
|
||||
placeholder="06:00, 06:30, 07:00..."
|
||||
/>
|
||||
<button onClick={handleBulkAdd} className="btn-primary text-sm">
|
||||
Добавить все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Times Grid */}
|
||||
<div className="mb-4">
|
||||
<label className="label">
|
||||
Время отправления ({times.length})
|
||||
</label>
|
||||
{times.length > 0 ? (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1.5 sm:gap-2 max-h-48 sm:max-h-64 overflow-y-auto p-2 bg-gray-50 rounded-lg">
|
||||
{times.map((time, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-white border border-gray-200 rounded px-1.5 sm:px-2 py-1"
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-mono">{time}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveTime(index)}
|
||||
className="text-red-500 hover:text-red-700 ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 sm:py-8 bg-gray-50 rounded-lg text-gray-500 text-sm">
|
||||
Нет времени отправления
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 flex-shrink-0 safe-bottom">
|
||||
<button onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button onClick={handleSave} className="btn-primary w-full sm:w-auto">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
frontend/src/pages/StopsPage.jsx
Normal file
250
frontend/src/pages/StopsPage.jsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, MapPin } from 'lucide-react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet';
|
||||
import { stopsApi } from '../services/api';
|
||||
import StopForm from '../components/StopForm';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix default marker icon
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const POLOTSK_CENTER = [55.4869, 28.7856]; // Полоцк координаты
|
||||
|
||||
export default function StopsPage() {
|
||||
const [stops, setStops] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingStop, setEditingStop] = useState(null);
|
||||
const [newStopLocation, setNewStopLocation] = useState(null);
|
||||
const [mobileView, setMobileView] = useState('map'); // 'map' or 'list'
|
||||
|
||||
useEffect(() => {
|
||||
loadStops();
|
||||
}, []);
|
||||
|
||||
const loadStops = async () => {
|
||||
try {
|
||||
const response = await stopsApi.getAll();
|
||||
setStops(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading stops:', error);
|
||||
alert('Ошибка загрузки остановок');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapClick = (lat, lon) => {
|
||||
setNewStopLocation({ latitude: lat, longitude: lon });
|
||||
setEditingStop(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (stop) => {
|
||||
setEditingStop(stop);
|
||||
setNewStopLocation(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
if (editingStop) {
|
||||
await stopsApi.update(editingStop.id, data);
|
||||
} else {
|
||||
await stopsApi.create(data);
|
||||
}
|
||||
setShowForm(false);
|
||||
setNewStopLocation(null);
|
||||
loadStops();
|
||||
} catch (error) {
|
||||
console.error('Error saving stop:', error);
|
||||
alert('Ошибка сохранения остановки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту остановку?')) return;
|
||||
|
||||
try {
|
||||
await stopsApi.delete(id);
|
||||
loadStops();
|
||||
} catch (error) {
|
||||
console.error('Error deleting stop:', error);
|
||||
alert('Ошибка удаления остановки');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 className="page-title">Остановки</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
Всего: <span className="font-semibold">{stops.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<StopForm
|
||||
stop={editingStop}
|
||||
initialLocation={newStopLocation}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false);
|
||||
setNewStopLocation(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile: Tabs for Map/List view */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<div className="flex rounded-lg bg-gray-100 p-1 relative">
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-white rounded-md shadow-sm transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: 'calc(50% - 4px)',
|
||||
left: mobileView === 'map' ? '4px' : 'calc(50% + 0px)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setMobileView('map')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-md text-sm font-medium transition-colors relative z-10 flex items-center justify-center ${
|
||||
mobileView === 'map' ? 'text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="h-4 w-4 mr-1.5" />
|
||||
Карта
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileView('list')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-md text-sm font-medium transition-colors relative z-10 flex items-center justify-center ${
|
||||
mobileView === 'list' ? 'text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Список ({stops.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Map */}
|
||||
<div className={`lg:col-span-2 ${mobileView === 'list' ? 'hidden lg:block' : ''}`}>
|
||||
<div className="card p-0 overflow-hidden" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
<MapContainer
|
||||
center={POLOTSK_CENTER}
|
||||
zoom={13}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
<MapClickHandler onMapClick={handleMapClick} />
|
||||
|
||||
{stops.map(stop => (
|
||||
<Marker
|
||||
key={stop.id}
|
||||
position={[stop.latitude, stop.longitude]}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1 min-w-[150px]">
|
||||
<h3 className="font-semibold text-sm">{stop.name}</h3>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-gray-600 mt-1">{stop.address}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Маршрутов: {stop.routes_count || 0}
|
||||
</p>
|
||||
<div className="flex space-x-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleEdit(stop)}
|
||||
className="text-xs bg-primary-600 text-white px-2 py-1.5 rounded hover:bg-primary-700 touch-target"
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
className="text-xs bg-red-600 text-white px-2 py-1.5 rounded hover:bg-red-700 touch-target"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-2">
|
||||
<MapPin className="h-4 w-4 inline mr-1" />
|
||||
Нажмите на карту, чтобы добавить остановку
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stops List */}
|
||||
<div className={`card overflow-y-auto smooth-scroll ${mobileView === 'map' ? 'hidden lg:block' : ''}`}
|
||||
style={{ maxHeight: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 sticky top-0 bg-white pb-2">
|
||||
Список остановок
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{stops.map((stop, index) => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:bg-gray-50 active:bg-gray-100 cursor-pointer transition-all duration-200 hover:shadow-md hover:border-gray-300 list-item-animated"
|
||||
onClick={() => handleEdit(stop)}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 text-sm sm:text-base">{stop.name}</h3>
|
||||
{stop.address && (
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-0.5">{stop.address}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Маршрутов: {stop.routes_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{stops.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Нет остановок
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for adding via list view */}
|
||||
{mobileView === 'list' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileView('map');
|
||||
}}
|
||||
className="fab lg:hidden"
|
||||
title="Добавить на карте"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component to handle map clicks
|
||||
function MapClickHandler({ onMapClick }) {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
470
frontend/src/pages/UsersPage.jsx
Normal file
470
frontend/src/pages/UsersPage.jsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, Users as UsersIcon, Shield, ShieldAlert, RefreshCw, Key } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import axios from 'axios';
|
||||
import { PageLoader } from '../components/AnimatedComponents';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(null);
|
||||
const { getAccessToken, user: currentUser } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await axios.get(`${API_BASE_URL}/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
alert('Ошибка загрузки пользователей');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Удалить этого пользователя?')) return;
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
await axios.delete(`${API_BASE_URL}/users/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
alert(error.response?.data?.error || 'Ошибка удаления пользователя');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
if (editingUser) {
|
||||
await axios.put(`${API_BASE_URL}/users/${editingUser.id}`, data, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
} else {
|
||||
await axios.post(`${API_BASE_URL}/users`, data, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', error);
|
||||
alert(error.response?.data?.error || 'Ошибка сохранения пользователя');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = async (userId, newPassword) => {
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
await axios.post(`${API_BASE_URL}/users/${userId}/reset-password`,
|
||||
{ newPassword },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
alert('Пароль успешно сброшен');
|
||||
setShowPasswordReset(null);
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
alert(error.response?.data?.error || 'Ошибка сброса пароля');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="page-title">Пользователи</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Всего: {users.length} • Активных: {users.filter(u => u.is_active).length}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn-primary hidden sm:flex items-center">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Создать пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<UserForm
|
||||
user={editingUser}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPasswordReset && (
|
||||
<PasswordResetForm
|
||||
user={showPasswordReset}
|
||||
onSubmit={(password) => handlePasswordReset(showPasswordReset.id, password)}
|
||||
onCancel={() => setShowPasswordReset(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="card mb-6 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">Управление доступом</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Администраторы</strong> могут управлять всеми данными и создавать пользователей.
|
||||
<strong> Пользователи</strong> имеют доступ только к просмотру и редактированию данных.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="space-y-4">
|
||||
{users.map((user, index) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
index={index}
|
||||
currentUserId={currentUser?.id}
|
||||
onEdit={() => handleEdit(user)}
|
||||
onDelete={() => handleDelete(user.id)}
|
||||
onResetPassword={() => setShowPasswordReset(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fab sm:hidden"
|
||||
title="Создать пользователя"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ user, index, currentUserId, onEdit, onDelete, onResetPassword }) {
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card list-item-animated group ${!user.is_active ? 'opacity-60' : ''}`}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
user.role === 'admin' ? 'bg-purple-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
{user.role === 'admin' ? (
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
) : (
|
||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 className="font-bold text-gray-900">{user.full_name || user.username}</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</span>
|
||||
{!user.is_active && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
Неактивен
|
||||
</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
|
||||
Вы
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">@{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{user.last_login ? (
|
||||
<span>Последний вход: {new Date(user.last_login).toLocaleString('ru-RU')}</span>
|
||||
) : (
|
||||
<span>Еще не входил</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onResetPassword}
|
||||
className="btn-secondary text-sm flex items-center"
|
||||
title="Сбросить пароль"
|
||||
>
|
||||
<Key className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Пароль</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="btn-secondary text-sm flex items-center"
|
||||
>
|
||||
<Edit className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Изменить</span>
|
||||
</button>
|
||||
{!isCurrentUser && (
|
||||
<button onClick={onDelete} className="btn-danger p-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserForm({ user, onSubmit, onCancel }) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: user?.username || '',
|
||||
password: '',
|
||||
full_name: user?.full_name || '',
|
||||
email: user?.email || '',
|
||||
role: user?.role || 'user',
|
||||
is_active: user?.is_active !== false
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const data = { ...formData };
|
||||
if (user) {
|
||||
delete data.password; // Don't send password on edit
|
||||
delete data.username; // Can't change username
|
||||
}
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-lg sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">
|
||||
{user ? 'Редактировать' : 'Создать'} пользователя
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-4">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="label">Имя пользователя *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
disabled={!!user}
|
||||
placeholder="username"
|
||||
/>
|
||||
{user && (
|
||||
<p className="text-xs text-gray-500 mt-1">Имя пользователя нельзя изменить</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
{!user && (
|
||||
<div>
|
||||
<label className="label">Пароль *</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required={!user}
|
||||
minLength={6}
|
||||
placeholder="Минимум 6 символов"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="label">Полное имя</label>
|
||||
<input
|
||||
type="text"
|
||||
name="full_name"
|
||||
value={formData.full_name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
placeholder="Иван Иванов"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="label">Роль *</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formData.role === 'admin'
|
||||
? 'Полный доступ ко всем функциям'
|
||||
: 'Доступ только к просмотру и редактированию данных'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active */}
|
||||
<div>
|
||||
<label className="flex items-center p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 text-primary-600 border-gray-300 rounded mr-3"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-900 font-medium">Аккаунт активен</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Неактивные пользователи не могут войти в систему
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
||||
{user ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordResetForm({ user, onSubmit, onCancel }) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
|
||||
<div className="bg-white w-full sm:max-w-md sm:mx-4 sm:rounded-lg rounded-t-2xl overflow-hidden">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold">Сброс пароля</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Пользователь: <strong>{user.username}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Внимание!</strong> Все активные сессии пользователя будут завершены.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Новый пароль *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Минимум 6 символов"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn-danger w-full sm:w-auto">
|
||||
Сбросить пароль
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
frontend/src/services/api.js
Normal file
175
frontend/src/services/api.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || 'dev_key_12345678';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Request interceptor for authentication
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
console.log(`API Request: ${config.method.toUpperCase()} ${config.url}`);
|
||||
|
||||
// Check if this is an auth endpoint
|
||||
const isAuthEndpoint = config.url?.startsWith('/auth/');
|
||||
const isPublicAuthEndpoint = config.url === '/auth/login' ||
|
||||
config.url === '/auth/refresh' ||
|
||||
config.url === '/auth/logout';
|
||||
|
||||
// For auth endpoints (except protected ones), don't add any auth headers
|
||||
if (isAuthEndpoint && isPublicAuthEndpoint) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// For all other endpoints, try to use JWT token first
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
|
||||
if (accessToken) {
|
||||
// Use JWT token if available
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
} else {
|
||||
// Fall back to API key for non-authenticated requests
|
||||
config.headers['X-API-Key'] = API_KEY;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
console.error('API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling and token refresh
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 or 403 and we have a refresh token, try to refresh
|
||||
if ((error.response?.status === 401 || error.response?.status === 403) &&
|
||||
!originalRequest._retry &&
|
||||
localStorage.getItem('refreshToken')) {
|
||||
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken
|
||||
});
|
||||
|
||||
const { accessToken } = response.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout user
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Response Error:', error.response?.data || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Routes API
|
||||
export const routesApi = {
|
||||
getAll: () => api.get('/routes'),
|
||||
getById: (id) => api.get(`/routes/${id}`),
|
||||
create: (data) => api.post('/routes', data),
|
||||
update: (id, data) => api.put(`/routes/${id}`, data),
|
||||
delete: (id) => api.delete(`/routes/${id}`)
|
||||
};
|
||||
|
||||
// Route Stops API (привязка остановок к маршрутам)
|
||||
export const routeStopsApi = {
|
||||
getRouteStops: (routeId) => api.get(`/routes/${routeId}/stops`),
|
||||
getAvailableStops: (routeId) => api.get(`/routes/${routeId}/available-stops`),
|
||||
addStop: (routeId, data) => api.post(`/routes/${routeId}/stops`, data),
|
||||
updateStop: (routeId, routeStopId, data) => api.put(`/routes/${routeId}/stops/${routeStopId}`, data),
|
||||
removeStop: (routeId, routeStopId) => api.delete(`/routes/${routeId}/stops/${routeStopId}`),
|
||||
reorderStops: (routeId, stops) => api.put(`/routes/${routeId}/stops-reorder`, { stops })
|
||||
};
|
||||
|
||||
// Stops API
|
||||
export const stopsApi = {
|
||||
getAll: () => api.get('/stops'),
|
||||
getById: (id) => api.get(`/stops/${id}`),
|
||||
getNearby: (lat, lon, radius = 500) =>
|
||||
api.get('/stops/nearby', { params: { lat, lon, radius } }),
|
||||
create: (data) => api.post('/stops', data),
|
||||
update: (id, data) => api.put(`/stops/${id}`, data),
|
||||
delete: (id) => api.delete(`/stops/${id}`)
|
||||
};
|
||||
|
||||
// ETA API
|
||||
export const etaApi = {
|
||||
calculate: (stopId, routeId) =>
|
||||
api.post('/eta/calculate', { stopId, routeId }),
|
||||
getStopEtas: (stopId) => api.get(`/eta/stop/${stopId}`)
|
||||
};
|
||||
|
||||
// Sync API
|
||||
export const syncApi = {
|
||||
sync: (lastSync) => api.get('/sync', { params: { lastSync } }),
|
||||
getStatus: () => api.get('/sync/status')
|
||||
};
|
||||
|
||||
// Schedules API (расписания)
|
||||
export const schedulesApi = {
|
||||
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
||||
getTodaySchedule: (routeId, date) => api.get(`/routes/${routeId}/schedule/today`, { params: { date } }),
|
||||
getById: (id) => api.get(`/schedules/${id}`),
|
||||
create: (data) => api.post('/schedules', data),
|
||||
update: (id, data) => api.put(`/schedules/${id}`, data),
|
||||
delete: (id) => api.delete(`/schedules/${id}`),
|
||||
copy: (routeId, fromDayType, toDayType) => api.post(`/routes/${routeId}/schedules/copy`, { fromDayType, toDayType })
|
||||
};
|
||||
|
||||
// Holidays API (праздники)
|
||||
export const holidaysApi = {
|
||||
getAll: (year) => api.get('/holidays', { params: { year } }),
|
||||
getUpcoming: (limit) => api.get('/holidays/upcoming', { params: { limit } }),
|
||||
getForYear: (year) => api.get(`/holidays/year/${year}`),
|
||||
checkDate: (date) => api.get(`/holidays/check/${date}`),
|
||||
create: (data) => api.post('/holidays', data),
|
||||
update: (id, data) => api.put(`/holidays/${id}`, data),
|
||||
delete: (id) => api.delete(`/holidays/${id}`)
|
||||
};
|
||||
|
||||
// Alerts API (уведомления)
|
||||
export const alertsApi = {
|
||||
getAll: (params) => api.get('/alerts', { params }),
|
||||
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
||||
getById: (id) => api.get(`/alerts/${id}`),
|
||||
create: (data) => api.post('/alerts', data),
|
||||
update: (id, data) => api.put(`/alerts/${id}`, data),
|
||||
delete: (id) => api.delete(`/alerts/${id}`),
|
||||
cleanup: () => api.post('/alerts/cleanup')
|
||||
};
|
||||
|
||||
// Telemetry API (живые GPS-точки)
|
||||
export const telemetryApi = {
|
||||
ingest: (data) => api.post('/telemetry/ingest', data)
|
||||
};
|
||||
|
||||
// Realtime snapshot API (fallback/snapshot для live данных)
|
||||
export const realtimeApi = {
|
||||
getRouteVehicles: (routeId, params) => api.get(`/realtime/routes/${routeId}/vehicles`, { params }),
|
||||
getVehicle: (vehicleId, params) => api.get(`/realtime/vehicles/${vehicleId}`, { params })
|
||||
};
|
||||
|
||||
export default api;
|
||||
185
frontend/src/services/realtime.js
Normal file
185
frontend/src/services/realtime.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { getApiBaseUrl } from '../utils/apiBaseUrl';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || 'dev_key_12345678';
|
||||
|
||||
function normalizeBasePath(pathname) {
|
||||
return pathname
|
||||
.replace(/\/api\/v\d+\/?$/, '')
|
||||
.replace(/\/api\/?$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildRealtimeUrl() {
|
||||
try {
|
||||
const url = new URL(API_BASE_URL, window.location.origin);
|
||||
const isSecure = url.protocol === 'https:';
|
||||
const basePath = normalizeBasePath(url.pathname);
|
||||
|
||||
url.protocol = isSecure ? 'wss:' : 'ws:';
|
||||
url.pathname = `${basePath}/ws/realtime`.replace(/\/{2,}/g, '/');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
return `${isSecure ? 'wss' : 'ws'}://${window.location.host}/ws/realtime`;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteRealtimeClient {
|
||||
constructor({
|
||||
routeId,
|
||||
token,
|
||||
apiKey = API_KEY,
|
||||
onStatusChange,
|
||||
onSnapshot,
|
||||
onVehiclePosition,
|
||||
onError
|
||||
}) {
|
||||
this.routeId = routeId;
|
||||
this.token = token;
|
||||
this.apiKey = apiKey;
|
||||
this.onStatusChange = onStatusChange;
|
||||
this.onSnapshot = onSnapshot;
|
||||
this.onVehiclePosition = onVehiclePosition;
|
||||
this.onError = onError;
|
||||
this.socket = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectTimeout = null;
|
||||
this.closedManually = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.routeId || this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closedManually = false;
|
||||
this.updateStatus('connecting');
|
||||
|
||||
this.socket = new WebSocket(buildRealtimeUrl());
|
||||
|
||||
this.socket.addEventListener('open', () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.authenticate();
|
||||
});
|
||||
|
||||
this.socket.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
|
||||
this.socket.addEventListener('error', () => {
|
||||
this.updateStatus('error');
|
||||
this.onError?.('Не удалось установить realtime-соединение');
|
||||
});
|
||||
|
||||
this.socket.addEventListener('close', () => {
|
||||
this.socket = null;
|
||||
|
||||
if (this.closedManually) {
|
||||
this.updateStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus('reconnecting');
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.closedManually = true;
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus('authenticating');
|
||||
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: this.token || undefined,
|
||||
apiKey: this.token ? undefined : this.apiKey
|
||||
}));
|
||||
}
|
||||
|
||||
subscribeToRoute() {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'subscribe:route',
|
||||
routeId: this.routeId
|
||||
}));
|
||||
}
|
||||
|
||||
handleMessage(rawMessage) {
|
||||
try {
|
||||
const payload = JSON.parse(rawMessage);
|
||||
|
||||
switch (payload.type) {
|
||||
case 'welcome':
|
||||
break;
|
||||
case 'auth:ok':
|
||||
this.updateStatus('authenticated', payload);
|
||||
this.subscribeToRoute();
|
||||
break;
|
||||
case 'subscription:ok':
|
||||
this.updateStatus('connected', payload);
|
||||
break;
|
||||
case 'subscription:removed':
|
||||
this.updateStatus('disconnected', payload);
|
||||
break;
|
||||
case 'snapshot:route':
|
||||
this.onSnapshot?.(payload.vehicles || []);
|
||||
break;
|
||||
case 'vehicle:position':
|
||||
if (payload.vehicle) {
|
||||
this.onVehiclePosition?.(payload.vehicle);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
this.updateStatus('error', payload);
|
||||
this.onError?.(payload.message || 'Realtime server returned an error');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.onError?.('Получено некорректное realtime-сообщение');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
|
||||
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000);
|
||||
this.reconnectAttempts += 1;
|
||||
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
updateStatus(state, details = {}) {
|
||||
this.onStatusChange?.({
|
||||
state,
|
||||
routeId: this.routeId,
|
||||
...details
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getRealtimeUrl() {
|
||||
return buildRealtimeUrl();
|
||||
}
|
||||
45
frontend/src/utils/apiBaseUrl.js
Normal file
45
frontend/src/utils/apiBaseUrl.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api/v1';
|
||||
|
||||
function normalizePath(pathname) {
|
||||
const trimmedPath = (pathname || '').replace(/\/+$/, '');
|
||||
|
||||
if (/\/api\/v\d+$/.test(trimmedPath)) {
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
if (trimmedPath === '' || trimmedPath === '/') {
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
if (trimmedPath === '/api') {
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
return `${trimmedPath}/api/v1`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(rawValue = import.meta.env.VITE_API_URL || DEFAULT_API_BASE_URL) {
|
||||
try {
|
||||
const baseOrigin = typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: DEFAULT_API_BASE_URL;
|
||||
const url = new URL(rawValue || DEFAULT_API_BASE_URL, baseOrigin);
|
||||
|
||||
url.pathname = normalizePath(url.pathname);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch (error) {
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiOrigin(rawValue = import.meta.env.VITE_API_URL || DEFAULT_API_BASE_URL) {
|
||||
try {
|
||||
const url = new URL(getApiBaseUrl(rawValue));
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
}
|
||||
21
frontend/tailwind.config.js
Normal file
21
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 3001
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false
|
||||
}
|
||||
})
|
||||
125
nginx/nginx.conf
Normal file
125
nginx/nginx.conf
Normal file
@@ -0,0 +1,125 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=5r/s;
|
||||
|
||||
# Upstream backends
|
||||
upstream api_backend {
|
||||
server backend:3000;
|
||||
}
|
||||
|
||||
upstream admin_frontend {
|
||||
server frontend:80;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS (uncomment for production with SSL)
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name yourdomain.com;
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
|
||||
# API Server
|
||||
server {
|
||||
listen 80;
|
||||
# listen 443 ssl http2; # Uncomment for HTTPS
|
||||
server_name api.yourdomain.com localhost;
|
||||
|
||||
# SSL Configuration (uncomment and configure for production)
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/api_access.log;
|
||||
error_log /var/log/nginx/api_error.log;
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
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;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Health check (no rate limit)
|
||||
location /health {
|
||||
proxy_pass http://api_backend;
|
||||
proxy_http_version 1.1;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# Admin Panel Server
|
||||
server {
|
||||
listen 80;
|
||||
# listen 443 ssl http2; # Uncomment for HTTPS
|
||||
server_name admin.yourdomain.com localhost;
|
||||
|
||||
# SSL Configuration (same as above)
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/admin_access.log;
|
||||
error_log /var/log/nginx/admin_error.log;
|
||||
|
||||
location / {
|
||||
limit_req zone=admin_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://admin_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Default server (catch-all)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444; # Close connection without response
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user