Initial commit

main
Zakhar Timoshenko 10 hours ago
commit 3565390a49

@ -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

@ -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/

@ -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**

@ -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
**Удачи! 🚀**

@ -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 - используйте свободно!

@ -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"]

@ -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;

@ -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();

@ -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);

@ -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';

@ -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 $$;

@ -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"
}
}

@ -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();

@ -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;

@ -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;

@ -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;

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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();

@ -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;

@ -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
};

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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
};

@ -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
};

@ -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();

@ -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);
});

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,6 @@
node_modules
dist
.env
.env.*
.DS_Store
npm-debug.log*

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:3000/api/v1
VITE_API_KEY=dev_key_12345678

@ -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;"]

@ -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>

@ -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;
}

@ -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"
]
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -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;

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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;
}

@ -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>
);
}

@ -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>
);
}

@ -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;
}

@ -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;
}
}

@ -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>,
)

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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='&copy; 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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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='&copy; <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;
}

@ -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>
);
}

@ -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;

@ -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();
}

@ -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';
}
}

@ -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: [],
}

@ -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
}
})

@ -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
}
}
Loading…
Cancel
Save