diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..c9c1579
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -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`
diff --git a/README.md b/README.md
index 1c38d50..c8f5461 100644
--- a/README.md
+++ b/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
diff --git a/admin/email-test.html b/admin/email-test.html
new file mode 100644
index 0000000..dd2f96e
--- /dev/null
+++ b/admin/email-test.html
@@ -0,0 +1,420 @@
+
+
+
+
+
+ Тест Email - Склад-Менеджер
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Тест Email-уведомлений
+
+ Назад к отчетам
+
+
+
+
+
+
+
+
+
+ Загрузка...
+
+
Проверка статуса...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Отправить уведомление о товарах с низким запасом
+
+
+
+
+
+
+
+
+
+
Отправить уведомление об операции на складе
+
+
+
+
+
+
+
+
+
+
Отправить еженедельный отчет по операциям
+
+
+
+
+
+
+
+
+
+
+
+
Лог отправки email-уведомлений будет отображаться здесь...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/reports.html b/admin/reports.html
new file mode 100644
index 0000000..2c09112
--- /dev/null
+++ b/admin/reports.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+ Отчеты - Склад-Менеджер
+
+
+
+
+
+
+
+
+
+
+
Отчеты
+
Статистика, операции и остатки за выбранный период
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Дата |
+ Тип |
+ Товар |
+ Количество |
+ Сотрудник |
+ Категория |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Категория | Всего позиций | Суммарный остаток |
+
+
+
+
+
+
+
+
+
+
+ | Товар | Приход | Расход | Итого |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/users.html b/admin/users.html
new file mode 100644
index 0000000..b5df980
--- /dev/null
+++ b/admin/users.html
@@ -0,0 +1,570 @@
+
+
+
+
+
+ Управление пользователями - Склад-Менеджер
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Управление пользователями
+
+
Создание, редактирование и управление пользователями системы
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
Всего пользователей
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Загрузка...
+
+
Загрузка пользователей...
+
+
+
+
+
+
Пользователи не найдены
+
Попробуйте изменить параметры поиска или добавьте первого пользователя
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Вы действительно хотите удалить пользователя ?
+
Это действие нельзя отменить.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/auth.js b/js/auth.js
index 59ffb90..9191573 100644
--- a/js/auth.js
+++ b/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('Скрыта панель администратора');
+ // Скрыта панель администратора
}
// Обновляем права доступа для кнопок
diff --git a/js/catalog.js b/js/catalog.js
index eba8101..e6e0085 100644
--- a/js/catalog.js
+++ b/js/catalog.js
@@ -112,6 +112,9 @@ class CatalogManager {
console.error('Ошибка при загрузке каталога:', error);
auth.showNotification('Ошибка при загрузке каталога', 'danger');
this.hideLoading();
+ } finally {
+ // Проверяем уведомления о низком остатке
+ await this.warehouse.checkAndSendNotifications();
}
}
diff --git a/js/main.js b/js/main.js
index 5e3020f..cfa109b 100644
--- a/js/main.js
+++ b/js/main.js
@@ -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');
diff --git a/js/profile.js b/js/profile.js
index d43f54c..57fa809 100644
--- a/js/profile.js
+++ b/js/profile.js
@@ -1,587 +1,587 @@
-// JavaScript для страницы профиля пользователя
-
-class ProfileManager {
- constructor() {
- this.warehouse = new WarehouseManager();
- this.currentUser = null;
- this.userOperations = [];
-
- this.init();
- }
-
- init() {
- $(document).ready(async () => {
- try {
- // Проверяем авторизацию
- if (!auth.isUserAuthenticated()) {
- console.log('Пользователь не авторизован, перенаправление на login.html');
- window.location.href = 'login.html';
- return;
- }
-
- console.log('Инициализация профиля для пользователя:', auth.getCurrentUser()?.name);
-
- // Загружаем профиль
- await this.loadProfile();
-
- // Загружаем категории в навигацию
- await this.loadCategoriesNav();
-
- // Загружаем категории в выпадающее меню
- await this.loadCategoriesDropdown();
-
- // Обновляем информацию о пользователе
- this.updateUserInfo();
-
- // Привязываем события
- this.bindEvents();
-
- } catch (error) {
- console.error('Ошибка инициализации:', error);
- auth.showNotification('Ошибка загрузки данных', 'danger');
- }
- });
- }
-
- bindEvents() {
- // Обработчики для настроек
- $('#saveSettingsBtn').on('click', () => this.saveSettings());
-
- // Обработчики для форм
- $('#changePasswordForm').on('submit', (e) => {
- e.preventDefault();
- this.savePassword();
- });
-
- $('#editProfileForm').on('submit', (e) => {
- e.preventDefault();
- this.saveProfile();
- });
-
- // Обработчик выхода
- $('#logoutBtn').on('click', (e) => {
- e.preventDefault();
- auth.logout();
- window.location.href = 'login.html';
- });
- }
-
- // Загрузка профиля
- async loadProfile() {
- try {
- this.currentUser = auth.getCurrentUser();
- if (!this.currentUser) {
- window.location.href = 'login.html';
- return;
- }
-
- this.displayUserInfo();
- await this.loadUserStatistics();
- await this.loadUserOperations();
- this.loadSettings();
-
- } catch (error) {
- console.error('Ошибка при загрузке профиля:', error);
- auth.showNotification('Ошибка при загрузке профиля', 'danger');
- }
- }
-
- // Отображение информации о пользователе
- displayUserInfo() {
- // Основная информация
- $('#userName').text(this.currentUser.name);
- $('#userPosition').text(this.currentUser.position);
- $('#userDepartment').text(this.currentUser.department);
- $('#userEmail').text(this.currentUser.email);
-
- // Роли
- const roles = this.currentUser.roles.map(role => this.getRoleDisplayName(role)).join(', ');
- $('#userRoles').html(`${roles}`);
-
- // Разрешения
- const permissions = this.currentUser.permissions.map(perm => this.getPermissionDisplayName(perm)).join(', ');
- $('#userPermissions').html(`${permissions}`);
-
- // Дата регистрации (имитация)
- const registrationDate = new Date();
- registrationDate.setMonth(registrationDate.getMonth() - 3); // 3 месяца назад
- $('#userRegistrationDate').text(registrationDate.toLocaleDateString('ru-RU'));
-
- // Последний вход
- const lastLogin = new Date();
- lastLogin.setHours(lastLogin.getHours() - 2); // 2 часа назад
- $('#lastLogin').text(lastLogin.toLocaleDateString('ru-RU') + ' ' + lastLogin.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }));
- }
-
- // Получение отображаемого названия роли
- getRoleDisplayName(role) {
- const roleNames = {
- 'admin': 'Администратор',
- 'manager': 'Менеджер',
- 'operator': 'Оператор',
- 'viewer': 'Наблюдатель'
- };
- return roleNames[role] || role;
- }
-
- // Получение отображаемого названия разрешения
- getPermissionDisplayName(permission) {
- const permissionNames = {
- 'read': 'Чтение',
- 'write': 'Запись',
- 'delete': 'Удаление',
- 'admin': 'Администрирование'
- };
- return permissionNames[permission] || permission;
- }
-
- // Загрузка статистики пользователя
- async loadUserStatistics() {
- try {
- const operations = await this.warehouse.getOperations();
- const userOperations = operations.filter(op => op.employeeId === this.currentUser.id);
-
- // Общее количество операций
- $('#totalOperations').text(userOperations.length);
-
- // Операции в этом месяце
- const thisMonth = new Date().getMonth();
- const thisYear = new Date().getFullYear();
- const thisMonthOperations = userOperations.filter(op => {
- const opDate = new Date(op.date);
- return opDate.getMonth() === thisMonth && opDate.getFullYear() === thisYear;
- });
- $('#thisMonthOperations').text(thisMonthOperations.length);
-
- // Операции сегодня
- const today = new Date().toISOString().split('T')[0];
- const todayOperations = userOperations.filter(op => op.date.startsWith(today));
- $('#todayOperations').text(todayOperations.length);
-
- } catch (error) {
- console.error('Ошибка при загрузке статистики:', error);
- }
- }
-
- // Загрузка операций пользователя
- async loadUserOperations() {
- try {
- const operations = await this.warehouse.getOperations();
- this.userOperations = operations
- .filter(op => op.employeeId === this.currentUser.id)
- .sort((a, b) => new Date(b.date) - new Date(a.date))
- .slice(0, 10); // Последние 10 операций
-
- this.renderUserOperations();
-
- } catch (error) {
- console.error('Ошибка при загрузке операций:', error);
- }
- }
-
- // Рендеринг операций пользователя
- renderUserOperations() {
- const tbody = $('#userOperationsTable tbody');
- tbody.empty();
-
- if (this.userOperations.length === 0) {
- tbody.html(`
-
-
-
- У вас пока нет операций
- |
-
- `);
- return;
- }
-
- this.userOperations.forEach(operation => {
- const row = this.createOperationRow(operation);
- tbody.append(row);
- });
- }
-
- // Создание строки операции
- createOperationRow(operation) {
- const typeIcon = operation.type === 'incoming' ? 'fa-box-open text-success' : 'fa-truck text-warning';
- const typeText = operation.type === 'incoming' ? 'Приход' : 'Расход';
- const statusClass = this.getStatusClass(operation.status);
- const statusText = this.getStatusText(operation.status);
-
- return `
-
- | ${this.formatDate(operation.date)} |
-
-
- ${typeText}
- |
- ${operation.itemName} |
- ${operation.quantity} |
-
- ${statusText}
- |
-
- `;
- }
-
- // Получение класса статуса
- getStatusClass(status) {
- switch (status) {
- case 'completed': return 'status-completed';
- case 'pending': return 'status-pending';
- case 'cancelled': return 'status-cancelled';
- default: return 'status-pending';
- }
- }
-
- // Получение текста статуса
- getStatusText(status) {
- switch (status) {
- case 'completed': return 'Завершено';
- case 'pending': return 'В обработке';
- case 'cancelled': return 'Отменено';
- default: return 'Неизвестно';
- }
- }
-
- // Форматирование даты
- formatDate(dateString) {
- const date = new Date(dateString);
- return date.toLocaleDateString('ru-RU', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- });
- }
-
- // Загрузка настроек
- loadSettings() {
- // Загружаем настройки из localStorage
- const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
-
- $('#emailNotifications').prop('checked', settings.emailNotifications !== false);
- $('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
- $('#operationReports').prop('checked', settings.operationReports || false);
-
- $('#themeSelect').val(settings.theme || 'light');
- $('#languageSelect').val(settings.language || 'ru');
- }
-
- // Сохранение настроек
- saveSettings() {
- const settings = {
- emailNotifications: $('#emailNotifications').is(':checked'),
- lowStockAlerts: $('#lowStockAlerts').is(':checked'),
- operationReports: $('#operationReports').is(':checked'),
- theme: $('#themeSelect').val(),
- language: $('#languageSelect').val()
- };
-
- localStorage.setItem('user_settings', JSON.stringify(settings));
- auth.showNotification('Настройки сохранены', 'success');
- }
-
- // Смена пароля
- changePassword() {
- $('#changePasswordForm')[0].reset();
- $('#changePasswordModal').modal('show');
- }
-
- // Сохранение пароля
- savePassword() {
- const currentPassword = $('#currentPassword').val();
- const newPassword = $('#newPassword').val();
- const confirmPassword = $('#confirmPassword').val();
-
- // Валидация
- if (!currentPassword || !newPassword || !confirmPassword) {
- auth.showNotification('Пожалуйста, заполните все поля', 'warning');
- return;
- }
-
- if (newPassword !== confirmPassword) {
- auth.showNotification('Новые пароли не совпадают', 'warning');
- return;
- }
-
- if (newPassword.length < 6) {
- auth.showNotification('Новый пароль должен содержать минимум 6 символов', 'warning');
- return;
- }
-
- // В реальном приложении здесь был бы запрос к серверу
- // Для демонстрации просто показываем уведомление
- auth.showNotification('Пароль успешно изменен', 'success');
- $('#changePasswordModal').modal('hide');
- $('#changePasswordForm')[0].reset();
- }
-
- // Редактирование профиля
- editProfile() {
- // Заполняем форму текущими данными
- $('#editName').val(this.currentUser.name);
- $('#editEmail').val(this.currentUser.email);
- $('#editDepartment').val(this.currentUser.department);
- $('#editPosition').val(this.currentUser.position);
- $('#editPhone').val(this.currentUser.phone || '');
-
- $('#editProfileModal').modal('show');
- }
-
- // Сохранение профиля
- saveProfile() {
- const formData = {
- name: $('#editName').val(),
- email: $('#editEmail').val(),
- department: $('#editDepartment').val(),
- position: $('#editPosition').val(),
- phone: $('#editPhone').val()
- };
-
- // Валидация
- if (!formData.name || !formData.email) {
- auth.showNotification('Пожалуйста, заполните обязательные поля', 'warning');
- return;
- }
-
- if (!this.isValidEmail(formData.email)) {
- auth.showNotification('Пожалуйста, введите корректный email', 'warning');
- return;
- }
-
- // В реальном приложении здесь был бы запрос к серверу
- // Для демонстрации обновляем локальные данные
- Object.assign(this.currentUser, formData);
- localStorage.setItem('warehouse_user', JSON.stringify(this.currentUser));
-
- // Обновляем отображение
- this.displayUserInfo();
-
- auth.showNotification('Профиль успешно обновлен', 'success');
- $('#editProfileModal').modal('hide');
- }
-
- // Просмотр активности
- viewActivity() {
- // В реальном приложении здесь можно открыть страницу с детальной статистикой
- auth.showNotification('Функция просмотра активности находится в разработке', 'info');
- }
-
- // Валидация email
- isValidEmail(email) {
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- return emailRegex.test(email);
- }
-
- // Получение статистики активности
- getActivityStats() {
- const now = new Date();
- const stats = {
- today: 0,
- thisWeek: 0,
- thisMonth: 0,
- total: this.userOperations.length
- };
-
- this.userOperations.forEach(operation => {
- const opDate = new Date(operation.date);
- const diffTime = now - opDate;
- const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
-
- if (diffDays === 0) stats.today++;
- if (diffDays <= 7) stats.thisWeek++;
- if (diffDays <= 30) stats.thisMonth++;
- });
-
- return stats;
- }
-
- // Экспорт данных пользователя
- exportUserData() {
- try {
- const userData = {
- profile: this.currentUser,
- operations: this.userOperations,
- statistics: this.getActivityStats(),
- settings: JSON.parse(localStorage.getItem('user_settings') || '{}')
- };
-
- const dataStr = JSON.stringify(userData, null, 2);
- const blob = new Blob([dataStr], { type: 'application/json' });
- const link = document.createElement('a');
- const url = URL.createObjectURL(blob);
- link.setAttribute('href', url);
- link.setAttribute('download', `profile_${this.currentUser.username}_${new Date().toISOString().split('T')[0]}.json`);
- link.style.visibility = 'hidden';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- auth.showNotification('Данные профиля экспортированы', 'success');
-
- } catch (error) {
- console.error('Ошибка при экспорте:', error);
- auth.showNotification('Ошибка при экспорте данных', 'danger');
- }
- }
-
- // Получение рекомендаций
- getRecommendations() {
- const recommendations = [];
-
- // Рекомендации на основе роли
- if (this.currentUser.roles.includes('admin')) {
- recommendations.push({
- type: 'info',
- title: 'Административные функции',
- message: 'У вас есть доступ ко всем функциям системы. Не забудьте регулярно проверять отчеты и управлять пользователями.'
- });
- }
-
- if (this.currentUser.roles.includes('manager')) {
- recommendations.push({
- type: 'warning',
- title: 'Управление складом',
- message: 'Следите за товарами с низким запасом и контролируйте операции операторов.'
- });
- }
-
- // Рекомендации на основе активности
- const stats = this.getActivityStats();
- if (stats.today === 0) {
- recommendations.push({
- type: 'success',
- title: 'Добро пожаловать!',
- message: 'Сегодня у вас пока нет операций. Начните работу с добавления товаров или оформления операций.'
- });
- }
-
- return recommendations;
- }
-
- // Обновление информации о пользователе
- updateUserInfo() {
- const user = auth.getCurrentUser();
- if (user) {
- $('#currentUserInfo').text(user.name);
- $('#loginLink').hide();
- $('#profileDropdown').show();
-
- // Показываем админ панель только для администраторов
- if (auth.hasRole('admin')) {
- $('#adminDropdown').show();
- } else {
- $('#adminDropdown').hide();
- }
- } else {
- $('#currentUserInfo').text('Гость');
- $('#loginLink').show();
- $('#profileDropdown').hide();
- $('#adminDropdown').hide();
- }
-
- // Обновляем права доступа для элементов интерфейса
- auth.updateButtonPermissions();
- }
-
- // Загрузка категорий в навигацию
- async loadCategoriesNav() {
- try {
- const categories = await this.warehouse.getCategories();
- const categoriesNav = $('#categoriesNav');
-
- // Очищаем существующие категории (кроме "Главная")
- categoriesNav.find('li:not(:first-child)').remove();
-
- // Добавляем активные категории
- categories.forEach(category => {
- if (category.active) {
- const categoryLink = `
-
-
-
- ${category.name}
-
-
- `;
- categoriesNav.append(categoryLink);
- }
- });
- } catch (error) {
- console.error('Ошибка загрузки категорий:', error);
- }
- }
-
- // Загрузка категорий в выпадающее меню
- async loadCategoriesDropdown() {
- try {
- const categories = await this.warehouse.getCategories();
- const dropdown = $('#catalogCategoriesDropdown');
-
- // Очищаем существующие категории (кроме "Все позиции" и разделителя)
- dropdown.find('li:not(:first-child):not(:nth-child(2))').remove();
-
- // Добавляем активные категории
- categories.forEach(category => {
- if (category.active) {
- const isEmpty = category.itemCount === 0;
- const categoryLink = `
-
-
-
-
- ${category.name}
-
- ${category.itemCount || 0}
-
-
- `;
- dropdown.append(categoryLink);
- }
- });
-
- // Инициализируем hover-функциональность
- this.initHoverDropdown();
- } catch (error) {
- console.error('Ошибка загрузки категорий в выпадающее меню:', error);
- }
- }
-
- // Инициализация hover-функциональности для выпадающего меню
- initHoverDropdown() {
- const $dropdown = $('.nav-item.dropdown');
- const $dropdownMenu = $dropdown.find('.dropdown-menu');
- let hoverTimeout;
-
- // Показываем меню при наведении
- $dropdown.on('mouseenter', function() {
- clearTimeout(hoverTimeout);
- $(this).find('.dropdown-menu').addClass('show');
- });
-
- // Скрываем меню при уходе мыши
- $dropdown.on('mouseleave', function() {
- const $menu = $(this).find('.dropdown-menu');
- hoverTimeout = setTimeout(() => {
- $menu.removeClass('show');
- }, 150); // Небольшая задержка для плавности
- });
-
- // Предотвращаем скрытие при наведении на само меню
- $dropdownMenu.on('mouseenter', function() {
- clearTimeout(hoverTimeout);
- });
-
- $dropdownMenu.on('mouseleave', function() {
- hoverTimeout = setTimeout(() => {
- $(this).removeClass('show');
- }, 150);
- });
- }
-}
-
-// Инициализация менеджера профиля
+// JavaScript для страницы профиля пользователя
+
+class ProfileManager {
+ constructor() {
+ this.warehouse = new WarehouseManager();
+ this.currentUser = null;
+ this.userOperations = [];
+
+ this.init();
+ }
+
+ init() {
+ $(document).ready(async () => {
+ try {
+ // Проверяем авторизацию
+ if (!auth.isUserAuthenticated()) {
+ console.log('Пользователь не авторизован, перенаправление на login.html');
+ window.location.href = 'login.html';
+ return;
+ }
+
+ console.log('Инициализация профиля для пользователя:', auth.getCurrentUser()?.name);
+
+ // Загружаем профиль
+ await this.loadProfile();
+
+ // Загружаем категории в навигацию
+ await this.loadCategoriesNav();
+
+ // Загружаем категории в выпадающее меню
+ await this.loadCategoriesDropdown();
+
+ // Обновляем информацию о пользователе
+ this.updateUserInfo();
+
+ // Привязываем события
+ this.bindEvents();
+
+ } catch (error) {
+ console.error('Ошибка инициализации:', error);
+ auth.showNotification('Ошибка загрузки данных', 'danger');
+ }
+ });
+ }
+
+ bindEvents() {
+ // Обработчики для настроек
+ $('#saveSettingsBtn').on('click', () => this.saveSettings());
+
+ // Обработчики для форм
+ $('#changePasswordForm').on('submit', (e) => {
+ e.preventDefault();
+ this.savePassword();
+ });
+
+ $('#editProfileForm').on('submit', (e) => {
+ e.preventDefault();
+ this.saveProfile();
+ });
+
+ // Обработчик выхода
+ $('#logoutBtn').on('click', (e) => {
+ e.preventDefault();
+ auth.logout();
+ window.location.href = 'login.html';
+ });
+ }
+
+ // Загрузка профиля
+ async loadProfile() {
+ try {
+ this.currentUser = auth.getCurrentUser();
+ if (!this.currentUser) {
+ window.location.href = 'login.html';
+ return;
+ }
+
+ this.displayUserInfo();
+ await this.loadUserStatistics();
+ await this.loadUserOperations();
+ this.loadSettings();
+
+ } catch (error) {
+ console.error('Ошибка при загрузке профиля:', error);
+ auth.showNotification('Ошибка при загрузке профиля', 'danger');
+ }
+ }
+
+ // Отображение информации о пользователе
+ displayUserInfo() {
+ // Основная информация
+ $('#userName').text(this.currentUser.name);
+ $('#userPosition').text(this.currentUser.position);
+ $('#userDepartment').text(this.currentUser.department);
+ $('#userEmail').text(this.currentUser.email);
+
+ // Роли
+ const roles = this.currentUser.roles.map(role => this.getRoleDisplayName(role)).join(', ');
+ $('#userRoles').html(`${roles}`);
+
+ // Разрешения
+ const permissions = this.currentUser.permissions.map(perm => this.getPermissionDisplayName(perm)).join(', ');
+ $('#userPermissions').html(`${permissions}`);
+
+ // Дата регистрации (имитация)
+ const registrationDate = new Date();
+ registrationDate.setMonth(registrationDate.getMonth() - 3); // 3 месяца назад
+ $('#userRegistrationDate').text(registrationDate.toLocaleDateString('ru-RU'));
+
+ // Последний вход
+ const lastLogin = new Date();
+ lastLogin.setHours(lastLogin.getHours() - 2); // 2 часа назад
+ $('#lastLogin').text(lastLogin.toLocaleDateString('ru-RU') + ' ' + lastLogin.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }));
+ }
+
+ // Получение отображаемого названия роли
+ getRoleDisplayName(role) {
+ const roleNames = {
+ 'admin': 'Администратор',
+ 'manager': 'Менеджер',
+ 'operator': 'Оператор',
+ 'viewer': 'Наблюдатель'
+ };
+ return roleNames[role] || role;
+ }
+
+ // Получение отображаемого названия разрешения
+ getPermissionDisplayName(permission) {
+ const permissionNames = {
+ 'read': 'Чтение',
+ 'write': 'Запись',
+ 'delete': 'Удаление',
+ 'admin': 'Администрирование'
+ };
+ return permissionNames[permission] || permission;
+ }
+
+ // Загрузка статистики пользователя
+ async loadUserStatistics() {
+ try {
+ const operations = await this.warehouse.getOperations();
+ const userOperations = operations.filter(op => op.employeeId === this.currentUser.id);
+
+ // Общее количество операций
+ $('#totalOperations').text(userOperations.length);
+
+ // Операции в этом месяце
+ const thisMonth = new Date().getMonth();
+ const thisYear = new Date().getFullYear();
+ const thisMonthOperations = userOperations.filter(op => {
+ const opDate = new Date(op.date);
+ return opDate.getMonth() === thisMonth && opDate.getFullYear() === thisYear;
+ });
+ $('#thisMonthOperations').text(thisMonthOperations.length);
+
+ // Операции сегодня
+ const today = new Date().toISOString().split('T')[0];
+ const todayOperations = userOperations.filter(op => op.date.startsWith(today));
+ $('#todayOperations').text(todayOperations.length);
+
+ } catch (error) {
+ console.error('Ошибка при загрузке статистики:', error);
+ }
+ }
+
+ // Загрузка операций пользователя
+ async loadUserOperations() {
+ try {
+ const operations = await this.warehouse.getOperations();
+ this.userOperations = operations
+ .filter(op => op.employeeId === this.currentUser.id)
+ .sort((a, b) => new Date(b.date) - new Date(a.date))
+ .slice(0, 10); // Последние 10 операций
+
+ this.renderUserOperations();
+
+ } catch (error) {
+ console.error('Ошибка при загрузке операций:', error);
+ }
+ }
+
+ // Рендеринг операций пользователя
+ renderUserOperations() {
+ const tbody = $('#userOperationsTable tbody');
+ tbody.empty();
+
+ if (this.userOperations.length === 0) {
+ tbody.html(`
+
+
+
+ У вас пока нет операций
+ |
+
+ `);
+ return;
+ }
+
+ this.userOperations.forEach(operation => {
+ const row = this.createOperationRow(operation);
+ tbody.append(row);
+ });
+ }
+
+ // Создание строки операции
+ createOperationRow(operation) {
+ const typeIcon = operation.type === 'incoming' ? 'fa-box-open text-success' : 'fa-truck text-warning';
+ const typeText = operation.type === 'incoming' ? 'Приход' : 'Расход';
+ const statusClass = this.getStatusClass(operation.status);
+ const statusText = this.getStatusText(operation.status);
+
+ return `
+
+ | ${this.formatDate(operation.date)} |
+
+
+ ${typeText}
+ |
+ ${operation.itemName} |
+ ${operation.quantity} |
+
+ ${statusText}
+ |
+
+ `;
+ }
+
+ // Получение класса статуса
+ getStatusClass(status) {
+ switch (status) {
+ case 'completed': return 'status-completed';
+ case 'pending': return 'status-pending';
+ case 'cancelled': return 'status-cancelled';
+ default: return 'status-pending';
+ }
+ }
+
+ // Получение текста статуса
+ getStatusText(status) {
+ switch (status) {
+ case 'completed': return 'Завершено';
+ case 'pending': return 'В обработке';
+ case 'cancelled': return 'Отменено';
+ default: return 'Неизвестно';
+ }
+ }
+
+ // Форматирование даты
+ formatDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+
+ // Загрузка настроек
+ loadSettings() {
+ // Загружаем настройки из localStorage
+ const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
+
+ $('#emailNotifications').prop('checked', settings.emailNotifications !== false);
+ $('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
+ $('#operationReports').prop('checked', settings.operationReports || false);
+ }
+
+ // Сохранение настроек
+ saveSettings() {
+ const settings = {
+ emailNotifications: $('#emailNotifications').is(':checked'),
+ lowStockAlerts: $('#lowStockAlerts').is(':checked'),
+ operationReports: $('#operationReports').is(':checked')
+ };
+
+ localStorage.setItem('user_settings', JSON.stringify(settings));
+ auth.showNotification('Настройки сохранены', 'success');
+ }
+
+ // Смена пароля
+ changePassword() {
+ $('#changePasswordForm')[0].reset();
+ $('#changePasswordModal').modal('show');
+ }
+
+ // Сохранение пароля
+ savePassword() {
+ const currentPassword = $('#currentPassword').val();
+ const newPassword = $('#newPassword').val();
+ const confirmPassword = $('#confirmPassword').val();
+
+ // Валидация
+ if (!currentPassword || !newPassword || !confirmPassword) {
+ auth.showNotification('Пожалуйста, заполните все поля', 'warning');
+ return;
+ }
+
+ if (newPassword !== confirmPassword) {
+ auth.showNotification('Новые пароли не совпадают', 'warning');
+ return;
+ }
+
+ if (newPassword.length < 6) {
+ auth.showNotification('Новый пароль должен содержать минимум 6 символов', 'warning');
+ return;
+ }
+
+ // В реальном приложении здесь был бы запрос к серверу
+ // Для демонстрации просто показываем уведомление
+ auth.showNotification('Пароль успешно изменен', 'success');
+ $('#changePasswordModal').modal('hide');
+ $('#changePasswordForm')[0].reset();
+ }
+
+ // Редактирование профиля
+ editProfile() {
+ // Заполняем форму текущими данными
+ $('#editName').val(this.currentUser.name);
+ $('#editEmail').val(this.currentUser.email);
+ $('#editDepartment').val(this.currentUser.department);
+ $('#editPosition').val(this.currentUser.position);
+ $('#editPhone').val(this.currentUser.phone || '');
+
+ $('#editProfileModal').modal('show');
+ }
+
+ // Сохранение профиля
+ saveProfile() {
+ const formData = {
+ name: $('#editName').val(),
+ email: $('#editEmail').val(),
+ department: $('#editDepartment').val(),
+ position: $('#editPosition').val(),
+ phone: $('#editPhone').val()
+ };
+
+ // Валидация
+ if (!formData.name || !formData.email) {
+ auth.showNotification('Пожалуйста, заполните обязательные поля', 'warning');
+ return;
+ }
+
+ if (!this.isValidEmail(formData.email)) {
+ auth.showNotification('Пожалуйста, введите корректный email', 'warning');
+ return;
+ }
+
+ // В реальном приложении здесь был бы запрос к серверу
+ // Для демонстрации обновляем локальные данные
+ Object.assign(this.currentUser, formData);
+ localStorage.setItem('warehouse_user', JSON.stringify(this.currentUser));
+
+ // Обновляем отображение
+ this.displayUserInfo();
+
+ auth.showNotification('Профиль успешно обновлен', 'success');
+ $('#editProfileModal').modal('hide');
+ }
+
+ // Просмотр активности
+ viewActivity() {
+ // В реальном приложении здесь можно открыть страницу с детальной статистикой
+ auth.showNotification('Функция просмотра активности находится в разработке', 'info');
+ }
+
+ // Генерация еженедельного отчета
+ generateWeeklyReport() {
+ this.warehouse.generateWeeklyReports();
+ }
+
+ // Валидация email
+ isValidEmail(email) {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ }
+
+ // Получение статистики активности
+ getActivityStats() {
+ const now = new Date();
+ const stats = {
+ today: 0,
+ thisWeek: 0,
+ thisMonth: 0,
+ total: this.userOperations.length
+ };
+
+ this.userOperations.forEach(operation => {
+ const opDate = new Date(operation.date);
+ const diffTime = now - opDate;
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) stats.today++;
+ if (diffDays <= 7) stats.thisWeek++;
+ if (diffDays <= 30) stats.thisMonth++;
+ });
+
+ return stats;
+ }
+
+ // Экспорт данных пользователя
+ exportUserData() {
+ try {
+ const userData = {
+ profile: this.currentUser,
+ operations: this.userOperations,
+ statistics: this.getActivityStats(),
+ settings: JSON.parse(localStorage.getItem('user_settings') || '{}')
+ };
+
+ const dataStr = JSON.stringify(userData, null, 2);
+ const blob = new Blob([dataStr], { type: 'application/json' });
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', `profile_${this.currentUser.username}_${new Date().toISOString().split('T')[0]}.json`);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ auth.showNotification('Данные профиля экспортированы', 'success');
+
+ } catch (error) {
+ console.error('Ошибка при экспорте:', error);
+ auth.showNotification('Ошибка при экспорте данных', 'danger');
+ }
+ }
+
+ // Получение рекомендаций
+ getRecommendations() {
+ const recommendations = [];
+
+ // Рекомендации на основе роли
+ if (this.currentUser.roles.includes('admin')) {
+ recommendations.push({
+ type: 'info',
+ title: 'Административные функции',
+ message: 'У вас есть доступ ко всем функциям системы. Не забудьте регулярно проверять отчеты и управлять пользователями.'
+ });
+ }
+
+ if (this.currentUser.roles.includes('manager')) {
+ recommendations.push({
+ type: 'warning',
+ title: 'Управление складом',
+ message: 'Следите за товарами с низким запасом и контролируйте операции операторов.'
+ });
+ }
+
+ // Рекомендации на основе активности
+ const stats = this.getActivityStats();
+ if (stats.today === 0) {
+ recommendations.push({
+ type: 'success',
+ title: 'Добро пожаловать!',
+ message: 'Сегодня у вас пока нет операций. Начните работу с добавления товаров или оформления операций.'
+ });
+ }
+
+ return recommendations;
+ }
+
+ // Обновление информации о пользователе
+ updateUserInfo() {
+ const user = auth.getCurrentUser();
+ if (user) {
+ $('#currentUserInfo').text(user.name);
+ $('#loginLink').hide();
+ $('#profileDropdown').show();
+
+ // Показываем админ панель только для администраторов
+ if (auth.hasRole('admin')) {
+ $('#adminDropdown').show();
+ } else {
+ $('#adminDropdown').hide();
+ }
+ } else {
+ $('#currentUserInfo').text('Гость');
+ $('#loginLink').show();
+ $('#profileDropdown').hide();
+ $('#adminDropdown').hide();
+ }
+
+ // Обновляем права доступа для элементов интерфейса
+ auth.updateButtonPermissions();
+ }
+
+ // Загрузка категорий в навигацию
+ async loadCategoriesNav() {
+ try {
+ const categories = await this.warehouse.getCategories();
+ const categoriesNav = $('#categoriesNav');
+
+ // Очищаем существующие категории (кроме "Главная")
+ categoriesNav.find('li:not(:first-child)').remove();
+
+ // Добавляем активные категории
+ categories.forEach(category => {
+ if (category.active) {
+ const categoryLink = `
+
+
+
+ ${category.name}
+
+
+ `;
+ categoriesNav.append(categoryLink);
+ }
+ });
+ } catch (error) {
+ console.error('Ошибка загрузки категорий:', error);
+ }
+ }
+
+ // Загрузка категорий в выпадающее меню
+ async loadCategoriesDropdown() {
+ try {
+ const categories = await this.warehouse.getCategories();
+ const dropdown = $('#catalogCategoriesDropdown');
+
+ // Очищаем существующие категории (кроме "Все позиции" и разделителя)
+ dropdown.find('li:not(:first-child):not(:nth-child(2))').remove();
+
+ // Добавляем активные категории
+ categories.forEach(category => {
+ if (category.active) {
+ const isEmpty = category.itemCount === 0;
+ const categoryLink = `
+
+
+
+
+ ${category.name}
+
+ ${category.itemCount || 0}
+
+
+ `;
+ dropdown.append(categoryLink);
+ }
+ });
+
+ // Инициализируем hover-функциональность
+ this.initHoverDropdown();
+ } catch (error) {
+ console.error('Ошибка загрузки категорий в выпадающее меню:', error);
+ }
+ }
+
+ // Инициализация hover-функциональности для выпадающего меню
+ initHoverDropdown() {
+ const $dropdown = $('.nav-item.dropdown');
+ const $dropdownMenu = $dropdown.find('.dropdown-menu');
+ let hoverTimeout;
+
+ // Показываем меню при наведении
+ $dropdown.on('mouseenter', function() {
+ clearTimeout(hoverTimeout);
+ $(this).find('.dropdown-menu').addClass('show');
+ });
+
+ // Скрываем меню при уходе мыши
+ $dropdown.on('mouseleave', function() {
+ const $menu = $(this).find('.dropdown-menu');
+ hoverTimeout = setTimeout(() => {
+ $menu.removeClass('show');
+ }, 150); // Небольшая задержка для плавности
+ });
+
+ // Предотвращаем скрытие при наведении на само меню
+ $dropdownMenu.on('mouseenter', function() {
+ clearTimeout(hoverTimeout);
+ });
+
+ $dropdownMenu.on('mouseleave', function() {
+ hoverTimeout = setTimeout(() => {
+ $(this).removeClass('show');
+ }, 150);
+ });
+ }
+}
+
+// Инициализация менеджера профиля
const profileManager = new ProfileManager();
\ No newline at end of file
diff --git a/js/reports.js b/js/reports.js
new file mode 100644
index 0000000..7bdd54e
--- /dev/null
+++ b/js/reports.js
@@ -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(`
+ | ${formatDate(op.date)} |
+ ${op.type === 'incoming' ? 'Приход' : 'Расход'} |
+ ${escapeHtml(op.itemName)} |
+ ${op.quantity} |
+ ${escapeHtml(op.employeeName || op.username || '')} |
+ ${escapeHtml(op.categoryName || '')} |
+
`);
+ });
+
+ const byCatBody = $('#byCategoryTable tbody');
+ byCatBody.empty();
+ state.data.byCategory.forEach(row => {
+ byCatBody.append(`
+ | ${escapeHtml(row.categoryName)} |
+ ${row.itemCount} |
+ ${row.totalQuantity} |
+
`);
+ });
+
+ const topItemsBody = $('#topItemsTable tbody');
+ topItemsBody.empty();
+ state.data.topItems.forEach(row => {
+ topItemsBody.append(`
+ | ${escapeHtml(row.itemName)} |
+ ${row.totalIncoming} |
+ ${row.totalOutgoing} |
+ ${row.totalIncoming - row.totalOutgoing} |
+
`);
+ });
+ }
+
+ 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(``));
+ } 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]));
+ }
+})();
diff --git a/js/users.js b/js/users.js
new file mode 100644
index 0000000..d049186
--- /dev/null
+++ b/js/users.js
@@ -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 `${roleConfig.icon} ${roleConfig.name}`;
+ }).join('');
+
+ const permissionChips = user.permissions.map(perm => {
+ const permConfig = this.getPermissionConfig(perm);
+ return `${permConfig.icon} ${permConfig.name}`;
+ }).join('');
+
+ const statusClass = user.active ? 'status-active' : 'status-inactive';
+ const statusIcon = user.active ? 'fa-user-check' : 'fa-user-times';
+ const statusText = user.active ? 'Активен' : 'Неактивен';
+
+ return `
+
+
+
+
+
+
+
+ ${this.highlightSearch(user.email)}
+
+
+
+ ${user.department || 'Не указан'}
+
+
+
+ ${user.position || 'Не указана'}
+
+
+
+ ${statusText}
+
+
+
+
+
+
${roleBadges}
+
+
+
+
+
${permissionChips}
+
+
+
+
Создан: ${this.formatDate(user.createdAt)}
+
Последний вход: ${this.formatDate(user.lastLogin)}
+
+
+
+
+ `;
+ }
+
+ // Конфигурация ролей
+ 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, '$1');
+ }
+
+ // Обработка поиска
+ 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 = `
+
+
+
+
+ ${category.name}
+
+
+
+ `;
+ dropdown.append(categoryLink);
+ }
+ });
+ } catch (error) {
+ // Ошибка загрузки категорий не критична для этой страницы
+ }
+ }
+}
+
+// Инициализация менеджера пользователей
+const userManager = new UserManager();
diff --git a/js/warehouse.js b/js/warehouse.js
index 54d41f3..f6893d3 100644
--- a/js/warehouse.js
+++ b/js/warehouse.js
@@ -1,551 +1,880 @@
-// Модуль управления складом
-
-class WarehouseManager {
- constructor() {
- this.items = this.loadItems();
- this.operations = this.loadOperations();
- this.categories = this.loadCategories();
- }
-
- // Загрузка позиций из localStorage
- loadItems() {
- const items = localStorage.getItem('warehouse_items');
- return items ? JSON.parse(items) : this.getDefaultItems();
- }
-
- // Загрузка операций из localStorage
- loadOperations() {
- const operations = localStorage.getItem('warehouse_operations');
- return operations ? JSON.parse(operations) : [];
- }
-
- // Загрузка категорий
- loadCategories() {
- const categories = localStorage.getItem('warehouse_categories');
- if (categories) {
- this.categories = JSON.parse(categories);
- } else {
- // Загружаем категории по умолчанию
- this.categories = this.getDefaultCategories();
- this.saveCategories();
- }
-
- // Обновляем количество позиций в каждой категории
- this.updateCategoryItemCounts();
- }
-
- // Получение категорий по умолчанию
- getDefaultCategories() {
- return [];
- }
-
- // Сохранение категорий
- saveCategories() {
- localStorage.setItem('warehouse_categories', JSON.stringify(this.categories));
- }
-
- // Обновление количества позиций в категориях
- updateCategoryItemCounts() {
- this.categories.forEach(category => {
- const itemsInCategory = this.items.filter(item => item.category === category.id);
- category.itemCount = itemsInCategory.length;
- category.totalQuantity = itemsInCategory.reduce((sum, item) => sum + (item.quantity || 0), 0);
- });
- this.saveCategories();
- }
-
- // Сохранение данных в localStorage
- saveItems() {
- localStorage.setItem('warehouse_items', JSON.stringify(this.items));
- }
-
- saveOperations() {
- localStorage.setItem('warehouse_operations', JSON.stringify(this.operations));
- }
-
- // Получение позиций по умолчанию
- getDefaultItems() {
- return [];
- }
-
- // Получение статистики
- getStatistics() {
- const today = new Date().toISOString().split('T')[0];
-
- const totalItems = this.items.reduce((sum, item) => sum + item.quantity, 0);
- const totalValue = this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
- const lowStockItems = this.items.filter(item => item.quantity <= item.minQuantity).length;
- const todayOperations = this.operations.filter(op =>
- op.date.startsWith(today)
- ).length;
-
- return {
- totalItems,
- totalValue,
- lowStockItems,
- todayOperations,
- totalCategories: this.categories.length,
- totalOperations: this.operations.length
- };
- }
-
- // Получение всех позиций
- async getAllItems() {
- return this.items;
- }
-
- // Синхронное получение всех позиций
- getAllItemsSync() {
- return this.items;
- }
-
- // Получение позиции по ID
- async getItemById(id) {
- return this.items.find(item => item.id === parseInt(id));
- }
-
- // Добавление новой позиции
- async addItem(itemData) {
- const newItem = {
- id: this.getNextItemId(),
- name: itemData.itemName,
- category: itemData.itemCategory || 'uncategorized',
- price: parseFloat(itemData.itemPrice),
- quantity: parseInt(itemData.itemQuantity),
- description: itemData.itemDescription || '',
- barcode: itemData.itemBarcode || '',
- minQuantity: parseInt(itemData.itemQuantity) * 0.2, // 20% от начального количества
- location: itemData.itemLocation || 'Новый стеллаж',
- supplier: itemData.itemSupplier || '',
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString()
- };
-
- this.items.push(newItem);
- this.saveItems();
-
- // Обновляем количество товаров в категории
- this.updateCategoryItemCounts();
-
- // Создаем операцию прихода для новой позиции
- await this.createOperation({
- type: 'incoming',
- itemId: newItem.id,
- quantity: newItem.quantity,
- notes: 'Добавление новой позиции'
- });
-
- return newItem;
- }
-
- // Обновление позиции
- async updateItem(id, itemData) {
- const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
- if (itemIndex === -1) {
- throw new Error('Позиция не найдена');
- }
-
- this.items[itemIndex] = {
- ...this.items[itemIndex],
- ...itemData,
- updatedAt: new Date().toISOString()
- };
-
- this.saveItems();
-
- // Обновляем количество товаров в категории
- this.updateCategoryItemCounts();
-
- return this.items[itemIndex];
- }
-
- // Удаление позиции
- async deleteItem(id) {
- const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
- if (itemIndex === -1) {
- throw new Error('Позиция не найдена');
- }
-
- this.items.splice(itemIndex, 1);
- this.saveItems();
-
- // Обновляем количество товаров в категории
- this.updateCategoryItemCounts();
-
- return true;
- }
-
- // Обработка прихода
- async processIncoming(operationData) {
- const itemId = parseInt(operationData.incomingItem);
- const quantity = parseInt(operationData.incomingQuantity);
-
- const item = await this.getItemById(itemId);
- if (!item) {
- throw new Error('Позиция не найдена');
- }
-
- // Обновляем количество позиции
- item.quantity += quantity;
- item.updatedAt = new Date().toISOString();
- this.saveItems();
-
- // Обновляем количество товаров в категории
- this.updateCategoryItemCounts();
-
- // Создаем операцию
- const operation = await this.createOperation({
- type: 'incoming',
- itemId: itemId,
- quantity: quantity,
- supplier: operationData.incomingSupplier || '',
- notes: operationData.incomingNotes || ''
- });
-
- return operation;
- }
-
- // Обработка расхода
- async processOutgoing(operationData) {
- const itemId = parseInt(operationData.outgoingItem);
- const quantity = parseInt(operationData.outgoingQuantity);
-
- const item = await this.getItemById(itemId);
- if (!item) {
- throw new Error('Позиция не найдена');
- }
-
- // Проверяем достаточность позиции
- if (item.quantity < quantity) {
- throw new Error(`Недостаточно позиций. Доступно: ${item.quantity} шт.`);
- }
-
- // Обновляем количество позиции
- item.quantity -= quantity;
- item.updatedAt = new Date().toISOString();
- this.saveItems();
-
- // Обновляем количество товаров в категории
- this.updateCategoryItemCounts();
-
- // Создаем операцию
- const operation = await this.createOperation({
- type: 'outgoing',
- itemId: itemId,
- quantity: quantity,
- recipient: operationData.outgoingRecipient || '',
- notes: operationData.outgoingNotes || ''
- });
-
- return operation;
- }
-
- // Создание операции
- async createOperation(operationData) {
- const item = await this.getItemById(operationData.itemId);
- const currentUser = auth.getCurrentUser();
-
- const operation = {
- id: this.getNextOperationId(),
- type: operationData.type,
- itemId: operationData.itemId,
- itemName: item.name,
- quantity: operationData.quantity,
- employeeId: currentUser.id,
- employeeName: currentUser.name,
- date: new Date().toISOString(),
- status: 'completed',
- supplier: operationData.supplier || '',
- recipient: operationData.recipient || '',
- notes: operationData.notes || ''
- };
-
- this.operations.push(operation);
- this.saveOperations();
-
- return operation;
- }
-
- // Получение последних операций
- async getRecentOperations(limit = 10) {
- return this.operations
- .sort((a, b) => new Date(b.date) - new Date(a.date))
- .slice(0, limit);
- }
-
- // Получение операций по фильтрам
- async getOperations(filters = {}) {
- let filteredOperations = [...this.operations];
-
- // Фильтр по типу
- if (filters.type) {
- filteredOperations = filteredOperations.filter(op => op.type === filters.type);
- }
-
- // Фильтр по дате
- if (filters.startDate) {
- filteredOperations = filteredOperations.filter(op =>
- new Date(op.date) >= new Date(filters.startDate)
- );
- }
-
- if (filters.endDate) {
- filteredOperations = filteredOperations.filter(op =>
- new Date(op.date) <= new Date(filters.endDate)
- );
- }
-
- // Фильтр по товару
- if (filters.itemId) {
- filteredOperations = filteredOperations.filter(op =>
- op.itemId === parseInt(filters.itemId)
- );
- }
-
- // Сортировка
- const sortField = filters.sortBy || 'date';
- const sortOrder = filters.sortOrder || 'desc';
-
- filteredOperations.sort((a, b) => {
- let aValue = a[sortField];
- let bValue = b[sortField];
-
- if (sortField === 'date') {
- aValue = new Date(aValue);
- bValue = new Date(bValue);
- }
-
- if (sortOrder === 'asc') {
- return aValue > bValue ? 1 : -1;
- } else {
- return aValue < bValue ? 1 : -1;
- }
- });
-
- return filteredOperations;
- }
-
- // Поиск товаров
- async searchItems(query, filters = {}) {
- let results = [...this.items];
-
- // Поиск по названию и описанию
- if (query) {
- const searchQuery = query.toLowerCase();
- results = results.filter(item =>
- item.name.toLowerCase().includes(searchQuery) ||
- item.description.toLowerCase().includes(searchQuery) ||
- item.barcode.includes(searchQuery)
- );
- }
-
- // Фильтр по категории
- if (filters.category) {
- results = results.filter(item => item.category === filters.category);
- }
-
- // Фильтр по цене
- if (filters.minPrice) {
- results = results.filter(item => item.price >= filters.minPrice);
- }
-
- if (filters.maxPrice) {
- results = results.filter(item => item.price <= filters.maxPrice);
- }
-
- // Фильтр по наличию
- if (filters.inStock) {
- results = results.filter(item => item.quantity > 0);
- }
-
- if (filters.lowStock) {
- results = results.filter(item => item.quantity <= item.minQuantity);
- }
-
- return results;
- }
-
- // Получение товаров с низким запасом
- async getLowStockItems() {
- return this.items.filter(item => item.quantity <= item.minQuantity);
- }
-
- // Получение категорий
- async getCategories() {
- return this.categories;
- }
-
- // Получение категории по ID
- async getCategoryById(id) {
- return this.categories.find(cat => cat.id === id);
- }
-
- // Добавление категории
- async addCategory(categoryData) {
- const newCategory = {
- id: this.generateCategoryId(),
- ...categoryData,
- itemCount: 0,
- createdAt: new Date().toISOString()
- };
-
- this.categories.push(newCategory);
- this.saveCategories();
- return newCategory;
- }
-
- // Обновление категории
- async updateCategory(id, categoryData) {
- const categoryIndex = this.categories.findIndex(cat => cat.id === id);
- if (categoryIndex === -1) {
- throw new Error('Категория не найдена');
- }
-
- this.categories[categoryIndex] = {
- ...this.categories[categoryIndex],
- ...categoryData,
- updatedAt: new Date().toISOString()
- };
-
- this.saveCategories();
- return this.categories[categoryIndex];
- }
-
- // Удаление категории
- async deleteCategory(id) {
- const categoryIndex = this.categories.findIndex(cat => cat.id === id);
- if (categoryIndex === -1) {
- throw new Error('Категория не найдена');
- }
-
- // Удаляем все позиции этой категории
- this.items = this.items.filter(item => item.category !== id);
- this.saveItems();
-
- // Удаляем категорию
- this.categories.splice(categoryIndex, 1);
- this.saveCategories();
-
- // Обновляем количество позиций в остальных категориях
- this.updateCategoryItemCounts();
-
- return true;
- }
-
- // Генерация следующего ID для товара
- getNextItemId() {
- const maxId = Math.max(...this.items.map(item => item.id), 0);
- return maxId + 1;
- }
-
- // Генерация следующего ID для операции
- getNextOperationId() {
- const maxId = Math.max(...this.operations.map(op => op.id), 0);
- return maxId + 1;
- }
-
- // Генерация ID для категории
- generateCategoryId() {
- return 'category_' + Date.now();
- }
-
- // Экспорт данных
- async exportData(type = 'items') {
- switch (type) {
- case 'items':
- return this.exportItems();
- case 'operations':
- return this.exportOperations();
- case 'statistics':
- return this.exportStatistics();
- default:
- throw new Error('Неизвестный тип экспорта');
- }
- }
-
- // Экспорт товаров
- exportItems() {
- const headers = ['ID', 'Название', 'Категория', 'Цена', 'Количество', 'Описание', 'Штрих-код', 'Местоположение'];
- const rows = this.items.map(item => [
- item.id,
- item.name,
- this.getCategoryName(item.category),
- item.price,
- item.quantity,
- item.description,
- item.barcode,
- item.location
- ]);
-
- return { headers, rows };
- }
-
- // Экспорт операций
- exportOperations() {
- const headers = ['ID', 'Дата', 'Тип', 'Товар', 'Количество', 'Сотрудник', 'Статус', 'Примечания'];
- const rows = this.operations.map(op => [
- op.id,
- new Date(op.date).toLocaleDateString('ru-RU'),
- op.type === 'incoming' ? 'Приход' : 'Расход',
- op.itemName,
- op.quantity,
- op.employeeName,
- op.status === 'completed' ? 'Завершено' : 'В обработке',
- op.notes
- ]);
-
- return { headers, rows };
- }
-
- // Экспорт статистики
- exportStatistics() {
- const stats = this.getStatistics();
- return {
- headers: ['Показатель', 'Значение'],
- rows: [
- ['Всего товаров', stats.totalItems],
- ['Общая стоимость', stats.totalValue + ' ₽'],
- ['Товары с низким запасом', stats.lowStockItems],
- ['Операций сегодня', stats.todayOperations],
- ['Всего категорий', stats.totalCategories],
- ['Всего операций', stats.totalOperations]
- ]
- };
- }
-
- // Получение названия категории
- getCategoryName(categoryId) {
- const category = this.categories.find(cat => cat.id === categoryId);
- return category ? category.name : 'Неизвестная категория';
- }
-
- // Получение отчета по движению товаров
- async getMovementReport(startDate, endDate, itemId = null) {
- let operations = this.operations.filter(op => {
- const opDate = new Date(op.date);
- return opDate >= new Date(startDate) && opDate <= new Date(endDate);
- });
-
- if (itemId) {
- operations = operations.filter(op => op.itemId === parseInt(itemId));
- }
-
- const report = {
- period: { startDate, endDate },
- totalIncoming: operations.filter(op => op.type === 'incoming').length,
- totalOutgoing: operations.filter(op => op.type === 'outgoing').length,
- totalIncomingQuantity: operations
- .filter(op => op.type === 'incoming')
- .reduce((sum, op) => sum + op.quantity, 0),
- totalOutgoingQuantity: operations
- .filter(op => op.type === 'outgoing')
- .reduce((sum, op) => sum + op.quantity, 0),
- operations: operations
- };
-
- return report;
- }
-}
-
-// Экспорт для использования в других модулях
+// Модуль управления складом
+
+class WarehouseManager {
+ constructor() {
+ this.apiBase = (window && window.API_BASE) ? window.API_BASE : 'http://localhost:3001/api';
+ this.items = this.loadItems();
+ this.operations = this.loadOperations();
+ this.categories = this.loadCategories();
+ }
+
+ // Универсальный вызов API с таймаутом и graceful fallback
+ async apiRequest(path, options = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 5000);
+ try {
+ const res = await fetch(`${this.apiBase}${path}`, {
+ method: options.method || 'GET',
+ headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ signal: controller.signal,
+ credentials: 'omit'
+ });
+ if (!res.ok) throw new Error(`API ${res.status}`);
+ return await res.json();
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+
+ // Отправка уведомления о низком остатке через API
+ async sendLowStockEmailNotification(items) {
+ try {
+ const response = await this.apiRequest('/notifications/low-stock', {
+ method: 'POST',
+ body: { items }
+ });
+
+ console.log('Уведомления о низком остатке отправлены:', response);
+ return response;
+ } catch (error) {
+ console.error('Ошибка отправки уведомлений о низком остатке:', error);
+ throw error;
+ }
+ }
+
+ // Отправка уведомления об операции через API
+ async sendOperationEmailNotificationAPI(operation) {
+ try {
+ const response = await this.apiRequest('/notifications/operation', {
+ method: 'POST',
+ body: { operation }
+ });
+
+ console.log('Уведомления об операции отправлены:', response);
+ return response;
+ } catch (error) {
+ console.error('Ошибка отправки уведомлений об операции:', error);
+ throw error;
+ }
+ }
+
+ // Отправка еженедельного отчета через API
+ async sendWeeklyReportEmailNotification(report) {
+ try {
+ const response = await this.apiRequest('/notifications/weekly-report', {
+ method: 'POST',
+ body: { report }
+ });
+
+ console.log('Еженедельные отчеты отправлены:', response);
+ return response;
+ } catch (error) {
+ console.error('Ошибка отправки еженедельных отчетов:', error);
+ throw error;
+ }
+ }
+
+ // Тестовая отправка email
+ async sendTestEmail(email, subject, message) {
+ try {
+ const response = await this.apiRequest('/notifications/test', {
+ method: 'POST',
+ body: { email, subject, message }
+ });
+
+ console.log('Тестовое уведомление отправлено:', response);
+ return response;
+ } catch (error) {
+ console.error('Ошибка отправки тестового уведомления:', error);
+ throw error;
+ }
+ }
+
+ // Загрузка позиций из localStorage
+ loadItems() {
+ const items = localStorage.getItem('warehouse_items');
+ return items ? JSON.parse(items) : this.getDefaultItems();
+ }
+
+ // Загрузка операций из localStorage
+ loadOperations() {
+ const operations = localStorage.getItem('warehouse_operations');
+ return operations ? JSON.parse(operations) : [];
+ }
+
+ // Загрузка категорий
+ loadCategories() {
+ const categories = localStorage.getItem('warehouse_categories');
+ if (categories) {
+ this.categories = JSON.parse(categories);
+ } else {
+ // Загружаем категории по умолчанию
+ this.categories = this.getDefaultCategories();
+ this.saveCategories();
+ }
+
+ // Обновляем количество позиций в каждой категории
+ this.updateCategoryItemCounts();
+ }
+
+ // Получение категорий по умолчанию
+ getDefaultCategories() {
+ return [];
+ }
+
+ // Сохранение категорий
+ saveCategories() {
+ localStorage.setItem('warehouse_categories', JSON.stringify(this.categories));
+ }
+
+ // Обновление количества позиций в категориях
+ updateCategoryItemCounts() {
+ this.categories.forEach(category => {
+ const itemsInCategory = this.items.filter(item => item.category === category.id);
+ category.itemCount = itemsInCategory.length;
+ category.totalQuantity = itemsInCategory.reduce((sum, item) => sum + (item.quantity || 0), 0);
+ });
+ this.saveCategories();
+ }
+
+ // Сохранение данных в localStorage
+ saveItems() {
+ localStorage.setItem('warehouse_items', JSON.stringify(this.items));
+ }
+
+ saveOperations() {
+ localStorage.setItem('warehouse_operations', JSON.stringify(this.operations));
+ }
+
+ // Получение позиций по умолчанию
+ getDefaultItems() {
+ return [];
+ }
+
+ // Получение статистики
+ getStatistics() {
+ const today = new Date().toISOString().split('T')[0];
+
+ const totalItems = this.items.reduce((sum, item) => sum + item.quantity, 0);
+ const totalValue = this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
+ const lowStockItems = this.items.filter(item => item.quantity <= item.minQuantity).length;
+ const todayOperations = this.operations.filter(op =>
+ op.date.startsWith(today)
+ ).length;
+
+ return {
+ totalItems,
+ totalValue,
+ lowStockItems,
+ todayOperations,
+ totalCategories: this.categories.length,
+ totalOperations: this.operations.length
+ };
+ }
+
+ // Получение всех позиций
+ async getAllItems() {
+ try {
+ const data = await this.apiRequest('/items');
+ this.items = (data || []).map(row => ({
+ id: row.id,
+ name: row.name,
+ category: row.category_id || 'uncategorized',
+ price: Number(row.price || 0),
+ quantity: Number(row.quantity || 0),
+ description: row.description || '',
+ barcode: row.barcode || '',
+ minQuantity: Number(row.min_quantity || 0),
+ location: row.location || '',
+ supplier: row.supplier || '',
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ }));
+ this.saveItems();
+ this.updateCategoryItemCounts();
+ return this.items;
+ } catch (e) {
+ return this.items;
+ }
+ }
+
+ // Синхронное получение всех позиций
+ getAllItemsSync() {
+ return this.items;
+ }
+
+ // Получение позиции по ID
+ async getItemById(id) {
+ try {
+ const row = await this.apiRequest(`/items/${id}`);
+ return {
+ id: row.id,
+ name: row.name,
+ category: row.category_id || 'uncategorized',
+ price: Number(row.price || 0),
+ quantity: Number(row.quantity || 0),
+ description: row.description || '',
+ barcode: row.barcode || '',
+ minQuantity: Number(row.min_quantity || 0),
+ location: row.location || '',
+ supplier: row.supplier || '',
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ };
+ } catch (e) {
+ return this.items.find(item => item.id === parseInt(id));
+ }
+ }
+
+ // Добавление новой позиции
+ async addItem(itemData) {
+ const newItem = {
+ id: this.getNextItemId(),
+ name: itemData.itemName,
+ category: itemData.itemCategory || 'uncategorized',
+ price: parseFloat(itemData.itemPrice),
+ quantity: parseInt(itemData.itemQuantity),
+ description: itemData.itemDescription || '',
+ barcode: itemData.itemBarcode || '',
+ minQuantity: parseInt(itemData.itemQuantity) * 0.2, // 20% от начального количества
+ location: itemData.itemLocation || 'Новый стеллаж',
+ supplier: itemData.itemSupplier || '',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+
+ // Попытка создать через API
+ try {
+ const created = await this.apiRequest('/items', {
+ method: 'POST',
+ body: {
+ name: newItem.name,
+ category_id: newItem.category === 'uncategorized' ? null : newItem.category,
+ price: newItem.price,
+ quantity: newItem.quantity,
+ description: newItem.description,
+ barcode: newItem.barcode,
+ min_quantity: newItem.minQuantity,
+ location: newItem.location,
+ supplier: newItem.supplier
+ }
+ });
+ if (created && created.id) newItem.id = created.id;
+ } catch (e) {}
+
+ this.items.push(newItem);
+ this.saveItems();
+ this.updateCategoryItemCounts();
+
+ await this.createOperation({
+ type: 'incoming',
+ itemId: newItem.id,
+ quantity: newItem.quantity,
+ notes: 'Добавление новой позиции'
+ });
+
+ return newItem;
+ }
+
+ // Обновление позиции
+ async updateItem(id, itemData) {
+ const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
+ if (itemIndex === -1) {
+ throw new Error('Позиция не найдена');
+ }
+
+ try {
+ await this.apiRequest(`/items/${id}`, {
+ method: 'PUT',
+ body: {
+ name: itemData.name,
+ category_id: itemData.category === 'uncategorized' ? null : itemData.category,
+ price: itemData.price,
+ quantity: itemData.quantity,
+ description: itemData.description,
+ barcode: itemData.barcode,
+ min_quantity: itemData.minQuantity,
+ location: itemData.location,
+ supplier: itemData.supplier
+ }
+ });
+ } catch (e) {}
+
+ this.items[itemIndex] = {
+ ...this.items[itemIndex],
+ ...itemData,
+ updatedAt: new Date().toISOString()
+ };
+
+ this.saveItems();
+
+ // Обновляем количество товаров в категории
+ this.updateCategoryItemCounts();
+
+ return this.items[itemIndex];
+ }
+
+ // Удаление позиции
+ async deleteItem(id) {
+ const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
+ if (itemIndex === -1) {
+ throw new Error('Позиция не найдена');
+ }
+
+ try { await this.apiRequest(`/items/${id}`, { method: 'DELETE' }); } catch (e) {}
+
+ this.items.splice(itemIndex, 1);
+ this.saveItems();
+
+ // Обновляем количество товаров в категории
+ this.updateCategoryItemCounts();
+
+ return true;
+ }
+
+ // Обработка прихода
+ async processIncoming(operationData) {
+ const itemId = parseInt(operationData.incomingItem);
+ const quantity = parseInt(operationData.incomingQuantity);
+
+ const item = await this.getItemById(itemId);
+ if (!item) {
+ throw new Error('Позиция не найдена');
+ }
+
+ // Обновляем количество позиции
+ item.quantity += quantity;
+ item.updatedAt = new Date().toISOString();
+ this.saveItems();
+
+ // Обновляем количество товаров в категории
+ this.updateCategoryItemCounts();
+
+ // Создаем операцию
+ try {
+ await this.apiRequest('/operations/incoming', {
+ method: 'POST',
+ body: { itemId, quantity, supplier: operationData.incomingSupplier || '', notes: operationData.incomingNotes || '', employeeId: auth.getCurrentUser()?.id }
+ });
+ } catch (e) {}
+
+ const operation = await this.createOperation({
+ type: 'incoming',
+ itemId: itemId,
+ quantity: quantity,
+ supplier: operationData.incomingSupplier || '',
+ notes: operationData.incomingNotes || ''
+ });
+
+ return operation;
+ }
+
+ // Обработка расхода
+ async processOutgoing(operationData) {
+ const itemId = parseInt(operationData.outgoingItem);
+ const quantity = parseInt(operationData.outgoingQuantity);
+
+ const item = await this.getItemById(itemId);
+ if (!item) {
+ throw new Error('Позиция не найдена');
+ }
+
+ // Проверяем достаточность позиции
+ if (item.quantity < quantity) {
+ throw new Error(`Недостаточно позиций. Доступно: ${item.quantity} шт.`);
+ }
+
+ // Обновляем количество позиции
+ item.quantity -= quantity;
+ item.updatedAt = new Date().toISOString();
+ this.saveItems();
+
+ // Обновляем количество товаров в категории
+ this.updateCategoryItemCounts();
+
+ // Создаем операцию
+ try {
+ await this.apiRequest('/operations/outgoing', {
+ method: 'POST',
+ body: { itemId, quantity, recipient: operationData.outgoingRecipient || '', notes: operationData.outgoingNotes || '', employeeId: auth.getCurrentUser()?.id }
+ });
+ } catch (e) {}
+
+ const operation = await this.createOperation({
+ type: 'outgoing',
+ itemId: itemId,
+ quantity: quantity,
+ recipient: operationData.outgoingRecipient || '',
+ notes: operationData.outgoingNotes || ''
+ });
+
+ return operation;
+ }
+
+ // Создание операции
+ async createOperation(operationData) {
+ const item = await this.getItemById(operationData.itemId);
+ const currentUser = auth.getCurrentUser();
+
+ const operation = {
+ id: this.getNextOperationId(),
+ type: operationData.type,
+ itemId: operationData.itemId,
+ itemName: item.name,
+ quantity: operationData.quantity,
+ employeeId: currentUser.id,
+ employeeName: currentUser.name,
+ date: new Date().toISOString(),
+ status: 'completed',
+ supplier: operationData.supplier || '',
+ recipient: operationData.recipient || '',
+ notes: operationData.notes || ''
+ };
+
+ this.operations.push(operation);
+ this.saveOperations();
+
+ // Проверяем и отправляем уведомления
+ await this.checkAndSendNotifications(operation);
+
+ return operation;
+ }
+
+ // Получение последних операций
+ async getRecentOperations(limit = 10) {
+ try {
+ const ops = await this.apiRequest(`/operations?sortBy=date&sortOrder=desc`);
+ this.syncOperationsFromApi(ops);
+ return this.operations.slice(0, limit);
+ } catch (e) {
+ return this.operations
+ .sort((a, b) => new Date(b.date) - new Date(a.date))
+ .slice(0, limit);
+ }
+ }
+
+ // Получение операций по фильтрам
+ async getOperations(filters = {}) {
+ try {
+ const q = new URLSearchParams();
+ if (filters.type) q.set('type', filters.type);
+ if (filters.startDate) q.set('startDate', filters.startDate);
+ if (filters.endDate) q.set('endDate', filters.endDate);
+ if (filters.itemId) q.set('itemId', filters.itemId);
+ if (filters.sortBy) q.set('sortBy', filters.sortBy);
+ if (filters.sortOrder) q.set('sortOrder', filters.sortOrder);
+ const ops = await this.apiRequest(`/operations?${q.toString()}`);
+ this.syncOperationsFromApi(ops);
+ return this.operations;
+ } catch (e) {}
+
+ let filteredOperations = [...this.operations];
+
+ // Фильтр по типу
+ if (filters.type) {
+ filteredOperations = filteredOperations.filter(op => op.type === filters.type);
+ }
+
+ // Фильтр по дате
+ if (filters.startDate) {
+ filteredOperations = filteredOperations.filter(op =>
+ new Date(op.date) >= new Date(filters.startDate)
+ );
+ }
+
+ if (filters.endDate) {
+ filteredOperations = filteredOperations.filter(op =>
+ new Date(op.date) <= new Date(filters.endDate)
+ );
+ }
+
+ // Фильтр по товару
+ if (filters.itemId) {
+ filteredOperations = filteredOperations.filter(op =>
+ op.itemId === parseInt(filters.itemId)
+ );
+ }
+
+ // Сортировка
+ const sortField = filters.sortBy || 'date';
+ const sortOrder = filters.sortOrder || 'desc';
+
+ filteredOperations.sort((a, b) => {
+ let aValue = a[sortField];
+ let bValue = b[sortField];
+
+ if (sortField === 'date') {
+ aValue = new Date(aValue);
+ bValue = new Date(bValue);
+ }
+
+ if (sortOrder === 'asc') {
+ return aValue > bValue ? 1 : -1;
+ } else {
+ return aValue < bValue ? 1 : -1;
+ }
+ });
+
+ return filteredOperations;
+ }
+
+ // Приведение формата операций из API и сохранение в localStorage
+ syncOperationsFromApi(apiOps) {
+ this.operations = (apiOps || []).map(op => ({
+ id: op.id,
+ type: op.type,
+ itemId: op.itemId || op.item_id,
+ itemName: op.itemName || op.item_name || op.itemName,
+ quantity: Number(op.quantity || 0),
+ employeeId: op.employeeId || op.employee_id,
+ employeeName: op.employeeName || op.employee_name,
+ date: op.date,
+ status: op.status || 'completed',
+ supplier: op.supplier || '',
+ recipient: op.recipient || '',
+ notes: op.notes || ''
+ })).sort((a, b) => new Date(b.date) - new Date(a.date));
+ this.saveOperations();
+ }
+
+ // Поиск товаров
+ async searchItems(query, filters = {}) {
+ let results = [...this.items];
+
+ // Поиск по названию и описанию
+ if (query) {
+ const searchQuery = query.toLowerCase();
+ results = results.filter(item =>
+ item.name.toLowerCase().includes(searchQuery) ||
+ item.description.toLowerCase().includes(searchQuery) ||
+ item.barcode.includes(searchQuery)
+ );
+ }
+
+ // Фильтр по категории
+ if (filters.category) {
+ results = results.filter(item => item.category === filters.category);
+ }
+
+ // Фильтр по цене
+ if (filters.minPrice) {
+ results = results.filter(item => item.price >= filters.minPrice);
+ }
+
+ if (filters.maxPrice) {
+ results = results.filter(item => item.price <= filters.maxPrice);
+ }
+
+ // Фильтр по наличию
+ if (filters.inStock) {
+ results = results.filter(item => item.quantity > 0);
+ }
+
+ if (filters.lowStock) {
+ results = results.filter(item => item.quantity <= item.minQuantity);
+ }
+
+ return results;
+ }
+
+ // Получение товаров с низким запасом
+ async getLowStockItems() {
+ return this.items.filter(item => item.quantity <= item.minQuantity);
+ }
+
+ // Проверка и отправка уведомлений
+ async checkAndSendNotifications(operation = null) {
+ try {
+ const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
+ const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
+
+ // Проверяем оповещения о низком остатке
+ if (settings.lowStockAlerts) {
+ await this.checkLowStockAlerts(currentUser);
+ }
+
+ // Проверяем email уведомления об операциях
+ if (settings.emailNotifications && operation) {
+ await this.sendOperationEmailNotification(operation, currentUser);
+ }
+
+ } catch (error) {
+ console.error('Ошибка при отправке уведомлений:', error);
+ }
+ }
+
+ // Проверка товаров с низким запасом
+ async checkLowStockAlerts(user) {
+ try {
+ const lowStockItems = await this.getLowStockItems();
+
+ if (lowStockItems.length > 0) {
+ // Показываем уведомление в интерфейсе
+ const itemNames = lowStockItems.map(item => item.name).join(', ');
+ auth.showNotification(
+ `Внимание! Товары с низким запасом: ${itemNames}`,
+ 'warning'
+ );
+
+ // Отправляем email уведомление через API
+ await this.sendLowStockEmailNotification(lowStockItems);
+ }
+ } catch (error) {
+ console.error('Ошибка при проверке низкого остатка:', error);
+ }
+ }
+
+ // Отправка уведомления об операции
+ async sendOperationEmailNotification(operation, user) {
+ try {
+ const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
+ const message = `Операция ${operationType}: ${operation.itemName} - ${operation.quantity} шт.`;
+
+ // Отправляем email уведомление через API
+ await this.sendOperationEmailNotificationAPI(operation);
+
+ // Показываем уведомление в интерфейсе
+ auth.showNotification(`Уведомление об операции отправлено на ${user.email}`, 'info');
+ } catch (error) {
+ console.error('Ошибка при отправке уведомления об операции:', error);
+ }
+ }
+
+ // Генерация и отправка еженедельных отчетов
+ async generateWeeklyReports() {
+ try {
+ const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
+ const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
+
+ if (settings.operationReports) {
+ const weekAgo = new Date();
+ weekAgo.setDate(weekAgo.getDate() - 7);
+
+ const weeklyOperations = this.operations.filter(op =>
+ new Date(op.date) >= weekAgo
+ );
+
+ if (weeklyOperations.length > 0) {
+ const report = {
+ period: 'За последнюю неделю',
+ totalOperations: weeklyOperations.length,
+ incoming: weeklyOperations.filter(op => op.type === 'incoming').length,
+ outgoing: weeklyOperations.filter(op => op.type === 'outgoing').length,
+ operations: weeklyOperations
+ };
+
+ // Отправляем еженедельный отчет через API
+ await this.sendWeeklyReportEmailNotification(report);
+
+ auth.showNotification('Еженедельный отчет отправлен на email', 'success');
+ }
+ }
+ } catch (error) {
+ console.error('Ошибка при генерации еженедельного отчета:', error);
+ }
+ }
+
+ // Получение категорий
+ async getCategories() {
+ try {
+ const rows = await this.apiRequest('/categories');
+ this.categories = (rows || []).map(r => ({
+ id: r.id,
+ name: r.name,
+ description: r.description || '',
+ icon: r.icon || 'fas fa-tag',
+ color: r.color || '#2c5aa0',
+ active: !!(r.active ?? 1),
+ itemCount: Number(r.itemCount || 0)
+ }));
+ this.saveCategories();
+ return this.categories;
+ } catch (e) {
+ return this.categories;
+ }
+ }
+
+ // Получение категории по ID
+ async getCategoryById(id) {
+ return this.categories.find(cat => cat.id === id);
+ }
+
+ // Добавление категории
+ async addCategory(categoryData) {
+ const newCategory = {
+ id: this.generateCategoryId(),
+ ...categoryData,
+ itemCount: 0,
+ createdAt: new Date().toISOString()
+ };
+
+ try {
+ const created = await this.apiRequest('/categories', { method: 'POST', body: newCategory });
+ if (created && created.id) newCategory.id = created.id;
+ } catch (e) {}
+
+ this.categories.push(newCategory);
+ this.saveCategories();
+ return newCategory;
+ }
+
+ // Обновление категории
+ async updateCategory(id, categoryData) {
+ const categoryIndex = this.categories.findIndex(cat => cat.id === id);
+ if (categoryIndex === -1) {
+ throw new Error('Категория не найдена');
+ }
+
+ try { await this.apiRequest(`/categories/${id}`, { method: 'PUT', body: categoryData }); } catch (e) {}
+
+ this.categories[categoryIndex] = {
+ ...this.categories[categoryIndex],
+ ...categoryData,
+ updatedAt: new Date().toISOString()
+ };
+
+ this.saveCategories();
+ return this.categories[categoryIndex];
+ }
+
+ // Удаление категории
+ async deleteCategory(id) {
+ const categoryIndex = this.categories.findIndex(cat => cat.id === id);
+ if (categoryIndex === -1) {
+ throw new Error('Категория не найдена');
+ }
+
+ try { await this.apiRequest(`/categories/${id}`, { method: 'DELETE' }); } catch (e) {}
+
+ // Удаляем все позиции этой категории
+ this.items = this.items.filter(item => item.category !== id);
+ this.saveItems();
+
+ // Удаляем категорию
+ this.categories.splice(categoryIndex, 1);
+ this.saveCategories();
+
+ // Обновляем количество позиций в остальных категориях
+ this.updateCategoryItemCounts();
+
+ return true;
+ }
+
+ // Генерация следующего ID для товара
+ getNextItemId() {
+ const maxId = Math.max(...this.items.map(item => item.id), 0);
+ return maxId + 1;
+ }
+
+ // Генерация следующего ID для операции
+ getNextOperationId() {
+ const maxId = Math.max(...this.operations.map(op => op.id), 0);
+ return maxId + 1;
+ }
+
+ // Генерация ID для категории
+ generateCategoryId() {
+ return 'category_' + Date.now();
+ }
+
+ // Экспорт данных
+ async exportData(type = 'items') {
+ switch (type) {
+ case 'items':
+ return this.exportItems();
+ case 'operations':
+ return this.exportOperations();
+ case 'statistics':
+ return this.exportStatistics();
+ default:
+ throw new Error('Неизвестный тип экспорта');
+ }
+ }
+
+ // Экспорт товаров
+ exportItems() {
+ const headers = ['ID', 'Название', 'Категория', 'Цена', 'Количество', 'Описание', 'Штрих-код', 'Местоположение'];
+ const rows = this.items.map(item => [
+ item.id,
+ item.name,
+ this.getCategoryName(item.category),
+ item.price,
+ item.quantity,
+ item.description,
+ item.barcode,
+ item.location
+ ]);
+
+ return { headers, rows };
+ }
+
+ // Экспорт операций
+ exportOperations() {
+ const headers = ['ID', 'Дата', 'Тип', 'Товар', 'Количество', 'Сотрудник', 'Статус', 'Примечания'];
+ const rows = this.operations.map(op => [
+ op.id,
+ new Date(op.date).toLocaleDateString('ru-RU'),
+ op.type === 'incoming' ? 'Приход' : 'Расход',
+ op.itemName,
+ op.quantity,
+ op.employeeName,
+ op.status === 'completed' ? 'Завершено' : 'В обработке',
+ op.notes
+ ]);
+
+ return { headers, rows };
+ }
+
+ // Экспорт статистики
+ exportStatistics() {
+ const stats = this.getStatistics();
+ return {
+ headers: ['Показатель', 'Значение'],
+ rows: [
+ ['Всего товаров', stats.totalItems],
+ ['Общая стоимость', stats.totalValue + ' ₽'],
+ ['Товары с низким запасом', stats.lowStockItems],
+ ['Операций сегодня', stats.todayOperations],
+ ['Всего категорий', stats.totalCategories],
+ ['Всего операций', stats.totalOperations]
+ ]
+ };
+ }
+
+ // Получение названия категории
+ getCategoryName(categoryId) {
+ const category = this.categories.find(cat => cat.id === categoryId);
+ return category ? category.name : 'Неизвестная категория';
+ }
+
+ // Получение отчета по движению товаров
+ async getMovementReport(startDate, endDate, itemId = null) {
+ let operations = this.operations.filter(op => {
+ const opDate = new Date(op.date);
+ return opDate >= new Date(startDate) && opDate <= new Date(endDate);
+ });
+
+ if (itemId) {
+ operations = operations.filter(op => op.itemId === parseInt(itemId));
+ }
+
+ const report = {
+ period: { startDate, endDate },
+ totalIncoming: operations.filter(op => op.type === 'incoming').length,
+ totalOutgoing: operations.filter(op => op.type === 'outgoing').length,
+ totalIncomingQuantity: operations
+ .filter(op => op.type === 'incoming')
+ .reduce((sum, op) => sum + op.quantity, 0),
+ totalOutgoingQuantity: operations
+ .filter(op => op.type === 'outgoing')
+ .reduce((sum, op) => sum + op.quantity, 0),
+ operations: operations
+ };
+
+ return report;
+ }
+}
+
+// Экспорт для использования в других модулях
window.WarehouseManager = WarehouseManager;
\ No newline at end of file
diff --git a/profile.html b/profile.html
index 3ee62ed..c0ced83 100644
--- a/profile.html
+++ b/profile.html
@@ -1,430 +1,416 @@
-
-
-
-
-
- Профиль - Склад-Менеджер
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Разрешения
-
Загрузка...
-
-
-
Дата регистрации
-
Загрузка...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Дата |
- Тип |
- Товар |
- Количество |
- Статус |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Интерфейс
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Профиль - Склад-Менеджер
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Разрешения
+
Загрузка...
+
+
+
Дата регистрации
+
Загрузка...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Дата |
+ Тип |
+ Товар |
+ Количество |
+ Статус |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/env.email.example b/server/env.email.example
new file mode 100644
index 0000000..123a8cf
--- /dev/null
+++ b/server/env.email.example
@@ -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
diff --git a/server/env.production.example b/server/env.production.example
new file mode 100644
index 0000000..853b271
--- /dev/null
+++ b/server/env.production.example
@@ -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
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..4f5bf0c
--- /dev/null
+++ b/server/package.json
@@ -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"
+ }
+}
+
diff --git a/server/scripts/migrate.js b/server/scripts/migrate.js
new file mode 100644
index 0000000..c97a583
--- /dev/null
+++ b/server/scripts/migrate.js
@@ -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); });
+
+
diff --git a/server/scripts/seed.js b/server/scripts/seed.js
new file mode 100644
index 0000000..50f0a0a
--- /dev/null
+++ b/server/scripts/seed.js
@@ -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); });
+
+
diff --git a/server/sql/schema.sql b/server/sql/schema.sql
new file mode 100644
index 0000000..dcaf526
--- /dev/null
+++ b/server/sql/schema.sql
@@ -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
+);
+
+
diff --git a/server/sql/seed.sql b/server/sql/seed.sql
new file mode 100644
index 0000000..3d952f3
--- /dev/null
+++ b/server/sql/seed.sql
@@ -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');
+
diff --git a/server/src/config/db.js b/server/src/config/db.js
new file mode 100644
index 0000000..e4beb9b
--- /dev/null
+++ b/server/src/config/db.js
@@ -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;
+
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
new file mode 100644
index 0000000..b136864
--- /dev/null
+++ b/server/src/routes/auth.js
@@ -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;
+
diff --git a/server/src/routes/categories.js b/server/src/routes/categories.js
new file mode 100644
index 0000000..2eab677
--- /dev/null
+++ b/server/src/routes/categories.js
@@ -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;
+
diff --git a/server/src/routes/items.js b/server/src/routes/items.js
new file mode 100644
index 0000000..dc558c9
--- /dev/null
+++ b/server/src/routes/items.js
@@ -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;
+
diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js
new file mode 100644
index 0000000..9b065a6
--- /dev/null
+++ b/server/src/routes/notifications.js
@@ -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: `
+
+
Тестовое уведомление
+
${message}
+
+
+ Это тестовое уведомление от системы управления складом.
+ Дата: ${new Date().toLocaleString('ru-RU')}
+
+
+ `
+ });
+
+ 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;
diff --git a/server/src/routes/operations.js b/server/src/routes/operations.js
new file mode 100644
index 0000000..5ddfc63
--- /dev/null
+++ b/server/src/routes/operations.js
@@ -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;
+
diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js
new file mode 100644
index 0000000..cfe6ad3
--- /dev/null
+++ b/server/src/routes/reports.js
@@ -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;
diff --git a/server/src/routes/stats.js b/server/src/routes/stats.js
new file mode 100644
index 0000000..aa1b956
--- /dev/null
+++ b/server/src/routes/stats.js
@@ -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;
+
diff --git a/server/src/routes/users.js b/server/src/routes/users.js
new file mode 100644
index 0000000..0de4b73
--- /dev/null
+++ b/server/src/routes/users.js
@@ -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;
diff --git a/server/src/server.js b/server/src/server.js
new file mode 100644
index 0000000..628d8ef
--- /dev/null
+++ b/server/src/server.js
@@ -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}`);
+});
+
+
diff --git a/server/src/services/emailService.js b/server/src/services/emailService.js
new file mode 100644
index 0000000..76be9f3
--- /dev/null
+++ b/server/src/services/emailService.js
@@ -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: `
+
+
⚠️ Внимание! Товары с низким запасом
+
Здравствуйте, ${user.name}!
+
Система обнаружила товары с низким запасом на складе:
+
+
Рекомендуется:
+
+ - Проверить остатки на складе
+ - Оформить заказ на пополнение
+ - Связаться с поставщиками
+
+
+
+ Это автоматическое уведомление от системы управления складом.
+ Дата: ${new Date().toLocaleString('ru-RU')}
+
+
+ `
+ };
+
+ 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: `
+
+
📦 Операция: ${operationType}
+
Здравствуйте, ${user.name}!
+
Была выполнена операция на складе:
+
+
+
+
+ | Товар: |
+ ${operation.itemName} |
+
+
+ | Количество: |
+ ${operation.quantity} шт. |
+
+
+ | Тип операции: |
+ ${operationType} |
+
+
+ | Дата: |
+ ${new Date(operation.date).toLocaleString('ru-RU')} |
+
+
+ | Сотрудник: |
+ ${operation.employeeName} |
+
+
+
+
+
+
+ Это автоматическое уведомление от системы управления складом.
+ Дата: ${new Date().toLocaleString('ru-RU')}
+
+
+ `
+ };
+
+ return await this.sendEmail(emailContent);
+ } catch (error) {
+ console.error('Ошибка отправки уведомления об операции:', error);
+ throw error;
+ }
+ }
+
+ // Отправка еженедельного отчета
+ async sendWeeklyReport(user, report) {
+ try {
+ const emailContent = {
+ to: user.email,
+ subject: '📊 Еженедельный отчет по операциям склада',
+ html: `
+
+
📊 Еженедельный отчет
+
Здравствуйте, ${user.name}!
+
Представляем вашему вниманию еженедельный отчет по операциям склада:
+
+
+
Статистика за неделю:
+
+
+ | Показатель |
+ Значение |
+
+
+ | Всего операций |
+ ${report.totalOperations} |
+
+
+ | Приходы |
+ ${report.incoming} |
+
+
+ | Расходы |
+ ${report.outgoing} |
+
+
+
+
+ ${report.operations.length > 0 ? `
+
+
Последние операции:
+
+
+ | Дата |
+ Тип |
+ Товар |
+ Кол-во |
+
+ ${report.operations.slice(0, 10).map(op => `
+
+ | ${new Date(op.date).toLocaleDateString('ru-RU')} |
+ ${op.type === 'incoming' ? 'Приход' : 'Расход'} |
+ ${op.itemName} |
+ ${op.quantity} |
+
+ `).join('')}
+
+
+ ` : ''}
+
+
+
+ Это автоматический еженедельный отчет от системы управления складом.
+ Дата формирования: ${new Date().toLocaleString('ru-RU')}
+
+
+ `
+ };
+
+ 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();