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 # Поиск
|
||||
├── profile.html # Профиль пользователя
|
||||
├── admin/
|
||||
│ └── categories.html # Управление категориями
|
||||
│ ├── categories.html # Управление категориями
|
||||
│ └── users.html # Управление пользователями
|
||||
├── css/
|
||||
│ └── style.css # Основные стили
|
||||
├── js/
|
||||
@ -75,23 +76,14 @@ warehouse-system/
|
||||
│ ├── history.js # История операций
|
||||
│ ├── search.js # Поиск
|
||||
│ ├── profile.js # Профиль пользователя
|
||||
│ └── categories.js # Управление категориями
|
||||
│ ├── categories.js # Управление категориями
|
||||
│ └── users.js # Управление пользователями
|
||||
└── README.md # Документация
|
||||
```
|
||||
|
||||
## 🛠️ Технологии
|
||||
## 🚀 Старт
|
||||
|
||||
- **HTML5** - семантическая разметка
|
||||
- **CSS3** - современные стили с CSS переменными
|
||||
- **Bootstrap 5** - адаптивный дизайн
|
||||
- **JavaScript (ES6+)** - интерактивность
|
||||
- **jQuery** - упрощение работы с DOM
|
||||
- **Font Awesome** - иконки
|
||||
- **LocalStorage** - локальное хранение данных
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Первый вход
|
||||
### Для разработки/демо
|
||||
1. Откройте `login.html`
|
||||
2. Используйте тестовые учетные записи:
|
||||
- **Администратор**: `admin` / `admin123`
|
||||
@ -99,6 +91,18 @@ warehouse-system/
|
||||
- **Оператор**: `operator` / `operator123`
|
||||
- **Наблюдатель**: `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. Начальная настройка
|
||||
1. Войдите как администратор
|
||||
2. Создайте категории позиций через "Администрирование" → "Категории позиций"
|
||||
@ -157,27 +161,32 @@ warehouse-system/
|
||||
- **Настройка иконок**: выбор иконок Font Awesome для категорий
|
||||
- **Цветовая схема**: настройка цветов категорий
|
||||
- **Активность категорий**: включение/отключение категорий
|
||||
- **Управление пользователями**: полный CRUD для пользователей системы
|
||||
- **Назначение ролей**: гибкая система ролей и разрешений
|
||||
- **Статус пользователей**: активация/деактивация учетных записей
|
||||
- **Отчеты**: фильтры по периоду, категории, сотруднику; экспорт CSV/JSON
|
||||
|
||||
## 🔧 Администрирование
|
||||
## 📊 Отчеты
|
||||
|
||||
### Управление пользователями
|
||||
1. Войдите как администратор
|
||||
2. Перейдите в раздел "Администрирование"
|
||||
3. Выберите "Управление пользователями"
|
||||
4. Создавайте, редактируйте, удаляйте пользователей
|
||||
### Доступ
|
||||
Откройте "Администрирование" → "Отчеты" (`admin/reports.html`). Доступен пользователям с правами `admin` или `manager` (чтение).
|
||||
|
||||
### Настройка категорий
|
||||
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. **Измените пароли по умолчанию** при первом входе
|
||||
2. **Регулярно обновляйте** права доступа
|
||||
3. **Ведите логи** всех операций
|
||||
4. **Делайте резервные копии** данных
|
||||
2. **Настройте JWT_SECRET** в production окружении
|
||||
3. **Включите HTTPS** с SSL сертификатом
|
||||
4. **Настройте файрвол** и rate limiting
|
||||
5. **Регулярно обновляйте** права доступа
|
||||
6. **Ведите логи** всех операций
|
||||
7. **Делайте резервные копии** данных
|
||||
|
||||
## 📈 Производительность
|
||||
|
||||
@ -318,6 +336,13 @@ warehouse-system/
|
||||
- Минимизируйте количество одновременных операций
|
||||
- Регулярно очищайте старые данные
|
||||
|
||||
### Production-оптимизация
|
||||
- ✅ **Удалены отладочные логи** - улучшена производительность
|
||||
- ✅ **Оптимизирован код** - убраны лишние console.log
|
||||
- ✅ **Готовы production скрипты** - npm run start:prod
|
||||
- ✅ **Настроено кэширование** - статические файлы
|
||||
- ✅ **Оптимизированы запросы** - эффективная фильтрация
|
||||
|
||||
### Технические улучшения
|
||||
- **Безопасная обработка данных**: проверка на null/undefined
|
||||
- **Валидация форм**: клиентская проверка ввода
|
||||
@ -329,18 +354,6 @@ warehouse-system/
|
||||
- **Кэширование данных**: оптимизация производительности
|
||||
- **Hover-функциональность**: плавные анимации и переходы
|
||||
|
||||
## 🔄 Последние обновления
|
||||
|
||||
### Версия 1.3.0 (Август 2025)
|
||||
- ✅ **Система управления правами доступа**: автоматическое скрытие элементов согласно ролям
|
||||
- ✅ **Hover-навигация**: выпадающее меню категорий при наведении мыши
|
||||
- ✅ **Удаление данных по умолчанию**: чистая система для самостоятельного заполнения
|
||||
- ✅ **Улучшенная безопасность**: проверка прав доступа для всех операций
|
||||
- ✅ **Централизованное управление правами**: единая система в auth.js
|
||||
- ✅ **Информативные подсказки**: кнопки показывают требуемые права
|
||||
- ✅ **Плавные анимации**: hover-эффекты и переходы
|
||||
- ✅ **Умное поведение меню**: предотвращение закрытия при наведении
|
||||
|
||||
## 🐛 Устранение неполадок
|
||||
|
||||
### Частые проблемы
|
||||
@ -386,6 +399,9 @@ warehouse-system/
|
||||
|
||||
## 🤝 Поддержка
|
||||
|
||||
### Документация
|
||||
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - подробные инструкции по развертыванию
|
||||
|
||||
### Сообщение об ошибках
|
||||
При обнаружении ошибок:
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
**Версия**: 1.3.0
|
||||
**Дата**: Август 2025
|
||||
**Последнее обновление**: 15.08.2025
|
||||
- **Telegram/Discord**: @dark7es
|
||||
|
||||
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 {
|
||||
this.currentUser = JSON.parse(userData);
|
||||
this.isAuthenticated = true;
|
||||
console.log('Пользователь авторизован:', this.currentUser);
|
||||
// Пользователь авторизован
|
||||
this.updateUI();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', error);
|
||||
// Ошибка при загрузке данных пользователя
|
||||
this.logout();
|
||||
}
|
||||
} else {
|
||||
console.log('Пользователь не авторизован');
|
||||
// Пользователь не авторизован
|
||||
// Если нет сессии и мы не на странице входа, перенаправляем
|
||||
if (!window.location.pathname.includes('login.html')) {
|
||||
console.log('Перенаправление на страницу входа');
|
||||
// Перенаправление на страницу входа
|
||||
window.location.href = 'login.html';
|
||||
}
|
||||
}
|
||||
@ -118,11 +118,11 @@ class AuthSystem {
|
||||
// Обновление интерфейса в зависимости от прав доступа
|
||||
updateUI() {
|
||||
if (!this.currentUser) {
|
||||
console.log('Нет текущего пользователя для обновления UI');
|
||||
// Нет текущего пользователя для обновления UI
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Обновление UI для пользователя:', this.currentUser.name);
|
||||
// Обновление UI для пользователя
|
||||
|
||||
// Обновляем информацию о пользователе в навигации
|
||||
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||
@ -136,10 +136,10 @@ class AuthSystem {
|
||||
// Показываем/скрываем элементы администрирования
|
||||
if (this.hasRole('admin')) {
|
||||
$('#adminDropdown').show();
|
||||
console.log('Показана панель администратора');
|
||||
// Показана панель администратора
|
||||
} else {
|
||||
$('#adminDropdown').hide();
|
||||
console.log('Скрыта панель администратора');
|
||||
// Скрыта панель администратора
|
||||
}
|
||||
|
||||
// Обновляем права доступа для кнопок
|
||||
|
||||
@ -112,6 +112,9 @@ class CatalogManager {
|
||||
console.error('Ошибка при загрузке каталога:', error);
|
||||
auth.showNotification('Ошибка при загрузке каталога', 'danger');
|
||||
this.hideLoading();
|
||||
} finally {
|
||||
// Проверяем уведомления о низком остатке
|
||||
await this.warehouse.checkAndSendNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,12 +12,12 @@ class MainApp {
|
||||
try {
|
||||
// Проверяем авторизацию
|
||||
if (!auth.isUserAuthenticated()) {
|
||||
console.log('Пользователь не авторизован, перенаправление на login.html');
|
||||
// Пользователь не авторизован, перенаправление на login.html
|
||||
window.location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Инициализация главной страницы для пользователя:', auth.getCurrentUser()?.name);
|
||||
// Инициализация главной страницы
|
||||
|
||||
// Загружаем данные дашборда
|
||||
await this.loadDashboardData();
|
||||
@ -43,7 +43,7 @@ class MainApp {
|
||||
this.initializeModals();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации:', error);
|
||||
// Ошибка инициализации
|
||||
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||
}
|
||||
});
|
||||
@ -93,6 +93,9 @@ class MainApp {
|
||||
// Скрываем индикатор загрузки
|
||||
this.hideLoading();
|
||||
|
||||
// Проверяем уведомления о низком остатке
|
||||
await this.warehouse.checkAndSendNotifications();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке данных:', error);
|
||||
auth.showNotification('Ошибка при загрузке данных', 'danger');
|
||||
|
||||
1172
js/profile.js
1172
js/profile.js
File diff suppressed because it is too large
Load Diff
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();
|
||||
1429
js/warehouse.js
1429
js/warehouse.js
File diff suppressed because it is too large
Load Diff
844
profile.html
844
profile.html
@ -1,430 +1,416 @@
|
||||
<!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-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>
|
||||
|
||||
<!-- Поиск в шапке -->
|
||||
<div class="navbar-search me-auto ms-4">
|
||||
<div class="input-group" style="width: 400px;">
|
||||
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||
<button class="btn btn-primary" type="button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<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 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="admin/users.html">Управление пользователями</a></li>
|
||||
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</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 active" 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>
|
||||
|
||||
<!-- Панель категорий -->
|
||||
<div class="categories-bar bg-light border-bottom">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="index.html">
|
||||
<i class="fas fa-home me-1"></i>Главная
|
||||
</a>
|
||||
</li>
|
||||
<!-- Категории будут загружены динамически -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок профиля -->
|
||||
<div class="profile-header">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="profile-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h1 id="userName">Загрузка...</h1>
|
||||
<p class="mb-2" id="userPosition">Загрузка...</p>
|
||||
<p class="mb-0" id="userDepartment">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<main class="container mt-4">
|
||||
<div class="row">
|
||||
<!-- Боковая панель -->
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-user-circle me-2"></i>
|
||||
Информация
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Email</small>
|
||||
<div id="userEmail">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Роли</small>
|
||||
<div id="userRoles">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Разрешения</small>
|
||||
<div id="userPermissions">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Дата регистрации</small>
|
||||
<div id="userRegistrationDate">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые действия -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-bolt me-2"></i>
|
||||
Быстрые действия
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="profileManager.changePassword()">
|
||||
<i class="fas fa-key me-1"></i>Сменить пароль
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="profileManager.editProfile()">
|
||||
<i class="fas fa-edit me-1"></i>Редактировать профиль
|
||||
</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
||||
<i class="fas fa-chart-line me-1"></i>Моя активность
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основная область -->
|
||||
<div class="col-md-9">
|
||||
<!-- Статистика пользователя -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-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="totalOperations">0</h4>
|
||||
<p class="card-text">Всего операций</p>
|
||||
</div>
|
||||
<i class="fas fa-list fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="thisMonthOperations">0</h4>
|
||||
<p class="card-text">В этом месяце</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="todayOperations">0</h4>
|
||||
<p class="card-text">Сегодня</p>
|
||||
</div>
|
||||
<i class="fas fa-clock fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="lastLogin">-</h4>
|
||||
<p class="card-text">Последний вход</p>
|
||||
</div>
|
||||
<i class="fas fa-sign-in-alt fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Последние операции пользователя -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-history me-2"></i>
|
||||
Мои последние операции
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="userOperationsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Товар</th>
|
||||
<th>Количество</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Данные будут загружены через JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Настройки -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-cog me-2"></i>
|
||||
Настройки
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Уведомления</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="emailNotifications" checked>
|
||||
<label class="form-check-label" for="emailNotifications">
|
||||
Email уведомления
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="lowStockAlerts" checked>
|
||||
<label class="form-check-label" for="lowStockAlerts">
|
||||
Уведомления о низком запасе
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="operationReports">
|
||||
<label class="form-check-label" for="operationReports">
|
||||
Еженедельные отчеты
|
||||
</label>
|
||||
</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 class="mt-3">
|
||||
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить настройки
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Модальное окно смены пароля -->
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-key me-2"></i>
|
||||
Смена пароля
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="changePasswordForm">
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label">Текущий пароль</label>
|
||||
<input type="password" class="form-control" id="currentPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">Новый пароль</label>
|
||||
<input type="password" class="form-control" id="newPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Подтвердите новый пароль</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" onclick="profileManager.savePassword()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования профиля -->
|
||||
<div class="modal fade" id="editProfileModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактирование профиля
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editProfileForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editName" class="form-label">Имя</label>
|
||||
<input type="text" class="form-control" id="editName" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="editEmail" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editDepartment" class="form-label">Отдел</label>
|
||||
<input type="text" class="form-control" id="editDepartment">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editPosition" class="form-label">Должность</label>
|
||||
<input type="text" class="form-control" id="editPosition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editPhone" class="form-label">Телефон</label>
|
||||
<input type="tel" class="form-control" id="editPhone">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" onclick="profileManager.saveProfile()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Подвал -->
|
||||
<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 src="js/profile.js"></script>
|
||||
</body>
|
||||
<!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-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>
|
||||
|
||||
<!-- Поиск в шапке -->
|
||||
<div class="navbar-search me-auto ms-4">
|
||||
<div class="input-group" style="width: 400px;">
|
||||
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||
<button class="btn btn-primary" type="button">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<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 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="admin/users.html">Управление пользователями</a></li>
|
||||
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</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 active" 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>
|
||||
|
||||
<!-- Панель категорий -->
|
||||
<div class="categories-bar bg-light border-bottom">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="index.html">
|
||||
<i class="fas fa-home me-1"></i>Главная
|
||||
</a>
|
||||
</li>
|
||||
<!-- Категории будут загружены динамически -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Заголовок профиля -->
|
||||
<div class="profile-header">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="profile-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h1 id="userName">Загрузка...</h1>
|
||||
<p class="mb-2" id="userPosition">Загрузка...</p>
|
||||
<p class="mb-0" id="userDepartment">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<main class="container mt-4">
|
||||
<div class="row">
|
||||
<!-- Боковая панель -->
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-user-circle me-2"></i>
|
||||
Информация
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Email</small>
|
||||
<div id="userEmail">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Роли</small>
|
||||
<div id="userRoles">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Разрешения</small>
|
||||
<div id="userPermissions">Загрузка...</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Дата регистрации</small>
|
||||
<div id="userRegistrationDate">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрые действия -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-bolt me-2"></i>
|
||||
Быстрые действия
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="profileManager.changePassword()">
|
||||
<i class="fas fa-key me-1"></i>Сменить пароль
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="profileManager.editProfile()">
|
||||
<i class="fas fa-edit me-1"></i>Редактировать профиль
|
||||
</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
||||
<i class="fas fa-chart-line me-1"></i>Моя активность
|
||||
</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 class="col-md-9">
|
||||
<!-- Статистика пользователя -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-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="totalOperations">0</h4>
|
||||
<p class="card-text">Всего операций</p>
|
||||
</div>
|
||||
<i class="fas fa-list fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="thisMonthOperations">0</h4>
|
||||
<p class="card-text">В этом месяце</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="todayOperations">0</h4>
|
||||
<p class="card-text">Сегодня</p>
|
||||
</div>
|
||||
<i class="fas fa-clock fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-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="lastLogin">-</h4>
|
||||
<p class="card-text">Последний вход</p>
|
||||
</div>
|
||||
<i class="fas fa-sign-in-alt fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Последние операции пользователя -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-history me-2"></i>
|
||||
Мои последние операции
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="userOperationsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Товар</th>
|
||||
<th>Количество</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Данные будут загружены через JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Настройки -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-cog me-2"></i>
|
||||
Настройки
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Уведомления</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="emailNotifications" checked>
|
||||
<label class="form-check-label" for="emailNotifications">
|
||||
Email уведомления
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="lowStockAlerts" checked>
|
||||
<label class="form-check-label" for="lowStockAlerts">
|
||||
Уведомления о низком запасе
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="operationReports">
|
||||
<label class="form-check-label" for="operationReports">
|
||||
Еженедельные отчеты
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить настройки
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Модальное окно смены пароля -->
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-key me-2"></i>
|
||||
Смена пароля
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="changePasswordForm">
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label">Текущий пароль</label>
|
||||
<input type="password" class="form-control" id="currentPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">Новый пароль</label>
|
||||
<input type="password" class="form-control" id="newPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Подтвердите новый пароль</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" onclick="profileManager.savePassword()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно редактирования профиля -->
|
||||
<div class="modal fade" id="editProfileModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-edit me-2"></i>
|
||||
Редактирование профиля
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editProfileForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editName" class="form-label">Имя</label>
|
||||
<input type="text" class="form-control" id="editName" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="editEmail" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editDepartment" class="form-label">Отдел</label>
|
||||
<input type="text" class="form-control" id="editDepartment">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editPosition" class="form-label">Должность</label>
|
||||
<input type="text" class="form-control" id="editPosition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editPhone" class="form-label">Телефон</label>
|
||||
<input type="tel" class="form-control" id="editPhone">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" onclick="profileManager.saveProfile()">
|
||||
<i class="fas fa-save me-1"></i>Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Подвал -->
|
||||
<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 src="js/profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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