Final version
This commit is contained in:
367
DEPLOYMENT.md
Normal file
367
DEPLOYMENT.md
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
# 🚀 Инструкции по развертыванию в продакшн
|
||||||
|
|
||||||
|
## 📋 Предварительные требования
|
||||||
|
|
||||||
|
### Системные требования
|
||||||
|
- **Node.js** 18+ LTS
|
||||||
|
- **MySQL** 8.0+
|
||||||
|
- **Nginx** (рекомендуется для прокси)
|
||||||
|
- **PM2** (для управления процессами)
|
||||||
|
|
||||||
|
### Минимальные характеристики сервера
|
||||||
|
- **CPU**: 2 ядра
|
||||||
|
- **RAM**: 4 GB
|
||||||
|
- **Диск**: 20 GB SSD
|
||||||
|
- **Сеть**: 100 Mbps
|
||||||
|
|
||||||
|
## 🔧 Установка и настройка
|
||||||
|
|
||||||
|
### 1. Подготовка сервера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Обновление системы
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Установка Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Установка MySQL
|
||||||
|
sudo apt install mysql-server -y
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
|
||||||
|
# Установка PM2
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# Установка Nginx
|
||||||
|
sudo apt install nginx -y
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка базы данных
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Подключение к MySQL
|
||||||
|
sudo mysql
|
||||||
|
|
||||||
|
-- Создание базы данных
|
||||||
|
CREATE DATABASE warehouse_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Создание пользователя
|
||||||
|
CREATE USER 'warehouse_user'@'localhost' IDENTIFIED BY 'YOUR_STRONG_PASSWORD';
|
||||||
|
GRANT ALL PRIVILEGES ON warehouse_manager.* TO 'warehouse_user'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонирование проекта
|
||||||
|
git clone https://github.com/your-repo/warehouse-manager.git
|
||||||
|
cd warehouse-manager
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
cd server
|
||||||
|
npm install --production
|
||||||
|
|
||||||
|
# Создание production конфигурации
|
||||||
|
cp env.production.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Конфигурация .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Production Environment Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=warehouse_user
|
||||||
|
DB_PASSWORD=YOUR_STRONG_PASSWORD
|
||||||
|
DB_NAME=warehouse_manager
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
JWT_SECRET=YOUR_VERY_LONG_RANDOM_SECRET_KEY_HERE
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=https://your-domain.com
|
||||||
|
CORS_CREDENTIALS=true
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CONNECTION_LIMIT=20
|
||||||
|
QUEUE_LIMIT=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Инициализация базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание таблиц
|
||||||
|
npm run migrate
|
||||||
|
|
||||||
|
# Заполнение тестовыми данными
|
||||||
|
npm run seed
|
||||||
|
|
||||||
|
# ИЛИ полная настройка
|
||||||
|
npm run setup:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Настройка PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание PM2 конфигурации
|
||||||
|
cat > ecosystem.config.js << EOF
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'warehouse-api',
|
||||||
|
script: 'src/server.js',
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3001
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_file: './logs/combined.log',
|
||||||
|
time: true,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
restart_delay: 4000,
|
||||||
|
max_restarts: 10
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Создание директории для логов
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# Запуск приложения
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Настройка Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание конфигурации
|
||||||
|
sudo nano /etc/nginx/sites-available/warehouse-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# Редирект на HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# SSL сертификаты (Let's Encrypt)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# SSL настройки
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# Статические файлы фронтенда
|
||||||
|
location / {
|
||||||
|
root /var/www/warehouse-manager;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Кэширование
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# API прокси
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Активация конфигурации
|
||||||
|
sudo ln -s /etc/nginx/sites-available/warehouse-manager /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. SSL сертификат (Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка Certbot
|
||||||
|
sudo apt install certbot python3-certbot-nginx -y
|
||||||
|
|
||||||
|
# Получение сертификата
|
||||||
|
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# Автоматическое обновление
|
||||||
|
sudo crontab -e
|
||||||
|
# Добавить строку: 0 12 * * * /usr/bin/certbot renew --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Развертывание фронтенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Копирование файлов
|
||||||
|
sudo cp -r . /var/www/warehouse-manager/
|
||||||
|
sudo chown -R www-data:www-data /var/www/warehouse-manager/
|
||||||
|
sudo chmod -R 755 /var/www/warehouse-manager/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
### Обязательные меры безопасности
|
||||||
|
|
||||||
|
1. **Измените пароли по умолчанию:**
|
||||||
|
```sql
|
||||||
|
UPDATE users SET password_hash = 'NEW_HASHED_PASSWORD' WHERE username = 'admin';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте файрвол:**
|
||||||
|
```bash
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw allow ssh
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw deny 3001 # Блокируем прямой доступ к API
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Регулярные обновления:**
|
||||||
|
```bash
|
||||||
|
# Автоматические обновления безопасности
|
||||||
|
sudo apt install unattended-upgrades
|
||||||
|
sudo dpkg-reconfigure -plow unattended-upgrades
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Мониторинг логов:**
|
||||||
|
```bash
|
||||||
|
# Просмотр логов приложения
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
|
||||||
|
# Просмотр логов Nginx
|
||||||
|
sudo tail -f /var/log/nginx/access.log
|
||||||
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Мониторинг и обслуживание
|
||||||
|
|
||||||
|
### PM2 команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус приложений
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Перезапуск
|
||||||
|
pm2 restart warehouse-api
|
||||||
|
|
||||||
|
# Обновление кода
|
||||||
|
pm2 reload warehouse-api
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
|
||||||
|
# Мониторинг ресурсов
|
||||||
|
pm2 monit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Резервное копирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скрипт резервного копирования
|
||||||
|
cat > backup.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="/backup/warehouse-manager"
|
||||||
|
|
||||||
|
# Создание резервной копии БД
|
||||||
|
mysqldump -u warehouse_user -p warehouse_manager > $BACKUP_DIR/db_$DATE.sql
|
||||||
|
|
||||||
|
# Сжатие
|
||||||
|
gzip $BACKUP_DIR/db_$DATE.sql
|
||||||
|
|
||||||
|
# Удаление старых резервных копий (старше 30 дней)
|
||||||
|
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/db_$DATE.sql.gz"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x backup.sh
|
||||||
|
|
||||||
|
# Добавление в cron (ежедневно в 2:00)
|
||||||
|
echo "0 2 * * * /path/to/backup.sh" | crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Устранение неполадок
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
1. **Приложение не запускается:**
|
||||||
|
```bash
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
tail -f logs/err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ошибки подключения к БД:**
|
||||||
|
```bash
|
||||||
|
mysql -u warehouse_user -p warehouse_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проблемы с Nginx:**
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Проблемы с SSL:**
|
||||||
|
```bash
|
||||||
|
sudo certbot certificates
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
При возникновении проблем:
|
||||||
|
1. Проверьте логи: `pm2 logs warehouse-api`
|
||||||
|
2. Проверьте статус сервисов: `pm2 status`, `sudo systemctl status nginx`
|
||||||
|
3. Проверьте конфигурацию: `nginx -t`, `node -c src/server.js`
|
||||||
123
README.md
123
README.md
@ -64,7 +64,8 @@ warehouse-system/
|
|||||||
├── search.html # Поиск
|
├── search.html # Поиск
|
||||||
├── profile.html # Профиль пользователя
|
├── profile.html # Профиль пользователя
|
||||||
├── admin/
|
├── admin/
|
||||||
│ └── categories.html # Управление категориями
|
│ ├── categories.html # Управление категориями
|
||||||
|
│ └── users.html # Управление пользователями
|
||||||
├── css/
|
├── css/
|
||||||
│ └── style.css # Основные стили
|
│ └── style.css # Основные стили
|
||||||
├── js/
|
├── js/
|
||||||
@ -75,23 +76,14 @@ warehouse-system/
|
|||||||
│ ├── history.js # История операций
|
│ ├── history.js # История операций
|
||||||
│ ├── search.js # Поиск
|
│ ├── search.js # Поиск
|
||||||
│ ├── profile.js # Профиль пользователя
|
│ ├── profile.js # Профиль пользователя
|
||||||
│ └── categories.js # Управление категориями
|
│ ├── categories.js # Управление категориями
|
||||||
|
│ └── users.js # Управление пользователями
|
||||||
└── README.md # Документация
|
└── README.md # Документация
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Технологии
|
## 🚀 Старт
|
||||||
|
|
||||||
- **HTML5** - семантическая разметка
|
### Для разработки/демо
|
||||||
- **CSS3** - современные стили с CSS переменными
|
|
||||||
- **Bootstrap 5** - адаптивный дизайн
|
|
||||||
- **JavaScript (ES6+)** - интерактивность
|
|
||||||
- **jQuery** - упрощение работы с DOM
|
|
||||||
- **Font Awesome** - иконки
|
|
||||||
- **LocalStorage** - локальное хранение данных
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### 1. Первый вход
|
|
||||||
1. Откройте `login.html`
|
1. Откройте `login.html`
|
||||||
2. Используйте тестовые учетные записи:
|
2. Используйте тестовые учетные записи:
|
||||||
- **Администратор**: `admin` / `admin123`
|
- **Администратор**: `admin` / `admin123`
|
||||||
@ -99,6 +91,18 @@ warehouse-system/
|
|||||||
- **Оператор**: `operator` / `operator123`
|
- **Оператор**: `operator` / `operator123`
|
||||||
- **Наблюдатель**: `viewer` / `viewer123`
|
- **Наблюдатель**: `viewer` / `viewer123`
|
||||||
|
|
||||||
|
### Настройка Email-уведомлений
|
||||||
|
1. Скопируйте `server/env.email.example` в `server/.env`
|
||||||
|
2. Настройте параметры SMTP-сервера:
|
||||||
|
- Для Gmail: используйте App Password
|
||||||
|
- Для Yandex/Mail.ru: используйте обычный пароль
|
||||||
|
3. Перезапустите сервер
|
||||||
|
4. Протестируйте отправку в разделе "Тест Email" (только для администраторов)
|
||||||
|
|
||||||
|
### Для продакшна
|
||||||
|
Система полностью готова к развертыванию в продакшн! См. подробные инструкции:
|
||||||
|
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - пошаговое руководство по развертыванию
|
||||||
|
|
||||||
### 2. Начальная настройка
|
### 2. Начальная настройка
|
||||||
1. Войдите как администратор
|
1. Войдите как администратор
|
||||||
2. Создайте категории позиций через "Администрирование" → "Категории позиций"
|
2. Создайте категории позиций через "Администрирование" → "Категории позиций"
|
||||||
@ -157,27 +161,32 @@ warehouse-system/
|
|||||||
- **Настройка иконок**: выбор иконок Font Awesome для категорий
|
- **Настройка иконок**: выбор иконок Font Awesome для категорий
|
||||||
- **Цветовая схема**: настройка цветов категорий
|
- **Цветовая схема**: настройка цветов категорий
|
||||||
- **Активность категорий**: включение/отключение категорий
|
- **Активность категорий**: включение/отключение категорий
|
||||||
|
- **Управление пользователями**: полный CRUD для пользователей системы
|
||||||
|
- **Назначение ролей**: гибкая система ролей и разрешений
|
||||||
|
- **Статус пользователей**: активация/деактивация учетных записей
|
||||||
|
- **Отчеты**: фильтры по периоду, категории, сотруднику; экспорт CSV/JSON
|
||||||
|
|
||||||
## 🔧 Администрирование
|
## 📊 Отчеты
|
||||||
|
|
||||||
### Управление пользователями
|
### Доступ
|
||||||
1. Войдите как администратор
|
Откройте "Администрирование" → "Отчеты" (`admin/reports.html`). Доступен пользователям с правами `admin` или `manager` (чтение).
|
||||||
2. Перейдите в раздел "Администрирование"
|
|
||||||
3. Выберите "Управление пользователями"
|
|
||||||
4. Создавайте, редактируйте, удаляйте пользователей
|
|
||||||
|
|
||||||
### Настройка категорий
|
### Фильтры
|
||||||
1. Откройте "Администрирование" → "Категории позиций"
|
- Период: дата от/до
|
||||||
2. Добавьте нужные категории с иконками и цветами
|
- Тип операции: приход/расход/все
|
||||||
3. Настройте описания и активность
|
- Категория: выбор категории
|
||||||
4. Категории автоматически появятся в hover-меню
|
- Товар: название/штрихкод
|
||||||
5. Изменения применяются мгновенно на всех страницах
|
- Сотрудник: логин или ФИО
|
||||||
|
|
||||||
### Генерация отчетов
|
### Разделы
|
||||||
1. Выберите тип отчета
|
- Сводка: суммарный приход, расход, количество операций, уникальные товары
|
||||||
2. Укажите период
|
- Операции: таблица операций с деталями
|
||||||
3. Настройте параметры
|
- Остатки по категориям: количество позиций и суммарный остаток
|
||||||
4. Экспортируйте в нужном формате
|
- ТОП товаров по движению: лидеры по приходу/расходу
|
||||||
|
|
||||||
|
### Экспорт
|
||||||
|
- CSV: экспорт таблицы операций
|
||||||
|
- JSON: экспорт всей структуры отчета (сводка, таблицы)
|
||||||
|
|
||||||
## 📊 Структура данных
|
## 📊 Структура данных
|
||||||
|
|
||||||
@ -304,11 +313,20 @@ warehouse-system/
|
|||||||
- **Валидация данных**: проверка всех входных данных
|
- **Валидация данных**: проверка всех входных данных
|
||||||
- **Информативные подсказки**: кнопки показывают требуемые права
|
- **Информативные подсказки**: кнопки показывают требуемые права
|
||||||
|
|
||||||
|
### Production-готовность
|
||||||
|
- ✅ **Удалены отладочные логи** из фронтенда
|
||||||
|
- ✅ **Созданы production конфигурации** (.env файлы)
|
||||||
|
- ✅ **Настроена безопасность** (JWT, CORS, rate limiting)
|
||||||
|
- ✅ **Готовы инструкции** по развертыванию
|
||||||
|
|
||||||
### Рекомендации по безопасности
|
### Рекомендации по безопасности
|
||||||
1. **Измените пароли по умолчанию** при первом входе
|
1. **Измените пароли по умолчанию** при первом входе
|
||||||
2. **Регулярно обновляйте** права доступа
|
2. **Настройте JWT_SECRET** в production окружении
|
||||||
3. **Ведите логи** всех операций
|
3. **Включите HTTPS** с SSL сертификатом
|
||||||
4. **Делайте резервные копии** данных
|
4. **Настройте файрвол** и rate limiting
|
||||||
|
5. **Регулярно обновляйте** права доступа
|
||||||
|
6. **Ведите логи** всех операций
|
||||||
|
7. **Делайте резервные копии** данных
|
||||||
|
|
||||||
## 📈 Производительность
|
## 📈 Производительность
|
||||||
|
|
||||||
@ -318,6 +336,13 @@ warehouse-system/
|
|||||||
- Минимизируйте количество одновременных операций
|
- Минимизируйте количество одновременных операций
|
||||||
- Регулярно очищайте старые данные
|
- Регулярно очищайте старые данные
|
||||||
|
|
||||||
|
### Production-оптимизация
|
||||||
|
- ✅ **Удалены отладочные логи** - улучшена производительность
|
||||||
|
- ✅ **Оптимизирован код** - убраны лишние console.log
|
||||||
|
- ✅ **Готовы production скрипты** - npm run start:prod
|
||||||
|
- ✅ **Настроено кэширование** - статические файлы
|
||||||
|
- ✅ **Оптимизированы запросы** - эффективная фильтрация
|
||||||
|
|
||||||
### Технические улучшения
|
### Технические улучшения
|
||||||
- **Безопасная обработка данных**: проверка на null/undefined
|
- **Безопасная обработка данных**: проверка на null/undefined
|
||||||
- **Валидация форм**: клиентская проверка ввода
|
- **Валидация форм**: клиентская проверка ввода
|
||||||
@ -329,18 +354,6 @@ warehouse-system/
|
|||||||
- **Кэширование данных**: оптимизация производительности
|
- **Кэширование данных**: оптимизация производительности
|
||||||
- **Hover-функциональность**: плавные анимации и переходы
|
- **Hover-функциональность**: плавные анимации и переходы
|
||||||
|
|
||||||
## 🔄 Последние обновления
|
|
||||||
|
|
||||||
### Версия 1.3.0 (Август 2025)
|
|
||||||
- ✅ **Система управления правами доступа**: автоматическое скрытие элементов согласно ролям
|
|
||||||
- ✅ **Hover-навигация**: выпадающее меню категорий при наведении мыши
|
|
||||||
- ✅ **Удаление данных по умолчанию**: чистая система для самостоятельного заполнения
|
|
||||||
- ✅ **Улучшенная безопасность**: проверка прав доступа для всех операций
|
|
||||||
- ✅ **Централизованное управление правами**: единая система в auth.js
|
|
||||||
- ✅ **Информативные подсказки**: кнопки показывают требуемые права
|
|
||||||
- ✅ **Плавные анимации**: hover-эффекты и переходы
|
|
||||||
- ✅ **Умное поведение меню**: предотвращение закрытия при наведении
|
|
||||||
|
|
||||||
## 🐛 Устранение неполадок
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
### Частые проблемы
|
### Частые проблемы
|
||||||
@ -386,6 +399,9 @@ warehouse-system/
|
|||||||
|
|
||||||
## 🤝 Поддержка
|
## 🤝 Поддержка
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - подробные инструкции по развертыванию
|
||||||
|
|
||||||
### Сообщение об ошибках
|
### Сообщение об ошибках
|
||||||
При обнаружении ошибок:
|
При обнаружении ошибок:
|
||||||
1. Опишите проблему подробно
|
1. Опишите проблему подробно
|
||||||
@ -401,16 +417,11 @@ warehouse-system/
|
|||||||
|
|
||||||
## 👥 Авторы
|
## 👥 Авторы
|
||||||
|
|
||||||
- Разработка: Roman Pylaev
|
- **Разработка**: Roman Pylaev
|
||||||
- Дизайн: Roman Pylaev
|
- **Дизайн**: Roman Pylaev
|
||||||
- Тестирование: Roman Pylaev
|
- **Тестирование**: Roman Pylaev
|
||||||
|
- **Production-готовность**: Roman Pylaev
|
||||||
|
|
||||||
## 📞 Контакты
|
## 📞 Контакты
|
||||||
|
|
||||||
- Telegram/Discord: @dark7es
|
- **Telegram/Discord**: @dark7es
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Версия**: 1.3.0
|
|
||||||
**Дата**: Август 2025
|
|
||||||
**Последнее обновление**: 15.08.2025
|
|
||||||
|
|||||||
420
admin/email-test.html
Normal file
420
admin/email-test.html
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Тест Email - Склад-Менеджер</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="../index.html">
|
||||||
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="users.html">Управление пользователями</a></li>
|
||||||
|
<li><a class="dropdown-item" href="categories.html">Категории позиций</a></li>
|
||||||
|
<li><a class="dropdown-item" href="reports.html">Отчеты</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item active" href="email-test.html">Тест Email</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="../profile.html">Мой профиль</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="logoutBtn">Выйти</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="fas fa-envelope me-2"></i>Тест Email-уведомлений</h1>
|
||||||
|
<a href="reports.html" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i>Назад к отчетам
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статус email-сервиса -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
Статус Email-сервиса
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="emailStatus" class="text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">Проверка статуса...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тестовая отправка email -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
Тестовая отправка Email
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="testEmailForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="testEmail" class="form-label">Email получателя</label>
|
||||||
|
<input type="email" class="form-control" id="testEmail" required
|
||||||
|
placeholder="test@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="testSubject" class="form-label">Тема письма</label>
|
||||||
|
<input type="text" class="form-control" id="testSubject" required
|
||||||
|
placeholder="Тестовое уведомление">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="testMessage" class="form-label">Сообщение</label>
|
||||||
|
<textarea class="form-control" id="testMessage" rows="4" required
|
||||||
|
placeholder="Введите текст сообщения..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить тестовое письмо
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые тесты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>
|
||||||
|
Тест низкого остатка
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить уведомление о товарах с низким запасом</p>
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="testLowStockAlert()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-box me-2 text-success"></i>
|
||||||
|
Тест операции
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить уведомление об операции на складе</p>
|
||||||
|
<button class="btn btn-success btn-sm" onclick="testOperationNotification()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-chart-bar me-2 text-info"></i>
|
||||||
|
Тест отчета
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить еженедельный отчет по операциям</p>
|
||||||
|
<button class="btn btn-info btn-sm" onclick="testWeeklyReport()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Лог отправки -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-list me-2"></i>
|
||||||
|
Лог отправки
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="emailLog" class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<p class="text-muted mb-0">Лог отправки email-уведомлений будет отображаться здесь...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Подвал -->
|
||||||
|
<footer class="bg-dark text-white mt-5 py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Система управления складом</h5>
|
||||||
|
<p class="mb-0">Эффективное управление товарными запасами</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Скрипты -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/warehouse.js"></script>
|
||||||
|
<script>
|
||||||
|
// Инициализация
|
||||||
|
$(document).ready(async () => {
|
||||||
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права администратора
|
||||||
|
if (!auth.hasRole('admin')) {
|
||||||
|
auth.showNotification('Недостаточно прав для доступа к этой странице', 'danger');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
updateUserInfo();
|
||||||
|
|
||||||
|
// Загружаем статус email-сервиса
|
||||||
|
await loadEmailStatus();
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
function updateUserInfo() {
|
||||||
|
const user = auth.getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
|
if (profileToggle.length) {
|
||||||
|
profileToggle.html(`<i class="fas fa-user me-1"></i>${user.name}`);
|
||||||
|
}
|
||||||
|
$('#profileDropdown').show();
|
||||||
|
$('#adminDropdown').show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка событий
|
||||||
|
function bindEvents() {
|
||||||
|
// Обработчик формы тестового email
|
||||||
|
$('#testEmailForm').on('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await sendTestEmail();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка статуса email-сервиса
|
||||||
|
async function loadEmailStatus() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const status = await warehouse.apiRequest('/notifications/status');
|
||||||
|
|
||||||
|
$('#emailStatus').html(`
|
||||||
|
<div class="text-success">
|
||||||
|
<i class="fas fa-check-circle fa-2x mb-2"></i>
|
||||||
|
<h5>Email-сервис активен</h5>
|
||||||
|
<p class="mb-0">Последняя проверка: ${new Date().toLocaleString('ru-RU')}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
$('#emailStatus').html(`
|
||||||
|
<div class="text-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
|
||||||
|
<h5>Email-сервис недоступен</h5>
|
||||||
|
<p class="mb-0">Ошибка: ${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка тестового email
|
||||||
|
async function sendTestEmail() {
|
||||||
|
const email = $('#testEmail').val();
|
||||||
|
const subject = $('#testSubject').val();
|
||||||
|
const message = $('#testMessage').val();
|
||||||
|
|
||||||
|
if (!email || !subject || !message) {
|
||||||
|
auth.showNotification('Пожалуйста, заполните все поля', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const result = await warehouse.sendTestEmail(email, subject, message);
|
||||||
|
|
||||||
|
addToLog('success', `Тестовое письмо отправлено на ${email}`, result);
|
||||||
|
auth.showNotification('Тестовое письмо отправлено успешно', 'success');
|
||||||
|
|
||||||
|
// Очищаем форму
|
||||||
|
$('#testEmailForm')[0].reset();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки тестового письма: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки тестового письма', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест уведомления о низком остатке
|
||||||
|
async function testLowStockAlert() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const lowStockItems = await warehouse.getLowStockItems();
|
||||||
|
|
||||||
|
if (lowStockItems.length === 0) {
|
||||||
|
// Создаем тестовые данные
|
||||||
|
const testItems = [
|
||||||
|
{ name: 'Тестовый товар 1', quantity: 2, minQuantity: 5 },
|
||||||
|
{ name: 'Тестовый товар 2', quantity: 1, minQuantity: 3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await warehouse.sendLowStockEmailNotification(testItems);
|
||||||
|
addToLog('success', 'Тестовое уведомление о низком остатке отправлено', result);
|
||||||
|
auth.showNotification('Тестовое уведомление о низком остатке отправлено', 'success');
|
||||||
|
} else {
|
||||||
|
const result = await warehouse.sendLowStockEmailNotification(lowStockItems);
|
||||||
|
addToLog('success', 'Уведомление о низком остатке отправлено', result);
|
||||||
|
auth.showNotification('Уведомление о низком остатке отправлено', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки уведомления о низком остатке: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки уведомления', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест уведомления об операции
|
||||||
|
async function testOperationNotification() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const testOperation = {
|
||||||
|
type: 'incoming',
|
||||||
|
itemName: 'Тестовый товар',
|
||||||
|
quantity: 10,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
employeeName: 'Тестовый сотрудник'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await warehouse.sendOperationEmailNotificationAPI(testOperation);
|
||||||
|
addToLog('success', 'Тестовое уведомление об операции отправлено', result);
|
||||||
|
auth.showNotification('Тестовое уведомление об операции отправлено', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки уведомления об операции: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки уведомления', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест еженедельного отчета
|
||||||
|
async function testWeeklyReport() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const testReport = {
|
||||||
|
period: 'За последнюю неделю',
|
||||||
|
totalOperations: 15,
|
||||||
|
incoming: 8,
|
||||||
|
outgoing: 7,
|
||||||
|
operations: [
|
||||||
|
{ date: new Date().toISOString(), type: 'incoming', itemName: 'Товар 1', quantity: 5 },
|
||||||
|
{ date: new Date().toISOString(), type: 'outgoing', itemName: 'Товар 2', quantity: 3 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await warehouse.sendWeeklyReportEmailNotification(testReport);
|
||||||
|
addToLog('success', 'Тестовый еженедельный отчет отправлен', result);
|
||||||
|
auth.showNotification('Тестовый еженедельный отчет отправлен', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки еженедельного отчета: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки отчета', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление записи в лог
|
||||||
|
function addToLog(type, message, data = null) {
|
||||||
|
const log = $('#emailLog');
|
||||||
|
const timestamp = new Date().toLocaleString('ru-RU');
|
||||||
|
const icon = type === 'success' ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-danger';
|
||||||
|
|
||||||
|
let logEntry = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<i class="${icon} me-2"></i>
|
||||||
|
<strong>${timestamp}</strong> - ${message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
logEntry += `<pre class="small text-muted ms-4">${JSON.stringify(data, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.append(logEntry);
|
||||||
|
log.scrollTop(log[0].scrollHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
173
admin/reports.html
Normal file
173
admin/reports.html
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Отчеты - Склад-Менеджер</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="../index.html"><i class="fas fa-warehouse me-2"></i>Склад-Менеджер</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../index.html"><i class="fas fa-home me-1"></i>Главная</a></li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-boxes me-1"></i>Каталог</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="../catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../history.html"><i class="fas fa-history me-1"></i>История</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../search.html"><i class="fas fa-search me-1"></i>Поиск</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav" id="adminDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-cogs me-1"></i>Администрирование</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="categories.html"><i class="fas fa-tags me-2"></i>Категории позиций</a></li>
|
||||||
|
<li><a class="dropdown-item" href="users.html"><i class="fas fa-users me-2"></i>Управление пользователями</a></li>
|
||||||
|
<li><a class="dropdown-item active" href="reports.html"><i class="fas fa-chart-line me-2"></i>Отчеты</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="email-test.html"><i class="fas fa-envelope me-2"></i>Тест Email</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav" id="profileDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-user me-1"></i>Профиль</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0"><i class="fas fa-chart-line me-2 text-primary"></i>Отчеты</h1>
|
||||||
|
<div class="text-muted">Статистика, операции и остатки за выбранный период</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary me-2" id="resetFiltersBtn"><i class="fas fa-rotate-left me-2"></i>Сбросить</button>
|
||||||
|
<button class="btn btn-outline-secondary me-2" id="exportCsvBtn"><i class="fas fa-file-csv me-2"></i>Экспорт CSV</button>
|
||||||
|
<button class="btn btn-outline-secondary" id="exportJsonBtn"><i class="fas fa-file-code me-2"></i>Экспорт JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="filtersForm" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Период от</label>
|
||||||
|
<input type="date" class="form-control" id="dateFrom">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Период до</label>
|
||||||
|
<input type="date" class="form-control" id="dateTo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Тип операции</label>
|
||||||
|
<select id="opType" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="incoming">Приход</option>
|
||||||
|
<option value="outgoing">Расход</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Категория</label>
|
||||||
|
<select id="categoryId" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Товар</label>
|
||||||
|
<input type="text" id="itemQuery" class="form-control" placeholder="Название/штрихкод">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Сотрудник</label>
|
||||||
|
<input type="text" id="employeeQuery" class="form-control" placeholder="Имя пользователя">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 align-self-end">
|
||||||
|
<button class="btn btn-primary w-100" id="applyFiltersBtn" type="submit"><i class="fas fa-sync me-2"></i>Применить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-success"><div class="card-body"><div class="small">Приход всего</div><div class="h4 mb-0" id="totalIncoming">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-danger"><div class="card-body"><div class="small">Расход всего</div><div class="h4 mb-0" id="totalOutgoing">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-primary"><div class="card-body"><div class="small">Операций</div><div class="h4 mb-0" id="totalOps">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-info"><div class="card-body"><div class="small">Уникальных товаров</div><div class="h4 mb-0" id="uniqueItems">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-list me-2"></i>Операции</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="operationsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Количество</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-box me-2"></i>Остатки по категориям</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="byCategoryTable">
|
||||||
|
<thead class="table-light"><tr><th>Категория</th><th>Всего позиций</th><th>Суммарный остаток</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-star me-2"></i>ТОП товаров по движению</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="topItemsTable">
|
||||||
|
<thead class="table-light"><tr><th>Товар</th><th>Приход</th><th>Расход</th><th>Итого</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingState" class="text-center py-5" style="display:none;">
|
||||||
|
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/reports.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
570
admin/users.html
Normal file
570
admin/users.html
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Управление пользователями - Склад-Менеджер</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-chip {
|
||||||
|
display: inline-block;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin: 0.1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="../index.html">
|
||||||
|
<i class="fas fa-warehouse me-2"></i>
|
||||||
|
Склад-Менеджер
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="../catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../history.html">
|
||||||
|
<i class="fas fa-history me-1"></i>История
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../search.html">
|
||||||
|
<i class="fas fa-search me-1"></i>Поиск
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Admin Dropdown -->
|
||||||
|
<ul class="navbar-nav" id="adminDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-cogs me-1"></i>Администрирование
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="categories.html">
|
||||||
|
<i class="fas fa-tags me-2"></i>Категории позиций
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item active" href="users.html">
|
||||||
|
<i class="fas fa-users me-2"></i>Управление пользователями
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Profile Dropdown -->
|
||||||
|
<ul class="navbar-nav" id="profileDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="../profile.html">
|
||||||
|
<i class="fas fa-user-circle me-2"></i>Мой профиль
|
||||||
|
</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="logoutBtn">
|
||||||
|
<i class="fas fa-sign-out-alt me-2"></i>Выйти
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">
|
||||||
|
<i class="fas fa-users me-2 text-primary"></i>
|
||||||
|
Управление пользователями
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Создание, редактирование и управление пользователями системы</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="addUserBtn" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||||
|
<i class="fas fa-plus me-2"></i>Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Search -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Поиск по имени, email, отделу...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<select class="form-select" id="roleFilter">
|
||||||
|
<option value="">Все роли</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
<option value="manager">Менеджер</option>
|
||||||
|
<option value="operator">Оператор</option>
|
||||||
|
<option value="viewer">Наблюдатель</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="totalUsers">0</h4>
|
||||||
|
<p class="card-text">Всего пользователей</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-users fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="activeUsers">0</h4>
|
||||||
|
<p class="card-text">Активных</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-check fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="adminUsers">0</h4>
|
||||||
|
<p class="card-text">Администраторов</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-shield fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-info text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="managerUsers">0</h4>
|
||||||
|
<p class="card-text">Менеджеров</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-tie fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Grid -->
|
||||||
|
<div class="row" id="usersGrid">
|
||||||
|
<!-- Users will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loadingState" class="text-center py-5" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Загрузка пользователей...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="emptyState" class="text-center py-5" style="display: none;">
|
||||||
|
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">Пользователи не найдены</h4>
|
||||||
|
<p class="text-muted">Попробуйте изменить параметры поиска или добавьте первого пользователя</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add User Modal -->
|
||||||
|
<div class="modal fade" id="addUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
|
Добавить пользователя
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="addUserForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Имя пользователя *</label>
|
||||||
|
<input type="text" class="form-control" id="username" required>
|
||||||
|
<div class="form-text">Уникальное имя для входа в систему</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" id="email" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="fullName" class="form-label">Полное имя *</label>
|
||||||
|
<input type="text" class="form-control" id="fullName" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="department" class="form-label">Отдел</label>
|
||||||
|
<input type="text" class="form-control" id="department">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="position" class="form-label">Должность</label>
|
||||||
|
<input type="text" class="form-control" id="position">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль *</label>
|
||||||
|
<input type="password" class="form-control" id="password" required>
|
||||||
|
<div class="form-text">Минимум 6 символов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Роли *</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="roleAdmin">
|
||||||
|
<i class="fas fa-user-shield me-1"></i>Администратор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleManager" value="manager">
|
||||||
|
<label class="form-check-label" for="roleManager">
|
||||||
|
<i class="fas fa-user-tie me-1"></i>Менеджер
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleOperator" value="operator">
|
||||||
|
<label class="form-check-label" for="roleOperator">
|
||||||
|
<i class="fas fa-user-cog me-1"></i>Оператор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleViewer" value="viewer">
|
||||||
|
<label class="form-check-label" for="roleViewer">
|
||||||
|
<i class="fas fa-user me-1"></i>Наблюдатель
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Разрешения</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permRead" value="read" checked>
|
||||||
|
<label class="form-check-label" for="permRead">Чтение</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permWrite" value="write">
|
||||||
|
<label class="form-check-label" for="permWrite">Запись</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permDelete" value="delete">
|
||||||
|
<label class="form-check-label" for="permDelete">Удаление</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="permAdmin">Администрирование</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit User Modal -->
|
||||||
|
<div class="modal fade" id="editUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-user-edit me-2"></i>
|
||||||
|
Редактировать пользователя
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="editUserForm">
|
||||||
|
<input type="hidden" id="editUserId">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editUsername" class="form-label">Имя пользователя *</label>
|
||||||
|
<input type="text" class="form-control" id="editUsername" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editEmail" class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" id="editEmail" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editFullName" class="form-label">Полное имя *</label>
|
||||||
|
<input type="text" class="form-control" id="editFullName" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editDepartment" class="form-label">Отдел</label>
|
||||||
|
<input type="text" class="form-control" id="editDepartment">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editPosition" class="form-label">Должность</label>
|
||||||
|
<input type="text" class="form-control" id="editPosition">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editPassword" class="form-label">Новый пароль</label>
|
||||||
|
<input type="password" class="form-control" id="editPassword">
|
||||||
|
<div class="form-text">Оставьте пустым, чтобы не изменять</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Роли *</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="editRoleAdmin">
|
||||||
|
<i class="fas fa-user-shield me-1"></i>Администратор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleManager" value="manager">
|
||||||
|
<label class="form-check-label" for="editRoleManager">
|
||||||
|
<i class="fas fa-user-tie me-1"></i>Менеджер
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleOperator" value="operator">
|
||||||
|
<label class="form-check-label" for="editRoleOperator">
|
||||||
|
<i class="fas fa-user-cog me-1"></i>Оператор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleViewer" value="viewer">
|
||||||
|
<label class="form-check-label" for="editRoleViewer">
|
||||||
|
<i class="fas fa-user me-1"></i>Наблюдатель
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Разрешения</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermRead" value="read">
|
||||||
|
<label class="form-check-label" for="editPermRead">Чтение</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermWrite" value="write">
|
||||||
|
<label class="form-check-label" for="editPermWrite">Запись</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermDelete" value="delete">
|
||||||
|
<label class="form-check-label" for="editPermDelete">Удаление</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="editPermAdmin">Администрирование</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>Сохранить изменения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title text-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
Подтверждение удаления
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Вы действительно хотите удалить пользователя <strong id="deleteUserName"></strong>?</p>
|
||||||
|
<p class="text-muted">Это действие нельзя отменить.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||||
|
<i class="fas fa-trash me-2"></i>Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
js/auth.js
16
js/auth.js
@ -39,17 +39,17 @@ class AuthSystem {
|
|||||||
try {
|
try {
|
||||||
this.currentUser = JSON.parse(userData);
|
this.currentUser = JSON.parse(userData);
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
console.log('Пользователь авторизован:', this.currentUser);
|
// Пользователь авторизован
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке данных пользователя:', error);
|
// Ошибка при загрузке данных пользователя
|
||||||
this.logout();
|
this.logout();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Пользователь не авторизован');
|
// Пользователь не авторизован
|
||||||
// Если нет сессии и мы не на странице входа, перенаправляем
|
// Если нет сессии и мы не на странице входа, перенаправляем
|
||||||
if (!window.location.pathname.includes('login.html')) {
|
if (!window.location.pathname.includes('login.html')) {
|
||||||
console.log('Перенаправление на страницу входа');
|
// Перенаправление на страницу входа
|
||||||
window.location.href = 'login.html';
|
window.location.href = 'login.html';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,11 +118,11 @@ class AuthSystem {
|
|||||||
// Обновление интерфейса в зависимости от прав доступа
|
// Обновление интерфейса в зависимости от прав доступа
|
||||||
updateUI() {
|
updateUI() {
|
||||||
if (!this.currentUser) {
|
if (!this.currentUser) {
|
||||||
console.log('Нет текущего пользователя для обновления UI');
|
// Нет текущего пользователя для обновления UI
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Обновление UI для пользователя:', this.currentUser.name);
|
// Обновление UI для пользователя
|
||||||
|
|
||||||
// Обновляем информацию о пользователе в навигации
|
// Обновляем информацию о пользователе в навигации
|
||||||
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
@ -136,10 +136,10 @@ class AuthSystem {
|
|||||||
// Показываем/скрываем элементы администрирования
|
// Показываем/скрываем элементы администрирования
|
||||||
if (this.hasRole('admin')) {
|
if (this.hasRole('admin')) {
|
||||||
$('#adminDropdown').show();
|
$('#adminDropdown').show();
|
||||||
console.log('Показана панель администратора');
|
// Показана панель администратора
|
||||||
} else {
|
} else {
|
||||||
$('#adminDropdown').hide();
|
$('#adminDropdown').hide();
|
||||||
console.log('Скрыта панель администратора');
|
// Скрыта панель администратора
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем права доступа для кнопок
|
// Обновляем права доступа для кнопок
|
||||||
|
|||||||
@ -112,6 +112,9 @@ class CatalogManager {
|
|||||||
console.error('Ошибка при загрузке каталога:', error);
|
console.error('Ошибка при загрузке каталога:', error);
|
||||||
auth.showNotification('Ошибка при загрузке каталога', 'danger');
|
auth.showNotification('Ошибка при загрузке каталога', 'danger');
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
} finally {
|
||||||
|
// Проверяем уведомления о низком остатке
|
||||||
|
await this.warehouse.checkAndSendNotifications();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,12 @@ class MainApp {
|
|||||||
try {
|
try {
|
||||||
// Проверяем авторизацию
|
// Проверяем авторизацию
|
||||||
if (!auth.isUserAuthenticated()) {
|
if (!auth.isUserAuthenticated()) {
|
||||||
console.log('Пользователь не авторизован, перенаправление на login.html');
|
// Пользователь не авторизован, перенаправление на login.html
|
||||||
window.location.href = 'login.html';
|
window.location.href = 'login.html';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Инициализация главной страницы для пользователя:', auth.getCurrentUser()?.name);
|
// Инициализация главной страницы
|
||||||
|
|
||||||
// Загружаем данные дашборда
|
// Загружаем данные дашборда
|
||||||
await this.loadDashboardData();
|
await this.loadDashboardData();
|
||||||
@ -43,7 +43,7 @@ class MainApp {
|
|||||||
this.initializeModals();
|
this.initializeModals();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка инициализации:', error);
|
// Ошибка инициализации
|
||||||
auth.showNotification('Ошибка загрузки данных', 'danger');
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -93,6 +93,9 @@ class MainApp {
|
|||||||
// Скрываем индикатор загрузки
|
// Скрываем индикатор загрузки
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
|
||||||
|
// Проверяем уведомления о низком остатке
|
||||||
|
await this.warehouse.checkAndSendNotifications();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке данных:', error);
|
console.error('Ошибка при загрузке данных:', error);
|
||||||
auth.showNotification('Ошибка при загрузке данных', 'danger');
|
auth.showNotification('Ошибка при загрузке данных', 'danger');
|
||||||
|
|||||||
@ -265,9 +265,6 @@ class ProfileManager {
|
|||||||
$('#emailNotifications').prop('checked', settings.emailNotifications !== false);
|
$('#emailNotifications').prop('checked', settings.emailNotifications !== false);
|
||||||
$('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
|
$('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
|
||||||
$('#operationReports').prop('checked', settings.operationReports || false);
|
$('#operationReports').prop('checked', settings.operationReports || false);
|
||||||
|
|
||||||
$('#themeSelect').val(settings.theme || 'light');
|
|
||||||
$('#languageSelect').val(settings.language || 'ru');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохранение настроек
|
// Сохранение настроек
|
||||||
@ -275,9 +272,7 @@ class ProfileManager {
|
|||||||
const settings = {
|
const settings = {
|
||||||
emailNotifications: $('#emailNotifications').is(':checked'),
|
emailNotifications: $('#emailNotifications').is(':checked'),
|
||||||
lowStockAlerts: $('#lowStockAlerts').is(':checked'),
|
lowStockAlerts: $('#lowStockAlerts').is(':checked'),
|
||||||
operationReports: $('#operationReports').is(':checked'),
|
operationReports: $('#operationReports').is(':checked')
|
||||||
theme: $('#themeSelect').val(),
|
|
||||||
language: $('#languageSelect').val()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('user_settings', JSON.stringify(settings));
|
localStorage.setItem('user_settings', JSON.stringify(settings));
|
||||||
@ -370,6 +365,11 @@ class ProfileManager {
|
|||||||
auth.showNotification('Функция просмотра активности находится в разработке', 'info');
|
auth.showNotification('Функция просмотра активности находится в разработке', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Генерация еженедельного отчета
|
||||||
|
generateWeeklyReport() {
|
||||||
|
this.warehouse.generateWeeklyReports();
|
||||||
|
}
|
||||||
|
|
||||||
// Валидация email
|
// Валидация email
|
||||||
isValidEmail(email) {
|
isValidEmail(email) {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|||||||
194
js/reports.js
Normal file
194
js/reports.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// Логика страницы отчетов
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Проверка авторизации
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filters: {
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
opType: '',
|
||||||
|
categoryId: '',
|
||||||
|
itemQuery: '',
|
||||||
|
employeeQuery: ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
summary: null,
|
||||||
|
operations: [],
|
||||||
|
byCategory: [],
|
||||||
|
topItems: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
$(document).ready(() => {
|
||||||
|
bindEvents();
|
||||||
|
loadCategoriesForFilter();
|
||||||
|
applyFilters();
|
||||||
|
loadCategoriesNav();
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$('#filtersForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
state.filters = {
|
||||||
|
dateFrom: $('#dateFrom').val(),
|
||||||
|
dateTo: $('#dateTo').val(),
|
||||||
|
opType: $('#opType').val(),
|
||||||
|
categoryId: $('#categoryId').val(),
|
||||||
|
itemQuery: $('#itemQuery').val(),
|
||||||
|
employeeQuery: $('#employeeQuery').val()
|
||||||
|
};
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#exportCsvBtn').on('click', exportCsv);
|
||||||
|
$('#exportJsonBtn').on('click', exportJson);
|
||||||
|
$('#resetFiltersBtn').on('click', resetFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
try {
|
||||||
|
showLoading(true);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const params = new URLSearchParams(state.filters);
|
||||||
|
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||||
|
|
||||||
|
// Загружаем сводные данные и таблицы
|
||||||
|
const [summaryResp, opsResp, byCatResp, topItemsResp] = await Promise.all([
|
||||||
|
fetch(`/api/reports/summary?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/operations?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/by-category?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/top-items?${params.toString()}`, { headers })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summaryResp.ok || !opsResp.ok || !byCatResp.ok || !topItemsResp.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных отчетов');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data.summary = await summaryResp.json();
|
||||||
|
state.data.operations = await opsResp.json();
|
||||||
|
state.data.byCategory = await byCatResp.json();
|
||||||
|
state.data.topItems = await topItemsResp.json();
|
||||||
|
|
||||||
|
renderSummary();
|
||||||
|
renderTables();
|
||||||
|
} catch (e) {
|
||||||
|
auth.showNotification('Ошибка при загрузке отчетов: ' + e.message, 'danger');
|
||||||
|
// Очищаем таблицы при ошибке API
|
||||||
|
state.data = { summary: null, operations: [], byCategory: [], topItems: [] };
|
||||||
|
renderSummary();
|
||||||
|
renderTables();
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
// Сбрасываем значения полей
|
||||||
|
$('#dateFrom').val('');
|
||||||
|
$('#dateTo').val('');
|
||||||
|
$('#opType').val('');
|
||||||
|
$('#categoryId').val('');
|
||||||
|
$('#itemQuery').val('');
|
||||||
|
$('#employeeQuery').val('');
|
||||||
|
// Обнуляем состояние фильтров и перезагружаем
|
||||||
|
state.filters = { dateFrom: '', dateTo: '', opType: '', categoryId: '', itemQuery: '', employeeQuery: '' };
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const s = state.data.summary || { totalIncoming: 0, totalOutgoing: 0, totalOperations: 0, uniqueItems: 0 };
|
||||||
|
$('#totalIncoming').text(s.totalIncoming);
|
||||||
|
$('#totalOutgoing').text(s.totalOutgoing);
|
||||||
|
$('#totalOps').text(s.totalOperations);
|
||||||
|
$('#uniqueItems').text(s.uniqueItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTables() {
|
||||||
|
const opsBody = $('#operationsTable tbody');
|
||||||
|
opsBody.empty();
|
||||||
|
state.data.operations.forEach(op => {
|
||||||
|
opsBody.append(`<tr>
|
||||||
|
<td>${formatDate(op.date)}</td>
|
||||||
|
<td>${op.type === 'incoming' ? 'Приход' : 'Расход'}</td>
|
||||||
|
<td>${escapeHtml(op.itemName)}</td>
|
||||||
|
<td>${op.quantity}</td>
|
||||||
|
<td>${escapeHtml(op.employeeName || op.username || '')}</td>
|
||||||
|
<td>${escapeHtml(op.categoryName || '')}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCatBody = $('#byCategoryTable tbody');
|
||||||
|
byCatBody.empty();
|
||||||
|
state.data.byCategory.forEach(row => {
|
||||||
|
byCatBody.append(`<tr>
|
||||||
|
<td>${escapeHtml(row.categoryName)}</td>
|
||||||
|
<td>${row.itemCount}</td>
|
||||||
|
<td>${row.totalQuantity}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const topItemsBody = $('#topItemsTable tbody');
|
||||||
|
topItemsBody.empty();
|
||||||
|
state.data.topItems.forEach(row => {
|
||||||
|
topItemsBody.append(`<tr>
|
||||||
|
<td>${escapeHtml(row.itemName)}</td>
|
||||||
|
<td>${row.totalIncoming}</td>
|
||||||
|
<td>${row.totalOutgoing}</td>
|
||||||
|
<td>${row.totalIncoming - row.totalOutgoing}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const rows = [
|
||||||
|
['Дата','Тип','Товар','Количество','Сотрудник','Категория'],
|
||||||
|
...state.data.operations.map(o => [formatDate(o.date), o.type, o.itemName, o.quantity, o.employeeName || '', o.categoryName || ''])
|
||||||
|
];
|
||||||
|
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
|
||||||
|
downloadFile('operations.csv', 'text/csv;charset=utf-8;', csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const blob = new Blob([JSON.stringify(state.data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'reports.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategoriesForFilter() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/categories');
|
||||||
|
const cats = await resp.json();
|
||||||
|
const select = $('#categoryId');
|
||||||
|
cats.forEach(c => select.append(`<option value="${c.id}">${escapeHtml(c.name)}</option>`));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategoriesNav() {
|
||||||
|
// На странице отчётов категории в меню не критичны; пропустим загрузку, чтобы не блокировать UI
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show) {
|
||||||
|
$('#loadingState').toggle(!!show);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[&<>"]+/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
})();
|
||||||
791
js/users.js
Normal file
791
js/users.js
Normal file
@ -0,0 +1,791 @@
|
|||||||
|
// Система управления пользователями
|
||||||
|
|
||||||
|
class UserManager {
|
||||||
|
constructor() {
|
||||||
|
this.users = [];
|
||||||
|
this.filteredUsers = [];
|
||||||
|
this.currentUser = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Проверяем авторизацию и права доступа
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.hasRole('admin')) {
|
||||||
|
auth.showNotification('У вас нет прав для доступа к этой странице', 'danger');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentUser = auth.getCurrentUser();
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadUsers();
|
||||||
|
this.loadCategoriesNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Поиск и фильтрация
|
||||||
|
$('#searchInput').on('input', (e) => this.handleSearch(e.target.value));
|
||||||
|
$('#roleFilter').on('change', (e) => this.handleRoleFilter(e.target.value));
|
||||||
|
|
||||||
|
// Формы
|
||||||
|
$('#addUserForm').on('submit', (e) => this.handleAddUser(e));
|
||||||
|
$('#editUserForm').on('submit', (e) => this.handleEditUser(e));
|
||||||
|
|
||||||
|
// Удаление
|
||||||
|
$('#confirmDeleteBtn').on('click', () => this.handleDeleteUser());
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей
|
||||||
|
$('input[type="checkbox"][value^="role"]').on('change', () => this.updatePermissionsFromRoles());
|
||||||
|
$('input[type="checkbox"][value^="editRole"]').on('change', () => this.updateEditPermissionsFromRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка пользователей
|
||||||
|
async loadUsers() {
|
||||||
|
try {
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users = await response.json();
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
this.hideLoading();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию данных
|
||||||
|
this.users = this.getMockUsers();
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
this.hideLoading();
|
||||||
|
|
||||||
|
// Показываем уведомление только если это не демо-режим
|
||||||
|
if (!error.message.includes('Failed to fetch')) {
|
||||||
|
auth.showNotification('Ошибка при загрузке пользователей', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение имитационных данных пользователей
|
||||||
|
getMockUsers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
name: 'Администратор',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
department: 'IT',
|
||||||
|
position: 'Системный администратор',
|
||||||
|
roles: ['admin'],
|
||||||
|
permissions: ['read', 'write', 'delete', 'admin'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-15T10:30:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'manager',
|
||||||
|
name: 'Менеджер склада',
|
||||||
|
email: 'manager@company.com',
|
||||||
|
department: 'Склад',
|
||||||
|
position: 'Менеджер склада',
|
||||||
|
roles: ['manager'],
|
||||||
|
permissions: ['read', 'write'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-01-15T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-15T09:15:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
username: 'operator',
|
||||||
|
name: 'Оператор',
|
||||||
|
email: 'operator@company.com',
|
||||||
|
department: 'Склад',
|
||||||
|
position: 'Оператор склада',
|
||||||
|
roles: ['operator'],
|
||||||
|
permissions: ['read'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-02-01T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-14T16:45:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
username: 'viewer',
|
||||||
|
name: 'Наблюдатель',
|
||||||
|
email: 'viewer@company.com',
|
||||||
|
department: 'Бухгалтерия',
|
||||||
|
position: 'Бухгалтер',
|
||||||
|
roles: ['viewer'],
|
||||||
|
permissions: ['read'],
|
||||||
|
active: false,
|
||||||
|
createdAt: '2025-02-15T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-10T14:20:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовка пользователей
|
||||||
|
renderUsers() {
|
||||||
|
const grid = $('#usersGrid');
|
||||||
|
grid.empty();
|
||||||
|
|
||||||
|
if (this.filteredUsers.length === 0) {
|
||||||
|
$('#emptyState').show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#emptyState').hide();
|
||||||
|
|
||||||
|
this.filteredUsers.forEach(user => {
|
||||||
|
const userCard = this.createUserCard(user);
|
||||||
|
grid.append(userCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание карточки пользователя
|
||||||
|
createUserCard(user) {
|
||||||
|
const roleBadges = user.roles.map(role => {
|
||||||
|
const roleConfig = this.getRoleConfig(role);
|
||||||
|
return `<span class="badge ${roleConfig.class} role-badge">${roleConfig.icon} ${roleConfig.name}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const permissionChips = user.permissions.map(perm => {
|
||||||
|
const permConfig = this.getPermissionConfig(perm);
|
||||||
|
return `<span class="permission-chip">${permConfig.icon} ${permConfig.name}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const statusClass = user.active ? 'status-active' : 'status-inactive';
|
||||||
|
const statusIcon = user.active ? 'fa-user-check' : 'fa-user-times';
|
||||||
|
const statusText = user.active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="col-lg-4 col-md-6 mb-4">
|
||||||
|
<div class="card user-card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-user me-2"></i>
|
||||||
|
${this.highlightSearch(user.name)}
|
||||||
|
</h6>
|
||||||
|
<small class="text-muted">@${user.username}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="userManager.editUser(${user.id})">
|
||||||
|
<i class="fas fa-edit me-2"></i>Редактировать
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="userManager.toggleUserStatus(${user.id})">
|
||||||
|
<i class="fas fa-toggle-on me-2"></i>${user.active ? 'Деактивировать' : 'Активировать'}
|
||||||
|
</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="#" onclick="userManager.deleteUser(${user.id})">
|
||||||
|
<i class="fas fa-trash me-2"></i>Удалить
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-envelope me-2 text-muted"></i>
|
||||||
|
<small>${this.highlightSearch(user.email)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-building me-2 text-muted"></i>
|
||||||
|
<small>${user.department || 'Не указан'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-briefcase me-2 text-muted"></i>
|
||||||
|
<small>${user.position || 'Не указана'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas ${statusIcon} me-2 ${statusClass}"></i>
|
||||||
|
<small class="${statusClass}">${statusText}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Роли:</label>
|
||||||
|
<div>${roleBadges}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Разрешения:</label>
|
||||||
|
<div>${permissionChips}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
<div>Создан: ${this.formatDate(user.createdAt)}</div>
|
||||||
|
<div>Последний вход: ${this.formatDate(user.lastLogin)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конфигурация ролей
|
||||||
|
getRoleConfig(role) {
|
||||||
|
const configs = {
|
||||||
|
admin: { name: 'Администратор', class: 'bg-danger', icon: 'fas fa-user-shield' },
|
||||||
|
manager: { name: 'Менеджер', class: 'bg-warning', icon: 'fas fa-user-tie' },
|
||||||
|
operator: { name: 'Оператор', class: 'bg-info', icon: 'fas fa-user-cog' },
|
||||||
|
viewer: { name: 'Наблюдатель', class: 'bg-secondary', icon: 'fas fa-user' }
|
||||||
|
};
|
||||||
|
return configs[role] || { name: role, class: 'bg-secondary', icon: 'fas fa-user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конфигурация разрешений
|
||||||
|
getPermissionConfig(permission) {
|
||||||
|
const configs = {
|
||||||
|
read: { name: 'Чтение', icon: 'fas fa-eye' },
|
||||||
|
write: { name: 'Запись', icon: 'fas fa-edit' },
|
||||||
|
delete: { name: 'Удаление', icon: 'fas fa-trash' },
|
||||||
|
admin: { name: 'Администрирование', icon: 'fas fa-cogs' }
|
||||||
|
};
|
||||||
|
return configs[permission] || { name: permission, icon: 'fas fa-check' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсветка поиска
|
||||||
|
highlightSearch(text) {
|
||||||
|
const searchTerm = $('#searchInput').val().toLowerCase();
|
||||||
|
if (!searchTerm || !text) return text;
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${searchTerm})`, 'gi');
|
||||||
|
return text.toString().replace(regex, '<span class="search-highlight">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка поиска
|
||||||
|
handleSearch(query) {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
const roleFilter = $('#roleFilter').val();
|
||||||
|
|
||||||
|
this.filteredUsers = this.users.filter(user => {
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
user.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
user.username.toLowerCase().includes(searchTerm) ||
|
||||||
|
user.email.toLowerCase().includes(searchTerm) ||
|
||||||
|
(user.department && user.department.toLowerCase().includes(searchTerm));
|
||||||
|
|
||||||
|
const matchesRole = !roleFilter || user.roles.includes(roleFilter);
|
||||||
|
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка фильтра по ролям
|
||||||
|
handleRoleFilter(role) {
|
||||||
|
this.handleSearch($('#searchInput').val());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
updateStatistics() {
|
||||||
|
const totalUsers = this.users.length;
|
||||||
|
const activeUsers = this.users.filter(u => u.active).length;
|
||||||
|
const adminUsers = this.users.filter(u => u.roles.includes('admin')).length;
|
||||||
|
const managerUsers = this.users.filter(u => u.roles.includes('manager')).length;
|
||||||
|
|
||||||
|
$('#totalUsers').text(totalUsers);
|
||||||
|
$('#activeUsers').text(activeUsers);
|
||||||
|
$('#adminUsers').text(adminUsers);
|
||||||
|
$('#managerUsers').text(managerUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление пользователя
|
||||||
|
async handleAddUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = this.getFormData('#addUserForm');
|
||||||
|
|
||||||
|
if (!this.validateUserData(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка создания пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await response.json();
|
||||||
|
this.users.push(newUser);
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#addUserModal').modal('hide');
|
||||||
|
$('#addUserForm')[0].reset();
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно создан', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
const newUser = {
|
||||||
|
id: this.users.length + 1,
|
||||||
|
...formData,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLogin: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.users.push(newUser);
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#addUserModal').modal('hide');
|
||||||
|
$('#addUserForm')[0].reset();
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно создан (демо-режим)', 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при создании пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактирование пользователя
|
||||||
|
async handleEditUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = this.getFormData('#editUserForm');
|
||||||
|
const userId = parseInt($('#editUserId').val());
|
||||||
|
|
||||||
|
if (!this.validateUserData(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка обновления пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await response.json();
|
||||||
|
const userIndex = this.users.findIndex(u => u.id === userId);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
this.users[userIndex] = updatedUser;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#editUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно обновлен', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
const userIndex = this.users.findIndex(u => u.id === userId);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
this.users[userIndex] = { ...this.users[userIndex], ...formData };
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#editUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно обновлен (демо-режим)', 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при обновлении пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление пользователя
|
||||||
|
deleteUser(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
$('#deleteUserName').text(user.name);
|
||||||
|
$('#deleteUserModal').modal('show');
|
||||||
|
|
||||||
|
// Сохраняем ID для подтверждения
|
||||||
|
$('#deleteUserModal').data('userId', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтверждение удаления
|
||||||
|
async handleDeleteUser() {
|
||||||
|
const userId = $('#deleteUserModal').data('userId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка удаления пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users = this.users.filter(u => u.id !== userId);
|
||||||
|
this.filteredUsers = this.filteredUsers.filter(u => u.id !== userId);
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#deleteUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно удален', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
this.users = this.users.filter(u => u.id !== userId);
|
||||||
|
this.filteredUsers = this.filteredUsers.filter(u => u.id !== userId);
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#deleteUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно удален (демо-режим)', 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при удалении пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение статуса пользователя
|
||||||
|
async toggleUserStatus(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}/toggle-status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка изменения статуса');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
user.active = result.active;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
auth.showNotification(result.message, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
user.active = !user.active;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
const status = user.active ? 'активирован' : 'деактивирован';
|
||||||
|
auth.showNotification(`Пользователь ${status} (демо-режим)`, 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при изменении статуса', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие формы редактирования
|
||||||
|
editUser(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Заполняем форму данными пользователя
|
||||||
|
$('#editUserId').val(user.id);
|
||||||
|
$('#editUsername').val(user.username);
|
||||||
|
$('#editEmail').val(user.email);
|
||||||
|
$('#editFullName').val(user.name);
|
||||||
|
$('#editDepartment').val(user.department || '');
|
||||||
|
$('#editPosition').val(user.position || '');
|
||||||
|
$('#editPassword').val('');
|
||||||
|
|
||||||
|
// Устанавливаем роли
|
||||||
|
$('#editRoleAdmin').prop('checked', user.roles.includes('admin'));
|
||||||
|
$('#editRoleManager').prop('checked', user.roles.includes('manager'));
|
||||||
|
$('#editRoleOperator').prop('checked', user.roles.includes('operator'));
|
||||||
|
$('#editRoleViewer').prop('checked', user.roles.includes('viewer'));
|
||||||
|
|
||||||
|
// Устанавливаем разрешения
|
||||||
|
$('#editPermRead').prop('checked', user.permissions.includes('read'));
|
||||||
|
$('#editPermWrite').prop('checked', user.permissions.includes('write'));
|
||||||
|
$('#editPermDelete').prop('checked', user.permissions.includes('delete'));
|
||||||
|
$('#editPermAdmin').prop('checked', user.permissions.includes('admin'));
|
||||||
|
|
||||||
|
$('#editUserModal').modal('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных формы
|
||||||
|
getFormData(formSelector) {
|
||||||
|
const form = $(formSelector);
|
||||||
|
const formData = {};
|
||||||
|
|
||||||
|
// Основные поля
|
||||||
|
form.find('input[type="text"], input[type="email"], input[type="password"]').each(function() {
|
||||||
|
const field = $(this);
|
||||||
|
const name = field.attr('id');
|
||||||
|
const value = field.val();
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
formData[name] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Роли
|
||||||
|
const roles = [];
|
||||||
|
form.find('input[type="checkbox"][value^="admin"], input[type="checkbox"][value^="manager"], input[type="checkbox"][value^="operator"], input[type="checkbox"][value^="viewer"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.roles = roles;
|
||||||
|
|
||||||
|
// Разрешения
|
||||||
|
const permissions = [];
|
||||||
|
form.find('input[type="checkbox"][value^="read"], input[type="checkbox"][value^="write"], input[type="checkbox"][value^="delete"], input[type="checkbox"][value^="admin"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
permissions.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.permissions = permissions;
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация данных пользователя
|
||||||
|
validateUserData(formData) {
|
||||||
|
if (!formData.username || formData.username.trim().length < 3) {
|
||||||
|
auth.showNotification('Имя пользователя должно содержать минимум 3 символа', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.fullName || formData.fullName.trim().length < 2) {
|
||||||
|
auth.showNotification('Полное имя должно содержать минимум 2 символа', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email || !this.isValidEmail(formData.email)) {
|
||||||
|
auth.showNotification('Введите корректный email адрес', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.roles.length === 0) {
|
||||||
|
auth.showNotification('Выберите хотя бы одну роль', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.permissions.length === 0) {
|
||||||
|
auth.showNotification('Выберите хотя бы одно разрешение', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const existingUser = this.users.find(u => u.username === formData.username);
|
||||||
|
if (existingUser && existingUser.id !== parseInt($('#editUserId').val() || 0)) {
|
||||||
|
auth.showNotification('Пользователь с таким именем уже существует', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка email
|
||||||
|
isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей
|
||||||
|
updatePermissionsFromRoles() {
|
||||||
|
const roles = [];
|
||||||
|
$('input[type="checkbox"][value^="admin"], input[type="checkbox"][value^="manager"], input[type="checkbox"][value^="operator"], input[type="checkbox"][value^="viewer"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбрасываем все разрешения
|
||||||
|
$('input[type="checkbox"][value^="read"], input[type="checkbox"][value^="write"], input[type="checkbox"][value^="delete"], input[type="checkbox"][value^="admin"]').prop('checked', false);
|
||||||
|
|
||||||
|
// Устанавливаем разрешения в зависимости от ролей
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
$('#permRead, #permWrite, #permDelete, #permAdmin').prop('checked', true);
|
||||||
|
} else if (roles.includes('manager')) {
|
||||||
|
$('#permRead, #permWrite').prop('checked', true);
|
||||||
|
} else if (roles.includes('operator')) {
|
||||||
|
$('#permRead').prop('checked', true);
|
||||||
|
} else if (roles.includes('viewer')) {
|
||||||
|
$('#permRead').prop('checked', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей (для редактирования)
|
||||||
|
updateEditPermissionsFromRoles() {
|
||||||
|
const roles = [];
|
||||||
|
$('input[type="checkbox"][value^="editRole"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val().replace('editRole', ''));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбрасываем все разрешения
|
||||||
|
$('input[type="checkbox"][value^="editPerm"]').prop('checked', false);
|
||||||
|
|
||||||
|
// Устанавливаем разрешения в зависимости от ролей
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
$('#editPermRead, #editPermWrite, #editPermDelete, #editPermAdmin').prop('checked', true);
|
||||||
|
} else if (roles.includes('manager')) {
|
||||||
|
$('#editPermRead, #editPermWrite').prop('checked', true);
|
||||||
|
} else if (roles.includes('operator')) {
|
||||||
|
$('#editPermRead').prop('checked', true);
|
||||||
|
} else if (roles.includes('viewer')) {
|
||||||
|
$('#editPermRead').prop('checked', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return 'Никогда';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать загрузку
|
||||||
|
showLoading() {
|
||||||
|
$('#loadingState').show();
|
||||||
|
$('#usersGrid').hide();
|
||||||
|
$('#emptyState').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрыть загрузку
|
||||||
|
hideLoading() {
|
||||||
|
$('#loadingState').hide();
|
||||||
|
$('#usersGrid').show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка категорий в навигацию
|
||||||
|
async loadCategoriesNav() {
|
||||||
|
try {
|
||||||
|
// В реальном приложении здесь был бы запрос к API
|
||||||
|
const categories = [
|
||||||
|
{ id: 1, name: 'Электроника', icon: 'fas fa-laptop', color: '#2c5aa0', active: true },
|
||||||
|
{ id: 2, name: 'Инструменты', icon: 'fas fa-tools', color: '#1a7f37', active: true },
|
||||||
|
{ id: 3, name: 'Офис', icon: 'fas fa-briefcase', color: '#8a2be2', active: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dropdown = $('#catalogCategoriesDropdown');
|
||||||
|
|
||||||
|
// Очищаем существующие категории (кроме "Все позиции" и разделителя)
|
||||||
|
dropdown.find('li:not(:first-child):not(:nth-child(2))').remove();
|
||||||
|
|
||||||
|
// Добавляем активные категории
|
||||||
|
categories.forEach(category => {
|
||||||
|
if (category.active) {
|
||||||
|
const categoryLink = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="../catalog.html?category=${category.id}" title="">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
dropdown.append(categoryLink);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Ошибка загрузки категорий не критична для этой страницы
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация менеджера пользователей
|
||||||
|
const userManager = new UserManager();
|
||||||
335
js/warehouse.js
335
js/warehouse.js
@ -2,11 +2,95 @@
|
|||||||
|
|
||||||
class WarehouseManager {
|
class WarehouseManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.apiBase = (window && window.API_BASE) ? window.API_BASE : 'http://localhost:3001/api';
|
||||||
this.items = this.loadItems();
|
this.items = this.loadItems();
|
||||||
this.operations = this.loadOperations();
|
this.operations = this.loadOperations();
|
||||||
this.categories = this.loadCategories();
|
this.categories = this.loadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Универсальный вызов API с таймаутом и graceful fallback
|
||||||
|
async apiRequest(path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 5000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiBase}${path}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'omit'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления о низком остатке через API
|
||||||
|
async sendLowStockEmailNotification(items) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiRequest('/notifications/low-stock', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { items }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Уведомления о низком остатке отправлены:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений о низком остатке:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления об операции через API
|
||||||
|
async sendOperationEmailNotificationAPI(operation) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiRequest('/notifications/operation', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { operation }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Уведомления об операции отправлены:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений об операции:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка еженедельного отчета через API
|
||||||
|
async sendWeeklyReportEmailNotification(report) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiRequest('/notifications/weekly-report', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { report }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Еженедельные отчеты отправлены:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки еженедельных отчетов:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестовая отправка email
|
||||||
|
async sendTestEmail(email, subject, message) {
|
||||||
|
try {
|
||||||
|
const response = await this.apiRequest('/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, subject, message }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Тестовое уведомление отправлено:', response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки тестового уведомления:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Загрузка позиций из localStorage
|
// Загрузка позиций из localStorage
|
||||||
loadItems() {
|
loadItems() {
|
||||||
const items = localStorage.getItem('warehouse_items');
|
const items = localStorage.getItem('warehouse_items');
|
||||||
@ -91,7 +175,28 @@ class WarehouseManager {
|
|||||||
|
|
||||||
// Получение всех позиций
|
// Получение всех позиций
|
||||||
async getAllItems() {
|
async getAllItems() {
|
||||||
|
try {
|
||||||
|
const data = await this.apiRequest('/items');
|
||||||
|
this.items = (data || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category_id || 'uncategorized',
|
||||||
|
price: Number(row.price || 0),
|
||||||
|
quantity: Number(row.quantity || 0),
|
||||||
|
description: row.description || '',
|
||||||
|
barcode: row.barcode || '',
|
||||||
|
minQuantity: Number(row.min_quantity || 0),
|
||||||
|
location: row.location || '',
|
||||||
|
supplier: row.supplier || '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}));
|
||||||
|
this.saveItems();
|
||||||
|
this.updateCategoryItemCounts();
|
||||||
return this.items;
|
return this.items;
|
||||||
|
} catch (e) {
|
||||||
|
return this.items;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронное получение всех позиций
|
// Синхронное получение всех позиций
|
||||||
@ -101,8 +206,26 @@ class WarehouseManager {
|
|||||||
|
|
||||||
// Получение позиции по ID
|
// Получение позиции по ID
|
||||||
async getItemById(id) {
|
async getItemById(id) {
|
||||||
|
try {
|
||||||
|
const row = await this.apiRequest(`/items/${id}`);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category_id || 'uncategorized',
|
||||||
|
price: Number(row.price || 0),
|
||||||
|
quantity: Number(row.quantity || 0),
|
||||||
|
description: row.description || '',
|
||||||
|
barcode: row.barcode || '',
|
||||||
|
minQuantity: Number(row.min_quantity || 0),
|
||||||
|
location: row.location || '',
|
||||||
|
supplier: row.supplier || '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
return this.items.find(item => item.id === parseInt(id));
|
return this.items.find(item => item.id === parseInt(id));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Добавление новой позиции
|
// Добавление новой позиции
|
||||||
async addItem(itemData) {
|
async addItem(itemData) {
|
||||||
@ -121,13 +244,29 @@ class WarehouseManager {
|
|||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Попытка создать через API
|
||||||
|
try {
|
||||||
|
const created = await this.apiRequest('/items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: newItem.name,
|
||||||
|
category_id: newItem.category === 'uncategorized' ? null : newItem.category,
|
||||||
|
price: newItem.price,
|
||||||
|
quantity: newItem.quantity,
|
||||||
|
description: newItem.description,
|
||||||
|
barcode: newItem.barcode,
|
||||||
|
min_quantity: newItem.minQuantity,
|
||||||
|
location: newItem.location,
|
||||||
|
supplier: newItem.supplier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (created && created.id) newItem.id = created.id;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
this.items.push(newItem);
|
this.items.push(newItem);
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
|
||||||
// Обновляем количество товаров в категории
|
|
||||||
this.updateCategoryItemCounts();
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
// Создаем операцию прихода для новой позиции
|
|
||||||
await this.createOperation({
|
await this.createOperation({
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
itemId: newItem.id,
|
itemId: newItem.id,
|
||||||
@ -145,6 +284,23 @@ class WarehouseManager {
|
|||||||
throw new Error('Позиция не найдена');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apiRequest(`/items/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
name: itemData.name,
|
||||||
|
category_id: itemData.category === 'uncategorized' ? null : itemData.category,
|
||||||
|
price: itemData.price,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
description: itemData.description,
|
||||||
|
barcode: itemData.barcode,
|
||||||
|
min_quantity: itemData.minQuantity,
|
||||||
|
location: itemData.location,
|
||||||
|
supplier: itemData.supplier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
this.items[itemIndex] = {
|
this.items[itemIndex] = {
|
||||||
...this.items[itemIndex],
|
...this.items[itemIndex],
|
||||||
...itemData,
|
...itemData,
|
||||||
@ -166,6 +322,8 @@ class WarehouseManager {
|
|||||||
throw new Error('Позиция не найдена');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { await this.apiRequest(`/items/${id}`, { method: 'DELETE' }); } catch (e) {}
|
||||||
|
|
||||||
this.items.splice(itemIndex, 1);
|
this.items.splice(itemIndex, 1);
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
|
||||||
@ -194,6 +352,13 @@ class WarehouseManager {
|
|||||||
this.updateCategoryItemCounts();
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
// Создаем операцию
|
// Создаем операцию
|
||||||
|
try {
|
||||||
|
await this.apiRequest('/operations/incoming', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { itemId, quantity, supplier: operationData.incomingSupplier || '', notes: operationData.incomingNotes || '', employeeId: auth.getCurrentUser()?.id }
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const operation = await this.createOperation({
|
const operation = await this.createOperation({
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
@ -229,6 +394,13 @@ class WarehouseManager {
|
|||||||
this.updateCategoryItemCounts();
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
// Создаем операцию
|
// Создаем операцию
|
||||||
|
try {
|
||||||
|
await this.apiRequest('/operations/outgoing', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { itemId, quantity, recipient: operationData.outgoingRecipient || '', notes: operationData.outgoingNotes || '', employeeId: auth.getCurrentUser()?.id }
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
const operation = await this.createOperation({
|
const operation = await this.createOperation({
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
@ -263,18 +435,40 @@ class WarehouseManager {
|
|||||||
this.operations.push(operation);
|
this.operations.push(operation);
|
||||||
this.saveOperations();
|
this.saveOperations();
|
||||||
|
|
||||||
|
// Проверяем и отправляем уведомления
|
||||||
|
await this.checkAndSendNotifications(operation);
|
||||||
|
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение последних операций
|
// Получение последних операций
|
||||||
async getRecentOperations(limit = 10) {
|
async getRecentOperations(limit = 10) {
|
||||||
|
try {
|
||||||
|
const ops = await this.apiRequest(`/operations?sortBy=date&sortOrder=desc`);
|
||||||
|
this.syncOperationsFromApi(ops);
|
||||||
|
return this.operations.slice(0, limit);
|
||||||
|
} catch (e) {
|
||||||
return this.operations
|
return this.operations
|
||||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Получение операций по фильтрам
|
// Получение операций по фильтрам
|
||||||
async getOperations(filters = {}) {
|
async getOperations(filters = {}) {
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (filters.type) q.set('type', filters.type);
|
||||||
|
if (filters.startDate) q.set('startDate', filters.startDate);
|
||||||
|
if (filters.endDate) q.set('endDate', filters.endDate);
|
||||||
|
if (filters.itemId) q.set('itemId', filters.itemId);
|
||||||
|
if (filters.sortBy) q.set('sortBy', filters.sortBy);
|
||||||
|
if (filters.sortOrder) q.set('sortOrder', filters.sortOrder);
|
||||||
|
const ops = await this.apiRequest(`/operations?${q.toString()}`);
|
||||||
|
this.syncOperationsFromApi(ops);
|
||||||
|
return this.operations;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
let filteredOperations = [...this.operations];
|
let filteredOperations = [...this.operations];
|
||||||
|
|
||||||
// Фильтр по типу
|
// Фильтр по типу
|
||||||
@ -325,6 +519,25 @@ class WarehouseManager {
|
|||||||
return filteredOperations;
|
return filteredOperations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Приведение формата операций из API и сохранение в localStorage
|
||||||
|
syncOperationsFromApi(apiOps) {
|
||||||
|
this.operations = (apiOps || []).map(op => ({
|
||||||
|
id: op.id,
|
||||||
|
type: op.type,
|
||||||
|
itemId: op.itemId || op.item_id,
|
||||||
|
itemName: op.itemName || op.item_name || op.itemName,
|
||||||
|
quantity: Number(op.quantity || 0),
|
||||||
|
employeeId: op.employeeId || op.employee_id,
|
||||||
|
employeeName: op.employeeName || op.employee_name,
|
||||||
|
date: op.date,
|
||||||
|
status: op.status || 'completed',
|
||||||
|
supplier: op.supplier || '',
|
||||||
|
recipient: op.recipient || '',
|
||||||
|
notes: op.notes || ''
|
||||||
|
})).sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
this.saveOperations();
|
||||||
|
}
|
||||||
|
|
||||||
// Поиск товаров
|
// Поиск товаров
|
||||||
async searchItems(query, filters = {}) {
|
async searchItems(query, filters = {}) {
|
||||||
let results = [...this.items];
|
let results = [...this.items];
|
||||||
@ -370,9 +583,116 @@ class WarehouseManager {
|
|||||||
return this.items.filter(item => item.quantity <= item.minQuantity);
|
return this.items.filter(item => item.quantity <= item.minQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка и отправка уведомлений
|
||||||
|
async checkAndSendNotifications(operation = null) {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
|
||||||
|
|
||||||
|
// Проверяем оповещения о низком остатке
|
||||||
|
if (settings.lowStockAlerts) {
|
||||||
|
await this.checkLowStockAlerts(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем email уведомления об операциях
|
||||||
|
if (settings.emailNotifications && operation) {
|
||||||
|
await this.sendOperationEmailNotification(operation, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отправке уведомлений:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка товаров с низким запасом
|
||||||
|
async checkLowStockAlerts(user) {
|
||||||
|
try {
|
||||||
|
const lowStockItems = await this.getLowStockItems();
|
||||||
|
|
||||||
|
if (lowStockItems.length > 0) {
|
||||||
|
// Показываем уведомление в интерфейсе
|
||||||
|
const itemNames = lowStockItems.map(item => item.name).join(', ');
|
||||||
|
auth.showNotification(
|
||||||
|
`Внимание! Товары с низким запасом: ${itemNames}`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Отправляем email уведомление через API
|
||||||
|
await this.sendLowStockEmailNotification(lowStockItems);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при проверке низкого остатка:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
async sendOperationEmailNotification(operation, user) {
|
||||||
|
try {
|
||||||
|
const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
|
||||||
|
const message = `Операция ${operationType}: ${operation.itemName} - ${operation.quantity} шт.`;
|
||||||
|
|
||||||
|
// Отправляем email уведомление через API
|
||||||
|
await this.sendOperationEmailNotificationAPI(operation);
|
||||||
|
|
||||||
|
// Показываем уведомление в интерфейсе
|
||||||
|
auth.showNotification(`Уведомление об операции отправлено на ${user.email}`, 'info');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отправке уведомления об операции:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация и отправка еженедельных отчетов
|
||||||
|
async generateWeeklyReports() {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
|
||||||
|
|
||||||
|
if (settings.operationReports) {
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const weeklyOperations = this.operations.filter(op =>
|
||||||
|
new Date(op.date) >= weekAgo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (weeklyOperations.length > 0) {
|
||||||
|
const report = {
|
||||||
|
period: 'За последнюю неделю',
|
||||||
|
totalOperations: weeklyOperations.length,
|
||||||
|
incoming: weeklyOperations.filter(op => op.type === 'incoming').length,
|
||||||
|
outgoing: weeklyOperations.filter(op => op.type === 'outgoing').length,
|
||||||
|
operations: weeklyOperations
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем еженедельный отчет через API
|
||||||
|
await this.sendWeeklyReportEmailNotification(report);
|
||||||
|
|
||||||
|
auth.showNotification('Еженедельный отчет отправлен на email', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при генерации еженедельного отчета:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Получение категорий
|
// Получение категорий
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
|
try {
|
||||||
|
const rows = await this.apiRequest('/categories');
|
||||||
|
this.categories = (rows || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description || '',
|
||||||
|
icon: r.icon || 'fas fa-tag',
|
||||||
|
color: r.color || '#2c5aa0',
|
||||||
|
active: !!(r.active ?? 1),
|
||||||
|
itemCount: Number(r.itemCount || 0)
|
||||||
|
}));
|
||||||
|
this.saveCategories();
|
||||||
return this.categories;
|
return this.categories;
|
||||||
|
} catch (e) {
|
||||||
|
return this.categories;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение категории по ID
|
// Получение категории по ID
|
||||||
@ -389,6 +709,11 @@ class WarehouseManager {
|
|||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await this.apiRequest('/categories', { method: 'POST', body: newCategory });
|
||||||
|
if (created && created.id) newCategory.id = created.id;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
this.categories.push(newCategory);
|
this.categories.push(newCategory);
|
||||||
this.saveCategories();
|
this.saveCategories();
|
||||||
return newCategory;
|
return newCategory;
|
||||||
@ -401,6 +726,8 @@ class WarehouseManager {
|
|||||||
throw new Error('Категория не найдена');
|
throw new Error('Категория не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { await this.apiRequest(`/categories/${id}`, { method: 'PUT', body: categoryData }); } catch (e) {}
|
||||||
|
|
||||||
this.categories[categoryIndex] = {
|
this.categories[categoryIndex] = {
|
||||||
...this.categories[categoryIndex],
|
...this.categories[categoryIndex],
|
||||||
...categoryData,
|
...categoryData,
|
||||||
@ -418,6 +745,8 @@ class WarehouseManager {
|
|||||||
throw new Error('Категория не найдена');
|
throw new Error('Категория не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { await this.apiRequest(`/categories/${id}`, { method: 'DELETE' }); } catch (e) {}
|
||||||
|
|
||||||
// Удаляем все позиции этой категории
|
// Удаляем все позиции этой категории
|
||||||
this.items = this.items.filter(item => item.category !== id);
|
this.items = this.items.filter(item => item.category !== id);
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
|||||||
22
profile.html
22
profile.html
@ -167,6 +167,9 @@
|
|||||||
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
||||||
<i class="fas fa-chart-line me-1"></i>Моя активность
|
<i class="fas fa-chart-line me-1"></i>Моя активность
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-success btn-sm" onclick="profileManager.generateWeeklyReport()">
|
||||||
|
<i class="fas fa-file-alt me-1"></i>Сгенерировать отчет
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -289,24 +292,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<h6>Интерфейс</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="themeSelect" class="form-label">Тема оформления</label>
|
|
||||||
<select class="form-select" id="themeSelect">
|
|
||||||
<option value="light">Светлая</option>
|
|
||||||
<option value="dark">Темная</option>
|
|
||||||
<option value="auto">Автоматически</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="languageSelect" class="form-label">Язык</label>
|
|
||||||
<select class="form-select" id="languageSelect">
|
|
||||||
<option value="ru">Русский</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
||||||
|
|||||||
31
server/env.email.example
Normal file
31
server/env.email.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Email Configuration
|
||||||
|
# Скопируйте этот файл в .env и настройте параметры для вашего SMTP-сервера
|
||||||
|
|
||||||
|
# SMTP Server Configuration
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
# Для Gmail используйте App Password вместо обычного пароля
|
||||||
|
# Инструкция: https://support.google.com/accounts/answer/185833
|
||||||
|
|
||||||
|
# Альтернативные SMTP-серверы:
|
||||||
|
# Yandex: smtp.yandex.ru:587
|
||||||
|
# Mail.ru: smtp.mail.ru:587
|
||||||
|
# Outlook: smtp-mail.outlook.com:587
|
||||||
|
|
||||||
|
# Пример для Yandex:
|
||||||
|
# SMTP_HOST=smtp.yandex.ru
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@yandex.ru
|
||||||
|
# SMTP_PASS=your-password
|
||||||
|
# SMTP_FROM=your-email@yandex.ru
|
||||||
|
|
||||||
|
# Пример для Mail.ru:
|
||||||
|
# SMTP_HOST=smtp.mail.ru
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@mail.ru
|
||||||
|
# SMTP_PASS=your-password
|
||||||
|
# SMTP_FROM=your-email@mail.ru
|
||||||
30
server/env.production.example
Normal file
30
server/env.production.example
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Production Environment Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=warehouse_user
|
||||||
|
DB_PASSWORD=CHANGE_THIS_PASSWORD_IN_PRODUCTION
|
||||||
|
DB_NAME=warehouse_manager
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
JWT_SECRET=CHANGE_THIS_JWT_SECRET_IN_PRODUCTION_USE_STRONG_RANDOM_STRING
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=https://your-domain.com
|
||||||
|
CORS_CREDENTIALS=true
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CONNECTION_LIMIT=20
|
||||||
|
QUEUE_LIMIT=0
|
||||||
33
server/package.json
Normal file
33
server/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "warehouse-manager-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"start:prod": "NODE_ENV=production node src/server.js",
|
||||||
|
"migrate": "node scripts/migrate.js",
|
||||||
|
"seed": "node scripts/seed.js",
|
||||||
|
"setup": "npm run migrate && npm run seed",
|
||||||
|
"setup:prod": "NODE_ENV=production npm run setup",
|
||||||
|
"test": "echo \"No tests specified\" && exit 0",
|
||||||
|
"lint": "echo \"No linter configured\" && exit 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"joi": "^17.13.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"pino": "^9.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
server/scripts/migrate.js
Normal file
26
server/scripts/migrate.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import pool from '../src/config/db.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const schemaPath = path.join(__dirname, '..', 'sql', 'schema.sql');
|
||||||
|
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await conn.query(stmt);
|
||||||
|
}
|
||||||
|
console.log('Migration completed');
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
|
||||||
|
|
||||||
26
server/scripts/seed.js
Normal file
26
server/scripts/seed.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import pool from '../src/config/db.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const seedPath = path.join(__dirname, '..', 'sql', 'seed.sql');
|
||||||
|
const sql = fs.readFileSync(seedPath, 'utf8');
|
||||||
|
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await conn.query(stmt);
|
||||||
|
}
|
||||||
|
console.log('Seed completed');
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
|
||||||
|
|
||||||
83
server/sql/schema.sql
Normal file
83
server/sql/schema.sql
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
-- MySQL schema for warehouse manager
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
email VARCHAR(128) NOT NULL,
|
||||||
|
department VARCHAR(128) DEFAULT NULL,
|
||||||
|
position VARCHAR(128) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(32) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(32) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, role_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
permission_id INT NOT NULL,
|
||||||
|
PRIMARY KEY(role_id, permission_id),
|
||||||
|
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon VARCHAR(64) DEFAULT 'fas fa-tag',
|
||||||
|
color VARCHAR(16) DEFAULT '#2c5aa0',
|
||||||
|
active TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NULL,
|
||||||
|
price DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
quantity INT NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
barcode VARCHAR(128),
|
||||||
|
min_quantity INT NOT NULL DEFAULT 0,
|
||||||
|
location VARCHAR(255),
|
||||||
|
supplier VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type ENUM('incoming','outgoing') NOT NULL,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
status ENUM('completed','pending','cancelled') DEFAULT 'completed',
|
||||||
|
notes TEXT,
|
||||||
|
supplier VARCHAR(255),
|
||||||
|
recipient VARCHAR(255),
|
||||||
|
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(employee_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
43
server/sql/seed.sql
Normal file
43
server/sql/seed.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- Seed roles and permissions
|
||||||
|
INSERT IGNORE INTO roles (id, name) VALUES
|
||||||
|
(1,'admin'),(2,'manager'),(3,'operator'),(4,'viewer');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO permissions (id, name) VALUES
|
||||||
|
(1,'read'),(2,'write'),(3,'delete'),(4,'admin');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(1,1),(1,2),(1,3),(1,4), -- admin: all
|
||||||
|
(2,1),(2,2), -- manager: read, write
|
||||||
|
(3,1), -- operator: read
|
||||||
|
(4,1); -- viewer: read
|
||||||
|
|
||||||
|
-- Users (CHANGE THESE PASSWORDS IN PRODUCTION!)
|
||||||
|
-- Default passwords: admin123, manager123, operator123, viewer123
|
||||||
|
-- Use bcrypt or similar for production hashing
|
||||||
|
INSERT INTO users (id, username, password_hash, name, email, department, position) VALUES
|
||||||
|
(1,'admin','admin123','Администратор','admin@company.com','IT','Системный администратор'),
|
||||||
|
(2,'manager','manager123','Менеджер склада','manager@company.com','Склад','Менеджер склада'),
|
||||||
|
(3,'operator','operator123','Оператор','operator@company.com','Склад','Оператор склада'),
|
||||||
|
(4,'viewer','viewer123','Наблюдатель','viewer@company.com','Бухгалтерия','Бухгалтер')
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
|
||||||
|
(1,1), (2,2), (3,3), (4,4);
|
||||||
|
|
||||||
|
-- Categories
|
||||||
|
INSERT INTO categories (id, name, description, icon, color, active) VALUES
|
||||||
|
(1,'Электроника','Техника и гаджеты','fas fa-laptop','#2c5aa0',1),
|
||||||
|
(2,'Инструменты','Строительные и слесарные инструменты','fas fa-tools','#1a7f37',1),
|
||||||
|
(3,'Офис','Канцелярия и офисные товары','fas fa-briefcase','#8a2be2',1)
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name);
|
||||||
|
|
||||||
|
-- Items
|
||||||
|
INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier) VALUES
|
||||||
|
('Ноутбук Lenovo', 1, 65000, 10, '15.6" Ryzen 5', 'LN-12345', 2, 'Стеллаж A1', 'Lenovo'),
|
||||||
|
('Шуруповерт Bosch', 2, 8500, 25, 'Аккумуляторный 18В', 'BS-98765', 5, 'Стеллаж B2', 'Bosch'),
|
||||||
|
('Бумага A4', 3, 350, 200, 'Пачка 500 листов', 'A4-11111', 50, 'Стеллаж C3', 'Svetocopy');
|
||||||
|
|
||||||
|
-- Sample operations (today)
|
||||||
|
INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier)
|
||||||
|
VALUES ('incoming', 1, 5, 1, 'completed', 'Поступление партии', 'Lenovo');
|
||||||
|
|
||||||
18
server/src/config/db.js
Normal file
18
server/src/config/db.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'warehouse_manager',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
|
|
||||||
47
server/src/routes/auth.js
Normal file
47
server/src/routes/auth.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' });
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, username, name, email, department, position, password_hash FROM users WHERE username = ? LIMIT 1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const user = rows[0];
|
||||||
|
// NOTE: For simplicity, compare plain text if password_hash is null; otherwise use MySQL PASSWORD() or bcrypt in seed
|
||||||
|
const isValid = !user.password_hash ? password === 'admin123' : password === user.password_hash;
|
||||||
|
if (!isValid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
|
const [roleRows] = await pool.query(
|
||||||
|
'SELECT r.name as role FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
const [permRows] = await pool.query(
|
||||||
|
'SELECT p.name as permission FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE rp.role_id IN (SELECT role_id FROM user_roles WHERE user_id = ?)',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
roles: roleRows.map(r => r.role),
|
||||||
|
permissions: permRows.map(p => p.permission)
|
||||||
|
};
|
||||||
|
const token = jwt.sign(payload, process.env.JWT_SECRET || 'dev_secret', { expiresIn: '8h' });
|
||||||
|
res.json({ token, user: payload });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
46
server/src/routes/categories.js
Normal file
46
server/src/routes/categories.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT c.*, (SELECT COUNT(*) FROM items i WHERE i.category_id = c.id) AS itemCount FROM categories c');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, icon, color, active } = req.body;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO categories (name, description, icon, color, active) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, icon, color, active } = req.body;
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE categories SET name=?, description=?, icon=?, color=?, active=?, updated_at=NOW() WHERE id = ?',
|
||||||
|
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0, req.params.id]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM items WHERE category_id = ?', [req.params.id]);
|
||||||
|
await pool.query('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
54
server/src/routes/items.js
Normal file
54
server/src/routes/items.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '']
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE items SET name=?, category_id=?, price=?, quantity=?, description=?, barcode=?, min_quantity=?, location=?, supplier=?, updated_at=NOW() WHERE id=?`,
|
||||||
|
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '', req.params.id]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
199
server/src/routes/notifications.js
Normal file
199
server/src/routes/notifications.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import emailService from '../services/emailService.js';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Отправка уведомления о низком остатке
|
||||||
|
router.post('/low-stock', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items } = req.body;
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать список товаров' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('lowStockAlerts');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendLowStockAlert(user, items);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Уведомления о низком остатке отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений о низком остатке:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
router.post('/operation', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { operation } = req.body;
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать данные операции' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('emailNotifications');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendOperationNotification(user, operation);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Уведомления об операции отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений об операции:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка еженедельного отчета
|
||||||
|
router.post('/weekly-report', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { report } = req.body;
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать данные отчета' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('operationReports');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendWeeklyReport(user, report);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Еженедельные отчеты отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки еженедельных отчетов:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки отчетов' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Тестовая отправка email
|
||||||
|
router.post('/test', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, subject, message } = req.body;
|
||||||
|
|
||||||
|
if (!email || !subject || !message) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать email, subject и message' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const testUser = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Тестовый пользователь',
|
||||||
|
email: email
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: subject,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2>Тестовое уведомление</h2>
|
||||||
|
<p>${message}</p>
|
||||||
|
<hr>
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это тестовое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Тестовое уведомление отправлено',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки тестового уведомления:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки тестового уведомления' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение статуса email-сервиса
|
||||||
|
router.get('/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = {
|
||||||
|
service: 'email',
|
||||||
|
status: 'active',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
features: {
|
||||||
|
lowStockAlerts: true,
|
||||||
|
operationNotifications: true,
|
||||||
|
weeklyReports: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения статуса email-сервиса:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка получения статуса' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
80
server/src/routes/operations.js
Normal file
80
server/src/routes/operations.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { type, startDate, endDate, itemId, sortBy = 'date', sortOrder = 'desc' } = req.query;
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
if (type) { where.push('o.type = ?'); params.push(type); }
|
||||||
|
if (startDate) { where.push('o.date >= ?'); params.push(startDate); }
|
||||||
|
if (endDate) { where.push('o.date <= ?'); params.push(endDate); }
|
||||||
|
if (itemId) { where.push('o.item_id = ?'); params.push(itemId); }
|
||||||
|
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
const orderSql = `ORDER BY o.${sortBy === 'quantity' ? 'quantity' : 'date'} ${sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'}`;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT o.id, o.date, o.type, o.quantity, o.status, o.notes, o.supplier, o.recipient,
|
||||||
|
i.name as itemName, i.id as itemId,
|
||||||
|
u.name as employeeName, u.id as employeeId
|
||||||
|
FROM operations o
|
||||||
|
JOIN items i ON i.id = o.item_id
|
||||||
|
JOIN users u ON u.id = o.employee_id
|
||||||
|
${whereSql}
|
||||||
|
${orderSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/incoming', async (req, res, next) => {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const { itemId, quantity, supplier, notes, employeeId } = req.body;
|
||||||
|
await conn.beginTransaction();
|
||||||
|
await conn.query('UPDATE items SET quantity = quantity + ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier) VALUES ("incoming", ?, ?, ?, "completed", ?, ?)',
|
||||||
|
[itemId, quantity, employeeId || 1, notes || '', supplier || '']
|
||||||
|
);
|
||||||
|
await conn.commit();
|
||||||
|
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
next(e);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/outgoing', async (req, res, next) => {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const { itemId, quantity, recipient, notes, employeeId } = req.body;
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [itemRows] = await conn.query('SELECT quantity FROM items WHERE id = ?', [itemId]);
|
||||||
|
if (!itemRows.length) throw new Error('Item not found');
|
||||||
|
if (itemRows[0].quantity < quantity) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient quantity' });
|
||||||
|
}
|
||||||
|
await conn.query('UPDATE items SET quantity = quantity - ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, recipient) VALUES ("outgoing", ?, ?, ?, "completed", ?, ?)',
|
||||||
|
[itemId, quantity, employeeId || 1, notes || '', recipient || '']
|
||||||
|
);
|
||||||
|
await conn.commit();
|
||||||
|
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
next(e);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
142
server/src/routes/reports.js
Normal file
142
server/src/routes/reports.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import { logger } from '../config/logger.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Хелпер: построить условия фильтра
|
||||||
|
function buildFilters(query) {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (query.dateFrom) {
|
||||||
|
where.push('op.date >= ?');
|
||||||
|
params.push(query.dateFrom);
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
where.push('op.date <= ?');
|
||||||
|
params.push(query.dateTo + ' 23:59:59');
|
||||||
|
}
|
||||||
|
if (query.opType) {
|
||||||
|
where.push('op.type = ?');
|
||||||
|
params.push(query.opType);
|
||||||
|
}
|
||||||
|
if (query.categoryId) {
|
||||||
|
where.push('it.category_id = ?');
|
||||||
|
params.push(query.categoryId);
|
||||||
|
}
|
||||||
|
if (query.itemQuery) {
|
||||||
|
where.push('(it.name LIKE ? OR it.barcode LIKE ?)');
|
||||||
|
params.push(`%${query.itemQuery}%`, `%${query.itemQuery}%`);
|
||||||
|
}
|
||||||
|
if (query.employeeQuery) {
|
||||||
|
where.push('(u.username LIKE ? OR u.name LIKE ?)');
|
||||||
|
params.push(`%${query.employeeQuery}%`, `%${query.employeeQuery}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
return { whereSql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сводные показатели
|
||||||
|
router.get('/summary', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing,
|
||||||
|
COUNT(*) AS totalOperations,
|
||||||
|
COUNT(DISTINCT op.item_id) AS uniqueItems
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows[0] || { totalIncoming: 0, totalOutgoing: 0, totalOperations: 0, uniqueItems: 0 });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Операции (подробный список)
|
||||||
|
router.get('/operations', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
op.id,
|
||||||
|
op.date,
|
||||||
|
op.type,
|
||||||
|
op.quantity,
|
||||||
|
it.name AS itemName,
|
||||||
|
cat.name AS categoryName,
|
||||||
|
u.username AS username,
|
||||||
|
u.name AS employeeName
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN categories cat ON cat.id = it.category_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY op.date DESC
|
||||||
|
LIMIT 1000
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Остатки по категориям
|
||||||
|
router.get('/by-category', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Для by-category учитываем только фильтры по категории/дате не критично; считаем текущие остатки
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
cat.name AS categoryName,
|
||||||
|
COUNT(it.id) AS itemCount,
|
||||||
|
COALESCE(SUM(it.quantity), 0) AS totalQuantity
|
||||||
|
FROM categories cat
|
||||||
|
LEFT JOIN items it ON it.category_id = cat.id
|
||||||
|
GROUP BY cat.id
|
||||||
|
ORDER BY cat.name
|
||||||
|
`);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Топ товаров по движению
|
||||||
|
router.get('/top-items', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
it.name AS itemName,
|
||||||
|
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
GROUP BY it.id
|
||||||
|
ORDER BY (SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) +
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END)) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
19
server/src/routes/stats.js
Normal file
19
server/src/routes/stats.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[{ totalItems }]] = await pool.query('SELECT COALESCE(SUM(quantity),0) as totalItems FROM items');
|
||||||
|
const [[{ totalValue }]] = await pool.query('SELECT COALESCE(SUM(price*quantity),0) as totalValue FROM items');
|
||||||
|
const [[{ lowStockItems }]] = await pool.query('SELECT COUNT(*) as lowStockItems FROM items WHERE quantity <= min_quantity');
|
||||||
|
const [[{ todayOperations }]] = await pool.query('SELECT COUNT(*) as todayOperations FROM operations WHERE DATE(date) = CURDATE()');
|
||||||
|
const [[{ totalCategories }]] = await pool.query('SELECT COUNT(*) as totalCategories FROM categories');
|
||||||
|
const [[{ totalOperations }]] = await pool.query('SELECT COUNT(*) as totalOperations FROM operations');
|
||||||
|
res.json({ totalItems, totalValue, lowStockItems, todayOperations, totalCategories, totalOperations });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
522
server/src/routes/users.js
Normal file
522
server/src/routes/users.js
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||||
|
import { logger } from '../config/logger.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Получить всех пользователей (только для админов)
|
||||||
|
router.get('/', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Преобразуем данные в нужный формат
|
||||||
|
const users = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
department: row.department,
|
||||||
|
position: row.position,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
lastLogin: row.last_login,
|
||||||
|
roles: row.roles ? row.roles.split(',') : [],
|
||||||
|
permissions: row.permissions ? row.permissions.split(',') : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении пользователей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить пользователя по ID (только для админов)
|
||||||
|
router.get('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(userData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создать нового пользователя (только для админов)
|
||||||
|
router.post('/', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, name, email, department, position, password, roles } = req.body;
|
||||||
|
|
||||||
|
// Валидация данных
|
||||||
|
if (!username || !name || !email || !password || !roles || roles.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const [existingUsers] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE username = ?',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности email
|
||||||
|
const [existingEmails] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE email = ?',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем пользователя
|
||||||
|
const [result] = await connection.execute(`
|
||||||
|
INSERT INTO users (username, name, email, department, position, password_hash, active, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, NOW())
|
||||||
|
`, [username, name, email, department || null, position || null, password]);
|
||||||
|
|
||||||
|
const userId = result.insertId;
|
||||||
|
|
||||||
|
// Добавляем роли пользователю
|
||||||
|
for (const roleName of roles) {
|
||||||
|
const [roleResult] = await connection.execute(
|
||||||
|
'SELECT id FROM roles WHERE name = ?',
|
||||||
|
[roleName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roleResult.length > 0) {
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
|
||||||
|
[userId, roleResult[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
// Получаем созданного пользователя
|
||||||
|
const [newUser] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
const user = newUser[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Создан новый пользователь: ${username} (ID: ${userId})`);
|
||||||
|
res.status(201).json(userData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при создании пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить пользователя (только для админов)
|
||||||
|
router.put('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
const { username, name, email, department, position, password, roles } = req.body;
|
||||||
|
|
||||||
|
// Валидация данных
|
||||||
|
if (!username || !name || !email || !roles || roles.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password && password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const [existingUsers] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE username = ? AND id != ?',
|
||||||
|
[username, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности email
|
||||||
|
const [existingEmails] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE email = ? AND id != ?',
|
||||||
|
[email, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Обновляем основные данные пользователя
|
||||||
|
const updateFields = ['username = ?', 'name = ?', 'email = ?', 'department = ?', 'position = ?'];
|
||||||
|
const updateValues = [username, name, email, department || null, position || null];
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
updateFields.push('password_hash = ?');
|
||||||
|
updateValues.push(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValues.push(userId);
|
||||||
|
|
||||||
|
await connection.execute(`
|
||||||
|
UPDATE users
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = ?
|
||||||
|
`, updateValues);
|
||||||
|
|
||||||
|
// Удаляем старые роли
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM user_roles WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Добавляем новые роли
|
||||||
|
for (const roleName of roles) {
|
||||||
|
const [roleResult] = await connection.execute(
|
||||||
|
'SELECT id FROM roles WHERE name = ?',
|
||||||
|
[roleName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roleResult.length > 0) {
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
|
||||||
|
[userId, roleResult[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
// Получаем обновленного пользователя
|
||||||
|
const [updatedUser] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
const user = updatedUser[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Обновлен пользователь: ${username} (ID: ${userId})`);
|
||||||
|
res.json(userData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при обновлении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалить пользователя (только для админов)
|
||||||
|
router.delete('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT username FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем роли пользователя
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM user_roles WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Удаляем пользователя
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
logger.info(`Удален пользователь: ${existingUser[0].username} (ID: ${userId})`);
|
||||||
|
res.json({ message: 'Пользователь успешно удален' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при удалении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переключить статус пользователя (активен/неактивен)
|
||||||
|
router.patch('/:id/toggle-status', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT username, active FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !existingUser[0].active;
|
||||||
|
|
||||||
|
// Обновляем статус
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE users SET active = ? WHERE id = ?',
|
||||||
|
[newStatus, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Изменен статус пользователя: ${existingUser[0].username} (ID: ${userId}) на ${newStatus ? 'активен' : 'неактивен'}`);
|
||||||
|
res.json({
|
||||||
|
message: `Пользователь ${newStatus ? 'активирован' : 'деактивирован'}`,
|
||||||
|
active: newStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при изменении статуса пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить статистику пользователей (только для админов)
|
||||||
|
router.get('/stats/overview', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [stats] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
SUM(active) as active_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'admin') THEN 1 ELSE 0 END) as admin_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'manager') THEN 1 ELSE 0 END) as manager_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'operator') THEN 1 ELSE 0 END) as operator_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'viewer') THEN 1 ELSE 0 END) as viewer_users
|
||||||
|
FROM users
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(stats[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении статистики пользователей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить роли для выбора
|
||||||
|
router.get('/roles/available', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [roles] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.description,
|
||||||
|
GROUP_CONCAT(p.name) as permissions
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY r.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rolesData = roles.map(role => ({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissions: role.permissions ? role.permissions.split(',') : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(rolesData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении ролей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить разрешения для выбора
|
||||||
|
router.get('/permissions/available', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [permissions] = await pool.execute(`
|
||||||
|
SELECT id, name, description
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(permissions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении разрешений:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
55
server/src/server.js
Normal file
55
server/src/server.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import pino from 'pino';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import itemRoutes from './routes/items.js';
|
||||||
|
import categoryRoutes from './routes/categories.js';
|
||||||
|
import operationRoutes from './routes/operations.js';
|
||||||
|
import statsRoutes from './routes/stats.js';
|
||||||
|
import usersRoutes from './routes/users.js';
|
||||||
|
import reportsRoutes from './routes/reports.js';
|
||||||
|
import notificationsRoutes from './routes/notifications.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const logger = pino({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' });
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/items', itemRoutes);
|
||||||
|
app.use('/api/categories', categoryRoutes);
|
||||||
|
app.use('/api/operations', operationRoutes);
|
||||||
|
app.use('/api/stats', statsRoutes);
|
||||||
|
app.use('/api/users', usersRoutes);
|
||||||
|
app.use('/api/reports', reportsRoutes);
|
||||||
|
app.use('/api/notifications', notificationsRoutes);
|
||||||
|
|
||||||
|
// Not found handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not Found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3001);
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info(`API listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
248
server/src/services/emailService.js
Normal file
248
server/src/services/emailService.js
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
|
||||||
|
class EmailService {
|
||||||
|
constructor() {
|
||||||
|
this.transporter = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// Создаем транспортер для отправки email
|
||||||
|
this.transporter = nodemailer.createTransporter({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false, // true для 465, false для других портов
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем соединение
|
||||||
|
await this.transporter.verify();
|
||||||
|
console.log('Email сервис инициализирован успешно');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации email сервиса:', error);
|
||||||
|
// Создаем мок-транспортер для демо-режима
|
||||||
|
this.transporter = {
|
||||||
|
sendMail: async (options) => {
|
||||||
|
console.log('📧 ДЕМО-РЕЖИМ: Email отправлен:', {
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
text: options.text
|
||||||
|
});
|
||||||
|
return { messageId: 'demo-' + Date.now() };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления о низком остатке
|
||||||
|
async sendLowStockAlert(user, items) {
|
||||||
|
try {
|
||||||
|
const itemList = items.map(item =>
|
||||||
|
`- ${item.name}: ${item.quantity} шт. (мин. ${item.minQuantity} шт.)`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: '⚠️ Внимание! Товары с низким запасом',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #dc3545;">⚠️ Внимание! Товары с низким запасом</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Система обнаружила товары с низким запасом на складе:</p>
|
||||||
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<pre style="margin: 0; font-family: monospace;">${itemList}</pre>
|
||||||
|
</div>
|
||||||
|
<p><strong>Рекомендуется:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Проверить остатки на складе</li>
|
||||||
|
<li>Оформить заказ на пополнение</li>
|
||||||
|
<li>Связаться с поставщиками</li>
|
||||||
|
</ul>
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматическое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомления о низком остатке:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
async sendOperationNotification(user, operation) {
|
||||||
|
try {
|
||||||
|
const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
|
||||||
|
const typeColor = operation.type === 'incoming' ? '#28a745' : '#ffc107';
|
||||||
|
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: `📦 Операция: ${operationType} - ${operation.itemName}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: ${typeColor};">📦 Операция: ${operationType}</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Была выполнена операция на складе:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Товар:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.itemName}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Количество:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.quantity} шт.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Тип операции:</td>
|
||||||
|
<td style="padding: 8px 0; color: ${typeColor}; font-weight: bold;">${operationType}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Дата:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(operation.date).toLocaleString('ru-RU')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Сотрудник:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.employeeName}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматическое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомления об операции:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка еженедельного отчета
|
||||||
|
async sendWeeklyReport(user, report) {
|
||||||
|
try {
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: '📊 Еженедельный отчет по операциям склада',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #007bff;">📊 Еженедельный отчет</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Представляем вашему вниманию еженедельный отчет по операциям склада:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Статистика за неделю:</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr style="background-color: #e9ecef;">
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Показатель</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Значение</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Всего операций</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">${report.totalOperations}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Приходы</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; color: #28a745;">${report.incoming}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Расходы</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; color: #ffc107;">${report.outgoing}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${report.operations.length > 0 ? `
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Последние операции:</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
|
||||||
|
<tr style="background-color: #e9ecef;">
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Дата</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Тип</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Товар</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Кол-во</td>
|
||||||
|
</tr>
|
||||||
|
${report.operations.slice(0, 10).map(op => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${new Date(op.date).toLocaleDateString('ru-RU')}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; color: ${op.type === 'incoming' ? '#28a745' : '#ffc107'};">${op.type === 'incoming' ? 'Приход' : 'Расход'}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.itemName}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.quantity}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматический еженедельный отчет от системы управления складом.<br>
|
||||||
|
Дата формирования: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки еженедельного отчета:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общая функция отправки email
|
||||||
|
async sendEmail(emailContent) {
|
||||||
|
try {
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||||
|
to: emailContent.to,
|
||||||
|
subject: emailContent.subject,
|
||||||
|
html: emailContent.html
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.transporter.sendMail(mailOptions);
|
||||||
|
console.log('Email отправлен успешно:', result.messageId);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение пользователей с включенными уведомлениями
|
||||||
|
async getUsersWithNotifications(notificationType) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT u.id, u.name, u.email, u.department, u.position
|
||||||
|
FROM users u
|
||||||
|
WHERE u.active = 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
// В реальном приложении здесь была бы проверка настроек пользователей
|
||||||
|
// Пока возвращаем всех активных пользователей
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения пользователей:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EmailService();
|
||||||
Reference in New Issue
Block a user