From 558cd96cdf35fcb74301bec8ca27452e225cae35 Mon Sep 17 00:00:00 2001 From: cyrteL <167682858+cyrteL@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:26:43 +0300 Subject: [PATCH] Final version --- DEPLOYMENT.md | 367 +++++++ README.md | 123 +-- admin/email-test.html | 420 ++++++++ admin/reports.html | 173 ++++ admin/users.html | 570 +++++++++++ js/auth.js | 16 +- js/catalog.js | 3 + js/main.js | 9 +- js/profile.js | 1172 +++++++++++----------- js/reports.js | 194 ++++ js/users.js | 791 +++++++++++++++ js/warehouse.js | 1429 ++++++++++++++++----------- profile.html | 844 ++++++++-------- server/env.email.example | 31 + server/env.production.example | 30 + server/package.json | 33 + server/scripts/migrate.js | 26 + server/scripts/seed.js | 26 + server/sql/schema.sql | 83 ++ server/sql/seed.sql | 43 + server/src/config/db.js | 18 + server/src/routes/auth.js | 47 + server/src/routes/categories.js | 46 + server/src/routes/items.js | 54 + server/src/routes/notifications.js | 199 ++++ server/src/routes/operations.js | 80 ++ server/src/routes/reports.js | 142 +++ server/src/routes/stats.js | 19 + server/src/routes/users.js | 522 ++++++++++ server/src/server.js | 55 ++ server/src/services/emailService.js | 248 +++++ 31 files changed, 6181 insertions(+), 1632 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 admin/email-test.html create mode 100644 admin/reports.html create mode 100644 admin/users.html create mode 100644 js/reports.js create mode 100644 js/users.js create mode 100644 server/env.email.example create mode 100644 server/env.production.example create mode 100644 server/package.json create mode 100644 server/scripts/migrate.js create mode 100644 server/scripts/seed.js create mode 100644 server/sql/schema.sql create mode 100644 server/sql/seed.sql create mode 100644 server/src/config/db.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/categories.js create mode 100644 server/src/routes/items.js create mode 100644 server/src/routes/notifications.js create mode 100644 server/src/routes/operations.js create mode 100644 server/src/routes/reports.js create mode 100644 server/src/routes/stats.js create mode 100644 server/src/routes/users.js create mode 100644 server/src/server.js create mode 100644 server/src/services/emailService.js 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-сервиса +
+
+
+
+
+ Загрузка... +
+

Проверка статуса...

+
+
+
+ + +
+
+
+ + Тестовая отправка 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 @@ + + + + + + Отчеты - Склад-Менеджер + + + + + + + +
+
+
+

Отчеты

+
Статистика, операции и остатки за выбранный период
+
+
+ + + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+
Приход всего
0
+
+
+
Расход всего
0
+
+
+
Операций
0
+
+
+
Уникальных товаров
0
+
+
+ +
+
Операции
+
+ + + + + + + + + + + + +
ДатаТипТоварКоличествоСотрудникКатегория
+
+
+ +
+
+
+
Остатки по категориям
+
+ + + +
КатегорияВсего позицийСуммарный остаток
+
+
+
+
+
+
ТОП товаров по движению
+
+ + + +
ТоварПриходРасходИтого
+
+
+
+
+ + +
+ + + + + + + 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

+

Всего пользователей

+
+
+ +
+
+
+
+
+
+
+
+
+
+

0

+

Активных

+
+
+ +
+
+
+
+
+
+
+
+
+
+

0

+

Администраторов

+
+
+ +
+
+
+
+
+
+
+
+
+
+

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 = ` - - `; - 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 = ` + + `; + 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 @@ - - - - - - Профиль - Склад-Менеджер - - - - - - - - - - - -
    -
    -
    -
    - -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    -
    -

    Загрузка...

    -

    Загрузка...

    -

    Загрузка...

    -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    - - Информация -
    -
    -
    -
    - Email -
    Загрузка...
    -
    -
    - Роли -
    Загрузка...
    -
    -
    - Разрешения -
    Загрузка...
    -
    -
    - Дата регистрации -
    Загрузка...
    -
    -
    -
    - - -
    -
    -
    - - Быстрые действия -
    -
    -
    -
    - - - -
    -
    -
    -
    - - -
    - -
    -
    -
    -
    -
    -
    -

    0

    -

    Всего операций

    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    0

    -

    В этом месяце

    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    0

    -

    Сегодня

    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    -

    -

    Последний вход

    -
    - -
    -
    -
    -
    -
    - - -
    -
    -
    - - Мои последние операции -
    -
    -
    -
    - - - - - - - - - - - - - -
    ДатаТипТоварКоличествоСтатус
    -
    -
    -
    - - -
    -
    -
    - - Настройки -
    -
    -
    -
    -
    -
    Уведомления
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    -
    Интерфейс
    -
    - - -
    -
    - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - - + + + + + + Профиль - Склад-Менеджер + + + + + + + + + + + +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +

    Загрузка...

    +

    Загрузка...

    +

    Загрузка...

    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    + + Информация +
    +
    +
    +
    + Email +
    Загрузка...
    +
    +
    + Роли +
    Загрузка...
    +
    +
    + Разрешения +
    Загрузка...
    +
    +
    + Дата регистрации +
    Загрузка...
    +
    +
    +
    + + +
    +
    +
    + + Быстрые действия +
    +
    +
    +
    + + + + +
    +
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +

    0

    +

    Всего операций

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    0

    +

    В этом месяце

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    0

    +

    Сегодня

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    -

    +

    Последний вход

    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    + + Мои последние операции +
    +
    +
    +
    + + + + + + + + + + + + + +
    ДатаТипТоварКоличествоСтатус
    +
    +
    +
    + + +
    +
    +
    + + Настройки +
    +
    +
    +
    +
    +
    Уведомления
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + \ 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}!

    +

    Система обнаружила товары с низким запасом на складе:

    +
    +
    ${itemList}
    +
    +

    Рекомендуется:

    + +
    +

    + Это автоматическое уведомление от системы управления складом.
    + Дата: ${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 => ` + + + + + + + `).join('')} +
    ДатаТипТоварКол-во
    ${new Date(op.date).toLocaleDateString('ru-RU')}${op.type === 'incoming' ? 'Приход' : 'Расход'}${op.itemName}${op.quantity}
    +
    + ` : ''} + +
    +

    + Это автоматический еженедельный отчет от системы управления складом.
    + Дата формирования: ${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();