Compare commits
10 Commits
90d2362def
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e04bb08f2f | |||
| 558cd96cdf | |||
| b4c1a2caa9 | |||
| e572c34e99 | |||
| bd7afd54b5 | |||
| 4a6868ced6 | |||
| 119a74ad98 | |||
| ff8626e651 | |||
| addf79e58f | |||
| 5d61e5982c |
367
DEPLOYMENT.md
Normal file
367
DEPLOYMENT.md
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
# 🚀 Инструкции по развертыванию в продакшн
|
||||||
|
|
||||||
|
## 📋 Предварительные требования
|
||||||
|
|
||||||
|
### Системные требования
|
||||||
|
- **Node.js** 18+ LTS
|
||||||
|
- **MySQL** 8.0+
|
||||||
|
- **Nginx** (рекомендуется для прокси)
|
||||||
|
- **PM2** (для управления процессами)
|
||||||
|
|
||||||
|
### Минимальные характеристики сервера
|
||||||
|
- **CPU**: 2 ядра
|
||||||
|
- **RAM**: 4 GB
|
||||||
|
- **Диск**: 20 GB SSD
|
||||||
|
- **Сеть**: 100 Mbps
|
||||||
|
|
||||||
|
## 🔧 Установка и настройка
|
||||||
|
|
||||||
|
### 1. Подготовка сервера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Обновление системы
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Установка Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Установка MySQL
|
||||||
|
sudo apt install mysql-server -y
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
|
||||||
|
# Установка PM2
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# Установка Nginx
|
||||||
|
sudo apt install nginx -y
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка базы данных
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Подключение к MySQL
|
||||||
|
sudo mysql
|
||||||
|
|
||||||
|
-- Создание базы данных
|
||||||
|
CREATE DATABASE warehouse_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Создание пользователя
|
||||||
|
CREATE USER 'warehouse_user'@'localhost' IDENTIFIED BY 'YOUR_STRONG_PASSWORD';
|
||||||
|
GRANT ALL PRIVILEGES ON warehouse_manager.* TO 'warehouse_user'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонирование проекта
|
||||||
|
git clone https://github.com/your-repo/warehouse-manager.git
|
||||||
|
cd warehouse-manager
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
cd server
|
||||||
|
npm install --production
|
||||||
|
|
||||||
|
# Создание production конфигурации
|
||||||
|
cp env.production.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Конфигурация .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Production Environment Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=warehouse_user
|
||||||
|
DB_PASSWORD=YOUR_STRONG_PASSWORD
|
||||||
|
DB_NAME=warehouse_manager
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
JWT_SECRET=YOUR_VERY_LONG_RANDOM_SECRET_KEY_HERE
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=https://your-domain.com
|
||||||
|
CORS_CREDENTIALS=true
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CONNECTION_LIMIT=20
|
||||||
|
QUEUE_LIMIT=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Инициализация базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание таблиц
|
||||||
|
npm run migrate
|
||||||
|
|
||||||
|
# Заполнение тестовыми данными
|
||||||
|
npm run seed
|
||||||
|
|
||||||
|
# ИЛИ полная настройка
|
||||||
|
npm run setup:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Настройка PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание PM2 конфигурации
|
||||||
|
cat > ecosystem.config.js << EOF
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'warehouse-api',
|
||||||
|
script: 'src/server.js',
|
||||||
|
instances: 'max',
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3001
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_file: './logs/combined.log',
|
||||||
|
time: true,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
restart_delay: 4000,
|
||||||
|
max_restarts: 10
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Создание директории для логов
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# Запуск приложения
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Настройка Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание конфигурации
|
||||||
|
sudo nano /etc/nginx/sites-available/warehouse-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# Редирект на HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# SSL сертификаты (Let's Encrypt)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# SSL настройки
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# Статические файлы фронтенда
|
||||||
|
location / {
|
||||||
|
root /var/www/warehouse-manager;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Кэширование
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# API прокси
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Активация конфигурации
|
||||||
|
sudo ln -s /etc/nginx/sites-available/warehouse-manager /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. SSL сертификат (Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка Certbot
|
||||||
|
sudo apt install certbot python3-certbot-nginx -y
|
||||||
|
|
||||||
|
# Получение сертификата
|
||||||
|
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# Автоматическое обновление
|
||||||
|
sudo crontab -e
|
||||||
|
# Добавить строку: 0 12 * * * /usr/bin/certbot renew --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Развертывание фронтенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Копирование файлов
|
||||||
|
sudo cp -r . /var/www/warehouse-manager/
|
||||||
|
sudo chown -R www-data:www-data /var/www/warehouse-manager/
|
||||||
|
sudo chmod -R 755 /var/www/warehouse-manager/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
### Обязательные меры безопасности
|
||||||
|
|
||||||
|
1. **Измените пароли по умолчанию:**
|
||||||
|
```sql
|
||||||
|
UPDATE users SET password_hash = 'NEW_HASHED_PASSWORD' WHERE username = 'admin';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте файрвол:**
|
||||||
|
```bash
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw allow ssh
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw deny 3001 # Блокируем прямой доступ к API
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Регулярные обновления:**
|
||||||
|
```bash
|
||||||
|
# Автоматические обновления безопасности
|
||||||
|
sudo apt install unattended-upgrades
|
||||||
|
sudo dpkg-reconfigure -plow unattended-upgrades
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Мониторинг логов:**
|
||||||
|
```bash
|
||||||
|
# Просмотр логов приложения
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
|
||||||
|
# Просмотр логов Nginx
|
||||||
|
sudo tail -f /var/log/nginx/access.log
|
||||||
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Мониторинг и обслуживание
|
||||||
|
|
||||||
|
### PM2 команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус приложений
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Перезапуск
|
||||||
|
pm2 restart warehouse-api
|
||||||
|
|
||||||
|
# Обновление кода
|
||||||
|
pm2 reload warehouse-api
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
|
||||||
|
# Мониторинг ресурсов
|
||||||
|
pm2 monit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Резервное копирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скрипт резервного копирования
|
||||||
|
cat > backup.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="/backup/warehouse-manager"
|
||||||
|
|
||||||
|
# Создание резервной копии БД
|
||||||
|
mysqldump -u warehouse_user -p warehouse_manager > $BACKUP_DIR/db_$DATE.sql
|
||||||
|
|
||||||
|
# Сжатие
|
||||||
|
gzip $BACKUP_DIR/db_$DATE.sql
|
||||||
|
|
||||||
|
# Удаление старых резервных копий (старше 30 дней)
|
||||||
|
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/db_$DATE.sql.gz"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x backup.sh
|
||||||
|
|
||||||
|
# Добавление в cron (ежедневно в 2:00)
|
||||||
|
echo "0 2 * * * /path/to/backup.sh" | crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Устранение неполадок
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
1. **Приложение не запускается:**
|
||||||
|
```bash
|
||||||
|
pm2 logs warehouse-api
|
||||||
|
tail -f logs/err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ошибки подключения к БД:**
|
||||||
|
```bash
|
||||||
|
mysql -u warehouse_user -p warehouse_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проблемы с Nginx:**
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Проблемы с SSL:**
|
||||||
|
```bash
|
||||||
|
sudo certbot certificates
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
При возникновении проблем:
|
||||||
|
1. Проверьте логи: `pm2 logs warehouse-api`
|
||||||
|
2. Проверьте статус сервисов: `pm2 status`, `sudo systemctl status nginx`
|
||||||
|
3. Проверьте конфигурацию: `nginx -t`, `node -c src/server.js`
|
||||||
292
README.md
292
README.md
@ -1,17 +1,29 @@
|
|||||||
# Система управления складом "Склад-Менеджер"
|
# Система управления складом "Склад-Менеджер"
|
||||||
|
|
||||||
Современная веб-система для управления товарными запасами и операциями склада с разными уровнями доступа.
|
Современная веб-система для управления складом деталей и операциями с разными уровнями доступа.
|
||||||
|
|
||||||
## 🚀 Возможности системы
|
## 🚀 Возможности системы
|
||||||
|
|
||||||
### Основной функционал
|
### Основной функционал
|
||||||
- **Управление товарами**: добавление, редактирование, удаление товаров
|
- **Управление позициями**: добавление, редактирование, удаление позиций
|
||||||
- **Операции склада**: приход и расход товаров
|
- **Операции склада**: приход и расход позиций
|
||||||
- **Каталог товаров**: удобный просмотр с фильтрами и поиском
|
- **Каталог позиций**: удобный просмотр с фильтрами и поиском
|
||||||
- **История операций**: полный учет всех операций со складом
|
- **История операций**: полный учет всех операций со складом
|
||||||
- **Поиск**: быстрый поиск товаров и операций
|
- **Поиск**: быстрый поиск позиций и операций
|
||||||
- **Отчеты**: генерация различных отчетов
|
- **Отчеты**: генерация различных отчетов
|
||||||
- **Экспорт данных**: выгрузка в CSV и JSON форматах
|
- **Экспорт данных**: выгрузка в CSV и JSON форматах
|
||||||
|
- **Настраиваемые категории**: полный CRUD для категорий позиций
|
||||||
|
- **Hover-навигация**: выпадающее меню категорий при наведении
|
||||||
|
|
||||||
|
### Система безопасности
|
||||||
|
- **Многоуровневая авторизация** с разными ролями
|
||||||
|
- **Контроль доступа** к функциям системы
|
||||||
|
- **Безопасные сессии** пользователей
|
||||||
|
- **Валидация данных** на всех уровнях
|
||||||
|
- **Проверка прав доступа** для каждой операции
|
||||||
|
- **Безопасная обработка ошибок** с информативными уведомлениями
|
||||||
|
- **URL валидация** параметров и фильтров
|
||||||
|
- **Автоматическое скрытие элементов** в зависимости от ролей
|
||||||
|
|
||||||
### Уровни доступа
|
### Уровни доступа
|
||||||
|
|
||||||
@ -20,21 +32,25 @@
|
|||||||
- Управление пользователями
|
- Управление пользователями
|
||||||
- Настройка системы
|
- Настройка системы
|
||||||
- Просмотр всех отчетов
|
- Просмотр всех отчетов
|
||||||
|
- Удаление позиций и категорий
|
||||||
|
|
||||||
#### 👔 Менеджер (manager/manager123)
|
#### 👔 Менеджер (manager/manager123)
|
||||||
- Чтение и запись данных
|
- Чтение и запись данных
|
||||||
- Управление товарами
|
- Управление товарами
|
||||||
- Оформление операций
|
- Оформление операций
|
||||||
- Просмотр отчетов
|
- Просмотр отчетов
|
||||||
|
- Создание и редактирование категорий
|
||||||
|
|
||||||
#### 👷 Оператор (operator/operator123)
|
#### 👷 Оператор (operator/operator123)
|
||||||
- Только чтение данных
|
- Только чтение данных
|
||||||
- Просмотр каталога и истории
|
- Просмотр каталога и истории
|
||||||
|
- Экспорт данных
|
||||||
- Ограниченный доступ к функциям
|
- Ограниченный доступ к функциям
|
||||||
|
|
||||||
#### 👁️ Наблюдатель (viewer/viewer123)
|
#### 👁️ Наблюдатель (viewer/viewer123)
|
||||||
- Ограниченный просмотр
|
- Ограниченный просмотр
|
||||||
- Только чтение данных
|
- Только чтение данных
|
||||||
|
- Экспорт данных
|
||||||
- Минимальный доступ
|
- Минимальный доступ
|
||||||
|
|
||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
@ -47,32 +63,27 @@ warehouse-system/
|
|||||||
├── history.html # История операций
|
├── history.html # История операций
|
||||||
├── search.html # Поиск
|
├── search.html # Поиск
|
||||||
├── profile.html # Профиль пользователя
|
├── profile.html # Профиль пользователя
|
||||||
|
├── admin/
|
||||||
|
│ ├── categories.html # Управление категориями
|
||||||
|
│ └── users.html # Управление пользователями
|
||||||
├── css/
|
├── css/
|
||||||
│ └── style.css # Основные стили
|
│ └── style.css # Основные стили
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── auth.js # Система авторизации
|
│ ├── auth.js # Система авторизации и прав доступа
|
||||||
│ ├── main.js # Основная логика
|
│ ├── main.js # Основная логика
|
||||||
│ ├── warehouse.js # Управление складом
|
│ ├── warehouse.js # Управление складом
|
||||||
│ ├── catalog.js # Каталог товаров
|
│ ├── catalog.js # Каталог товаров
|
||||||
│ ├── history.js # История операций
|
│ ├── history.js # История операций
|
||||||
│ ├── search.js # Поиск
|
│ ├── search.js # Поиск
|
||||||
│ └── profile.js # Профиль пользователя
|
│ ├── profile.js # Профиль пользователя
|
||||||
|
│ ├── categories.js # Управление категориями
|
||||||
|
│ └── users.js # Управление пользователями
|
||||||
└── README.md # Документация
|
└── README.md # Документация
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Технологии
|
## 🚀 Старт
|
||||||
|
|
||||||
- **HTML5** - семантическая разметка
|
### Для разработки/демо
|
||||||
- **CSS3** - современные стили с CSS переменными
|
|
||||||
- **Bootstrap 5** - адаптивный дизайн
|
|
||||||
- **JavaScript (ES6+)** - интерактивность
|
|
||||||
- **jQuery** - упрощение работы с DOM
|
|
||||||
- **Font Awesome** - иконки
|
|
||||||
- **LocalStorage** - локальное хранение данных
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
|
||||||
|
|
||||||
### 1. Первый вход
|
|
||||||
1. Откройте `login.html`
|
1. Откройте `login.html`
|
||||||
2. Используйте тестовые учетные записи:
|
2. Используйте тестовые учетные записи:
|
||||||
- **Администратор**: `admin` / `admin123`
|
- **Администратор**: `admin` / `admin123`
|
||||||
@ -80,24 +91,51 @@ warehouse-system/
|
|||||||
- **Оператор**: `operator` / `operator123`
|
- **Оператор**: `operator` / `operator123`
|
||||||
- **Наблюдатель**: `viewer` / `viewer123`
|
- **Наблюдатель**: `viewer` / `viewer123`
|
||||||
|
|
||||||
|
### Настройка Email-уведомлений
|
||||||
|
1. Скопируйте `server/env.email.example` в `server/.env`
|
||||||
|
2. Настройте параметры SMTP-сервера:
|
||||||
|
- Для Gmail: используйте App Password
|
||||||
|
- Для Yandex/Mail.ru: используйте обычный пароль
|
||||||
|
3. Перезапустите сервер
|
||||||
|
4. Протестируйте отправку в разделе "Тест Email" (только для администраторов)
|
||||||
|
|
||||||
|
### Для продакшна
|
||||||
|
Система полностью готова к развертыванию в продакшн! См. подробные инструкции:
|
||||||
|
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - пошаговое руководство по развертыванию
|
||||||
|
|
||||||
### 2. Начальная настройка
|
### 2. Начальная настройка
|
||||||
1. Войдите как администратор
|
1. Войдите как администратор
|
||||||
2. Добавьте первые товары через главную страницу
|
2. Создайте категории позиций через "Администрирование" → "Категории позиций"
|
||||||
3. Настройте категории товаров
|
3. Добавьте первые позиции через "Каталог" → "Добавить позицию"
|
||||||
4. Создайте пользователей с нужными правами
|
4. Настройте права доступа для пользователей
|
||||||
|
|
||||||
## 📖 Руководство пользователя
|
## 📖 Руководство пользователя
|
||||||
|
|
||||||
### Главная страница
|
### Главная страница
|
||||||
- **Быстрые действия**: добавление товаров, приход/расход
|
- **Быстрые действия**: добавление товаров, приход/расход (согласно правам доступа)
|
||||||
- **Статистика**: общая информация о складе
|
- **Статистика**: общая информация о складе
|
||||||
- **Последние операции**: мониторинг активности
|
- **Последние операции**: мониторинг активности
|
||||||
|
- **Быстрая навигация**: выпадающее меню категорий при наведении
|
||||||
|
|
||||||
### Каталог товаров
|
### Hover-навигация по категориям
|
||||||
- **Просмотр**: все товары с фильтрами
|
- **Выпадающее меню**: появляется при наведении на "Каталог"
|
||||||
|
- **Плавная анимация**: плавное появление и исчезновение
|
||||||
|
- **Быстрый переход**: клик по категории открывает отфильтрованный каталог
|
||||||
|
- **URL фильтрация**: прямые ссылки вида `catalog.html?category=electronics`
|
||||||
|
- **Визуальная обратная связь**: иконки, цвета и количество позиций в категориях
|
||||||
|
- **Единообразие**: одинаковое меню на всех страницах
|
||||||
|
- **Умное поведение**: меню не закрывается при наведении на само меню
|
||||||
|
|
||||||
|
### Каталог позиций
|
||||||
|
- **Просмотр**: все позиции с фильтрами и статистикой
|
||||||
- **Поиск**: по названию, описанию, штрих-коду
|
- **Поиск**: по названию, описанию, штрих-коду
|
||||||
- **Фильтры**: по категории, цене, наличию
|
- **Фильтры**: по категории, цене, наличию, статусу запасов
|
||||||
- **Редактирование**: изменение информации о товарах
|
- **Редактирование**: изменение информации о позициях (только для админов и менеджеров)
|
||||||
|
- **Удаление**: удаление позиций (только для администраторов)
|
||||||
|
- **Экспорт**: выгрузка каталога в CSV формате
|
||||||
|
- **Безопасность**: проверка прав доступа для всех операций
|
||||||
|
- **Hover-навигация**: выпадающее меню категорий при наведении
|
||||||
|
- **URL фильтрация**: прямая ссылка на отфильтрованный каталог
|
||||||
|
|
||||||
### История операций
|
### История операций
|
||||||
- **Просмотр**: все операции с детализацией
|
- **Просмотр**: все операции с детализацией
|
||||||
@ -106,10 +144,11 @@ warehouse-system/
|
|||||||
- **Экспорт**: выгрузка данных
|
- **Экспорт**: выгрузка данных
|
||||||
|
|
||||||
### Поиск
|
### Поиск
|
||||||
- **Универсальный поиск**: товары и операции
|
- **Универсальный поиск**: позиции и операции
|
||||||
- **Расширенные фильтры**: точная настройка
|
- **Расширенные фильтры**: точная настройка
|
||||||
- **Результаты**: с подсветкой найденного
|
- **Результаты**: с подсветкой найденного
|
||||||
- **Экспорт результатов**: сохранение найденного
|
- **Экспорт результатов**: сохранение найденного
|
||||||
|
- **Hover-навигация**: переход к категориям из результатов поиска
|
||||||
|
|
||||||
### Профиль пользователя
|
### Профиль пользователя
|
||||||
- **Информация**: личные данные и права
|
- **Информация**: личные данные и права
|
||||||
@ -117,28 +156,41 @@ warehouse-system/
|
|||||||
- **Настройки**: персонализация интерфейса
|
- **Настройки**: персонализация интерфейса
|
||||||
- **Безопасность**: смена пароля
|
- **Безопасность**: смена пароля
|
||||||
|
|
||||||
## 🔧 Администрирование
|
### Администрирование
|
||||||
|
- **Управление категориями**: создание, редактирование, удаление категорий
|
||||||
|
- **Настройка иконок**: выбор иконок Font Awesome для категорий
|
||||||
|
- **Цветовая схема**: настройка цветов категорий
|
||||||
|
- **Активность категорий**: включение/отключение категорий
|
||||||
|
- **Управление пользователями**: полный CRUD для пользователей системы
|
||||||
|
- **Назначение ролей**: гибкая система ролей и разрешений
|
||||||
|
- **Статус пользователей**: активация/деактивация учетных записей
|
||||||
|
- **Отчеты**: фильтры по периоду, категории, сотруднику; экспорт CSV/JSON
|
||||||
|
|
||||||
### Управление пользователями
|
## 📊 Отчеты
|
||||||
1. Войдите как администратор
|
|
||||||
2. Перейдите в раздел "Администрирование"
|
|
||||||
3. Выберите "Управление пользователями"
|
|
||||||
4. Создавайте, редактируйте, удаляйте пользователей
|
|
||||||
|
|
||||||
### Настройка категорий
|
### Доступ
|
||||||
1. Откройте "Категории товаров"
|
Откройте "Администрирование" → "Отчеты" (`admin/reports.html`). Доступен пользователям с правами `admin` или `manager` (чтение).
|
||||||
2. Добавьте нужные категории
|
|
||||||
3. Настройте иконки и описания
|
|
||||||
|
|
||||||
### Генерация отчетов
|
### Фильтры
|
||||||
1. Выберите тип отчета
|
- Период: дата от/до
|
||||||
2. Укажите период
|
- Тип операции: приход/расход/все
|
||||||
3. Настройте параметры
|
- Категория: выбор категории
|
||||||
4. Экспортируйте в нужном формате
|
- Товар: название/штрихкод
|
||||||
|
- Сотрудник: логин или ФИО
|
||||||
|
|
||||||
|
### Разделы
|
||||||
|
- Сводка: суммарный приход, расход, количество операций, уникальные товары
|
||||||
|
- Операции: таблица операций с деталями
|
||||||
|
- Остатки по категориям: количество позиций и суммарный остаток
|
||||||
|
- ТОП товаров по движению: лидеры по приходу/расходу
|
||||||
|
|
||||||
|
### Экспорт
|
||||||
|
- CSV: экспорт таблицы операций
|
||||||
|
- JSON: экспорт всей структуры отчета (сводка, таблицы)
|
||||||
|
|
||||||
## 📊 Структура данных
|
## 📊 Структура данных
|
||||||
|
|
||||||
### Товар
|
### Позиция
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -151,8 +203,8 @@ warehouse-system/
|
|||||||
minQuantity: 10,
|
minQuantity: 10,
|
||||||
location: "Стеллаж A-1",
|
location: "Стеллаж A-1",
|
||||||
supplier: "Поставщик",
|
supplier: "Поставщик",
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
createdAt: "2025-08-15T10:00:00Z",
|
||||||
updatedAt: "2024-01-15T10:00:00Z"
|
updatedAt: "2025-08-15T10:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -166,7 +218,7 @@ warehouse-system/
|
|||||||
quantity: 10,
|
quantity: 10,
|
||||||
employeeId: 1,
|
employeeId: 1,
|
||||||
employeeName: "Имя сотрудника",
|
employeeName: "Имя сотрудника",
|
||||||
date: "2024-01-15T10:00:00Z",
|
date: "2025-08-15T10:00:00Z",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
supplier: "Поставщик",
|
supplier: "Поставщик",
|
||||||
recipient: "Получатель",
|
recipient: "Получатель",
|
||||||
@ -188,6 +240,22 @@ warehouse-system/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Категория
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: "electronics",
|
||||||
|
name: "Электроника",
|
||||||
|
description: "Электронные компоненты и устройства",
|
||||||
|
icon: "fas fa-microchip",
|
||||||
|
color: "#2c5aa0",
|
||||||
|
active: true,
|
||||||
|
itemCount: 25,
|
||||||
|
totalQuantity: 150,
|
||||||
|
createdAt: "2025-08-15T10:00:00Z",
|
||||||
|
updatedAt: "2025-08-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 🎨 Кастомизация
|
## 🎨 Кастомизация
|
||||||
|
|
||||||
### Изменение цветовой схемы
|
### Изменение цветовой схемы
|
||||||
@ -203,34 +271,88 @@ warehouse-system/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Добавление новых категорий
|
### Настройка hover-меню категорий
|
||||||
В файле `js/warehouse.js` найдите функцию `loadCategories()` и добавьте новые категории:
|
Для настройки стилей hover-меню отредактируйте CSS в `css/style.css`:
|
||||||
```javascript
|
```css
|
||||||
{
|
#catalogCategoriesDropdown {
|
||||||
id: 'new-category',
|
min-width: 280px;
|
||||||
name: 'Новая категория',
|
max-height: 500px;
|
||||||
icon: 'fas fa-icon-name'
|
overflow-y: auto;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover dropdown functionality */
|
||||||
|
.nav-item.dropdown:hover .dropdown-menu {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.dropdown:hover .dropdown-toggle::after {
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Настройка прав доступа
|
### Настройка прав доступа
|
||||||
В файле `js/auth.js` измените массив пользователей или добавьте новые роли и разрешения.
|
В файле `js/auth.js` измените массив пользователей или добавьте новые роли и разрешения. Система автоматически скрывает элементы в зависимости от ролей пользователя.
|
||||||
|
|
||||||
|
### Добавление новых категорий
|
||||||
|
Через интерфейс системы:
|
||||||
|
1. Войдите как администратор или менеджер
|
||||||
|
2. Перейдите в "Администрирование" → "Категории позиций"
|
||||||
|
3. Нажмите "Добавить категорию"
|
||||||
|
4. Заполните форму с иконкой, цветом и описанием
|
||||||
|
|
||||||
## 🔒 Безопасность
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
### Система управления правами доступа
|
||||||
|
- **Автоматическое скрытие элементов**: кнопки и функции скрываются согласно ролям
|
||||||
|
- **Проверка прав на сервере**: все операции проверяют права доступа
|
||||||
|
- **Безопасные сессии**: данные пользователя хранятся в LocalStorage
|
||||||
|
- **Валидация данных**: проверка всех входных данных
|
||||||
|
- **Информативные подсказки**: кнопки показывают требуемые права
|
||||||
|
|
||||||
|
### Production-готовность
|
||||||
|
- ✅ **Удалены отладочные логи** из фронтенда
|
||||||
|
- ✅ **Созданы production конфигурации** (.env файлы)
|
||||||
|
- ✅ **Настроена безопасность** (JWT, CORS, rate limiting)
|
||||||
|
- ✅ **Готовы инструкции** по развертыванию
|
||||||
|
|
||||||
### Рекомендации по безопасности
|
### Рекомендации по безопасности
|
||||||
1. **Измените пароли по умолчанию** при первом входе
|
1. **Измените пароли по умолчанию** при первом входе
|
||||||
2. **Используйте HTTPS** в продакшене
|
2. **Настройте JWT_SECRET** в production окружении
|
||||||
3. **Регулярно обновляйте** права доступа
|
3. **Включите HTTPS** с SSL сертификатом
|
||||||
4. **Ведите логи** всех операций
|
4. **Настройте файрвол** и rate limiting
|
||||||
5. **Делайте резервные копии** данных
|
5. **Регулярно обновляйте** права доступа
|
||||||
|
6. **Ведите логи** всех операций
|
||||||
|
7. **Делайте резервные копии** данных
|
||||||
|
|
||||||
### Настройка HTTPS
|
## 📈 Производительность
|
||||||
Для продакшена обязательно используйте SSL-сертификат:
|
|
||||||
```bash
|
### Оптимизация
|
||||||
# Пример с Let's Encrypt
|
- Используйте современный браузер
|
||||||
sudo certbot --nginx -d your-domain.com
|
- Включите кэширование
|
||||||
```
|
- Минимизируйте количество одновременных операций
|
||||||
|
- Регулярно очищайте старые данные
|
||||||
|
|
||||||
|
### Production-оптимизация
|
||||||
|
- ✅ **Удалены отладочные логи** - улучшена производительность
|
||||||
|
- ✅ **Оптимизирован код** - убраны лишние console.log
|
||||||
|
- ✅ **Готовы production скрипты** - npm run start:prod
|
||||||
|
- ✅ **Настроено кэширование** - статические файлы
|
||||||
|
- ✅ **Оптимизированы запросы** - эффективная фильтрация
|
||||||
|
|
||||||
|
### Технические улучшения
|
||||||
|
- **Безопасная обработка данных**: проверка на null/undefined
|
||||||
|
- **Валидация форм**: клиентская проверка ввода
|
||||||
|
- **Обработка ошибок**: информативные уведомления
|
||||||
|
- **Права доступа**: детальная проверка ролей
|
||||||
|
- **Оптимизация производительности**: эффективная фильтрация
|
||||||
|
- **URL маршрутизация**: обработка параметров и фильтров
|
||||||
|
- **Асинхронная загрузка**: динамическое обновление меню
|
||||||
|
- **Кэширование данных**: оптимизация производительности
|
||||||
|
- **Hover-функциональность**: плавные анимации и переходы
|
||||||
|
|
||||||
## 🐛 Устранение неполадок
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
@ -240,6 +362,7 @@ sudo certbot --nginx -d your-domain.com
|
|||||||
- Проверьте правильность логина/пароля
|
- Проверьте правильность логина/пароля
|
||||||
- Очистите кэш браузера
|
- Очистите кэш браузера
|
||||||
- Проверьте консоль на ошибки JavaScript
|
- Проверьте консоль на ошибки JavaScript
|
||||||
|
- Убедитесь, что JavaScript включен в браузере
|
||||||
|
|
||||||
#### Не сохраняются данные
|
#### Не сохраняются данные
|
||||||
- Проверьте доступ к LocalStorage
|
- Проверьте доступ к LocalStorage
|
||||||
@ -251,11 +374,34 @@ sudo certbot --nginx -d your-domain.com
|
|||||||
- Очистите кэш браузера
|
- Очистите кэш браузера
|
||||||
- Проверьте количество товаров/операций
|
- Проверьте количество товаров/операций
|
||||||
|
|
||||||
|
#### Проблемы с каталогом
|
||||||
|
- Проверьте права доступа пользователя
|
||||||
|
- Убедитесь, что категории загружены корректно
|
||||||
|
- Проверьте консоль на ошибки загрузки данных
|
||||||
|
- Очистите фильтры, если каталог пустой
|
||||||
|
- Проверьте URL параметры для фильтрации по категориям
|
||||||
|
- Убедитесь, что hover-меню категорий загружается
|
||||||
|
|
||||||
|
#### Не работает hover-меню
|
||||||
|
- Проверьте, что JavaScript загружен корректно
|
||||||
|
- Убедитесь, что jQuery подключен
|
||||||
|
- Проверьте консоль на ошибки
|
||||||
|
- Очистите кэш браузера
|
||||||
|
|
||||||
|
#### Элементы интерфейса скрыты
|
||||||
|
- Проверьте роль пользователя
|
||||||
|
- Убедитесь, что пользователь авторизован
|
||||||
|
- Проверьте права доступа в auth.js
|
||||||
|
- Очистите LocalStorage и войдите заново
|
||||||
|
|
||||||
### Логи и отладка
|
### Логи и отладка
|
||||||
Откройте консоль разработчика (F12) для просмотра ошибок и логов.
|
Откройте консоль разработчика (F12) для просмотра ошибок и логов.
|
||||||
|
|
||||||
## 🤝 Поддержка
|
## 🤝 Поддержка
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - подробные инструкции по развертыванию
|
||||||
|
|
||||||
### Сообщение об ошибках
|
### Сообщение об ошибках
|
||||||
При обнаружении ошибок:
|
При обнаружении ошибок:
|
||||||
1. Опишите проблему подробно
|
1. Опишите проблему подробно
|
||||||
@ -269,19 +415,13 @@ sudo certbot --nginx -d your-domain.com
|
|||||||
2. Укажите приоритет
|
2. Укажите приоритет
|
||||||
3. Приведите примеры использования
|
3. Приведите примеры использования
|
||||||
|
|
||||||
## 📄 Лицензия
|
|
||||||
|
|
||||||
Этот проект распространяется под лицензией MIT.
|
|
||||||
|
|
||||||
## 👥 Авторы
|
## 👥 Авторы
|
||||||
|
|
||||||
- Разработка: Roman Pylaev
|
- **Разработка**: Roman Pylaev
|
||||||
- Дизайн: Roman Pylaev
|
- **Дизайн**: Roman Pylaev
|
||||||
- Тестирование: Roman Pylaev
|
- **Тестирование**: Roman Pylaev
|
||||||
|
|
||||||
## 📞 Контакты
|
## 📞 Контакты
|
||||||
|
|
||||||
- Email: r0manpylaev@yandex.ru
|
- **Telegram/Discord**: @dark7es
|
||||||
- Телефон: +7 (977) 665-45-78
|
|
||||||
- Telegram: @dark7es
|
|
||||||
- Discord: @dark7es
|
|
||||||
|
|||||||
338
admin/categories.html
Normal file
338
admin/categories.html
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Управление категориями - Склад-Менеджер</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="../index.html">
|
||||||
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="../catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../history.html">
|
||||||
|
<i class="fas fa-history me-1"></i>История
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="users.html">Управление пользователями</a></li>
|
||||||
|
<li><a class="dropdown-item active" href="categories.html">Категории позиций</a></li>
|
||||||
|
<li><a class="dropdown-item" href="reports.html">Отчеты</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="../profile.html">Мой профиль</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="logoutBtn">Выйти</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="container mt-4">
|
||||||
|
<!-- Заголовок страницы -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-1">
|
||||||
|
<i class="fas fa-tags me-2"></i>
|
||||||
|
Управление категориями
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Создание и редактирование категорий позиций</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addCategoryModal" id="addCategoryBtn" data-role="manager">
|
||||||
|
<i class="fas fa-plus me-1"></i>Добавить категорию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика категорий -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="totalCategories">0</h4>
|
||||||
|
<p class="card-text">Всего категорий</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-tags fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="totalItems">0</h4>
|
||||||
|
<p class="card-text">Всего позиций</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-boxes fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card bg-info text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="activeCategories">0</h4>
|
||||||
|
<p class="card-text">Активные категории</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-check-circle fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="emptyCategories">0</h4>
|
||||||
|
<p class="card-text">Пустые категории</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-exclamation-triangle fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица категорий -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-list me-2"></i>
|
||||||
|
Список категорий
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="categoriesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Иконка</th>
|
||||||
|
<th>Количество позиций</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Модальное окно добавления категории -->
|
||||||
|
<div class="modal fade" id="addCategoryModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
Добавить категорию
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="addCategoryForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoryName" class="form-label">Название категории *</label>
|
||||||
|
<input type="text" class="form-control" id="categoryName" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoryDescription" class="form-label">Описание</label>
|
||||||
|
<textarea class="form-control" id="categoryDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoryIcon" class="form-label">Иконка</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-icons"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" id="categoryIcon" placeholder="fas fa-box">
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Используйте классы Font Awesome (например: fas fa-box)</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="categoryColor" class="form-label">Цвет категории</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="categoryColor" value="#2c5aa0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="categoryActive" checked>
|
||||||
|
<label class="form-check-label" for="categoryActive">
|
||||||
|
Активная категория
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveCategoryBtn">
|
||||||
|
<i class="fas fa-save me-1"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно редактирования категории -->
|
||||||
|
<div class="modal fade" id="editCategoryModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-edit me-2"></i>
|
||||||
|
Редактировать категорию
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="editCategoryForm">
|
||||||
|
<input type="hidden" id="editCategoryId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editCategoryName" class="form-label">Название категории *</label>
|
||||||
|
<input type="text" class="form-control" id="editCategoryName" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editCategoryDescription" class="form-label">Описание</label>
|
||||||
|
<textarea class="form-control" id="editCategoryDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editCategoryIcon" class="form-label">Иконка</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-icons"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" id="editCategoryIcon" placeholder="fas fa-box">
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Используйте классы Font Awesome (например: fas fa-box)</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editCategoryColor" class="form-label">Цвет категории</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="editCategoryColor" value="#2c5aa0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editCategoryActive">
|
||||||
|
<label class="form-check-label" for="editCategoryActive">
|
||||||
|
Активная категория
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="updateCategoryBtn">
|
||||||
|
<i class="fas fa-save me-1"></i>Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Подвал -->
|
||||||
|
<footer class="bg-dark text-white mt-5 py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Система управления складом</h5>
|
||||||
|
<p class="mb-0">Эффективное управление складом деталей</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Скрипты -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/warehouse.js"></script>
|
||||||
|
<script src="../js/categories.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
420
admin/email-test.html
Normal file
420
admin/email-test.html
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Тест Email - Склад-Менеджер</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="../index.html">
|
||||||
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="users.html">Управление пользователями</a></li>
|
||||||
|
<li><a class="dropdown-item" href="categories.html">Категории позиций</a></li>
|
||||||
|
<li><a class="dropdown-item" href="reports.html">Отчеты</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item active" href="email-test.html">Тест Email</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="../profile.html">Мой профиль</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="logoutBtn">Выйти</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="fas fa-envelope me-2"></i>Тест Email-уведомлений</h1>
|
||||||
|
<a href="reports.html" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i>Назад к отчетам
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статус email-сервиса -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
Статус Email-сервиса
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="emailStatus" class="text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">Проверка статуса...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Тестовая отправка email -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
Тестовая отправка Email
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="testEmailForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="testEmail" class="form-label">Email получателя</label>
|
||||||
|
<input type="email" class="form-control" id="testEmail" required
|
||||||
|
placeholder="test@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="testSubject" class="form-label">Тема письма</label>
|
||||||
|
<input type="text" class="form-control" id="testSubject" required
|
||||||
|
placeholder="Тестовое уведомление">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="testMessage" class="form-label">Сообщение</label>
|
||||||
|
<textarea class="form-control" id="testMessage" rows="4" required
|
||||||
|
placeholder="Введите текст сообщения..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить тестовое письмо
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые тесты -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>
|
||||||
|
Тест низкого остатка
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить уведомление о товарах с низким запасом</p>
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="testLowStockAlert()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-box me-2 text-success"></i>
|
||||||
|
Тест операции
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить уведомление об операции на складе</p>
|
||||||
|
<button class="btn btn-success btn-sm" onclick="testOperationNotification()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-chart-bar me-2 text-info"></i>
|
||||||
|
Тест отчета
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Отправить еженедельный отчет по операциям</p>
|
||||||
|
<button class="btn btn-info btn-sm" onclick="testWeeklyReport()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i>Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Лог отправки -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-list me-2"></i>
|
||||||
|
Лог отправки
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="emailLog" class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<p class="text-muted mb-0">Лог отправки email-уведомлений будет отображаться здесь...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Подвал -->
|
||||||
|
<footer class="bg-dark text-white mt-5 py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Система управления складом</h5>
|
||||||
|
<p class="mb-0">Эффективное управление товарными запасами</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Скрипты -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/warehouse.js"></script>
|
||||||
|
<script>
|
||||||
|
// Инициализация
|
||||||
|
$(document).ready(async () => {
|
||||||
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права администратора
|
||||||
|
if (!auth.hasRole('admin')) {
|
||||||
|
auth.showNotification('Недостаточно прав для доступа к этой странице', 'danger');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
updateUserInfo();
|
||||||
|
|
||||||
|
// Загружаем статус email-сервиса
|
||||||
|
await loadEmailStatus();
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
function updateUserInfo() {
|
||||||
|
const user = auth.getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
|
if (profileToggle.length) {
|
||||||
|
profileToggle.html(`<i class="fas fa-user me-1"></i>${user.name}`);
|
||||||
|
}
|
||||||
|
$('#profileDropdown').show();
|
||||||
|
$('#adminDropdown').show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка событий
|
||||||
|
function bindEvents() {
|
||||||
|
// Обработчик формы тестового email
|
||||||
|
$('#testEmailForm').on('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await sendTestEmail();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка статуса email-сервиса
|
||||||
|
async function loadEmailStatus() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const status = await warehouse.apiRequest('/notifications/status');
|
||||||
|
|
||||||
|
$('#emailStatus').html(`
|
||||||
|
<div class="text-success">
|
||||||
|
<i class="fas fa-check-circle fa-2x mb-2"></i>
|
||||||
|
<h5>Email-сервис активен</h5>
|
||||||
|
<p class="mb-0">Последняя проверка: ${new Date().toLocaleString('ru-RU')}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
$('#emailStatus').html(`
|
||||||
|
<div class="text-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
|
||||||
|
<h5>Email-сервис недоступен</h5>
|
||||||
|
<p class="mb-0">Ошибка: ${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка тестового email
|
||||||
|
async function sendTestEmail() {
|
||||||
|
const email = $('#testEmail').val();
|
||||||
|
const subject = $('#testSubject').val();
|
||||||
|
const message = $('#testMessage').val();
|
||||||
|
|
||||||
|
if (!email || !subject || !message) {
|
||||||
|
auth.showNotification('Пожалуйста, заполните все поля', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const result = await warehouse.sendTestEmail(email, subject, message);
|
||||||
|
|
||||||
|
addToLog('success', `Тестовое письмо отправлено на ${email}`, result);
|
||||||
|
auth.showNotification('Тестовое письмо отправлено успешно', 'success');
|
||||||
|
|
||||||
|
// Очищаем форму
|
||||||
|
$('#testEmailForm')[0].reset();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки тестового письма: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки тестового письма', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест уведомления о низком остатке
|
||||||
|
async function testLowStockAlert() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const lowStockItems = await warehouse.getLowStockItems();
|
||||||
|
|
||||||
|
if (lowStockItems.length === 0) {
|
||||||
|
// Создаем тестовые данные
|
||||||
|
const testItems = [
|
||||||
|
{ name: 'Тестовый товар 1', quantity: 2, minQuantity: 5 },
|
||||||
|
{ name: 'Тестовый товар 2', quantity: 1, minQuantity: 3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await warehouse.sendLowStockEmailNotification(testItems);
|
||||||
|
addToLog('success', 'Тестовое уведомление о низком остатке отправлено', result);
|
||||||
|
auth.showNotification('Тестовое уведомление о низком остатке отправлено', 'success');
|
||||||
|
} else {
|
||||||
|
const result = await warehouse.sendLowStockEmailNotification(lowStockItems);
|
||||||
|
addToLog('success', 'Уведомление о низком остатке отправлено', result);
|
||||||
|
auth.showNotification('Уведомление о низком остатке отправлено', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки уведомления о низком остатке: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки уведомления', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест уведомления об операции
|
||||||
|
async function testOperationNotification() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const testOperation = {
|
||||||
|
type: 'incoming',
|
||||||
|
itemName: 'Тестовый товар',
|
||||||
|
quantity: 10,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
employeeName: 'Тестовый сотрудник'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await warehouse.sendOperationEmailNotificationAPI(testOperation);
|
||||||
|
addToLog('success', 'Тестовое уведомление об операции отправлено', result);
|
||||||
|
auth.showNotification('Тестовое уведомление об операции отправлено', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки уведомления об операции: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки уведомления', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест еженедельного отчета
|
||||||
|
async function testWeeklyReport() {
|
||||||
|
try {
|
||||||
|
const warehouse = new WarehouseManager();
|
||||||
|
const testReport = {
|
||||||
|
period: 'За последнюю неделю',
|
||||||
|
totalOperations: 15,
|
||||||
|
incoming: 8,
|
||||||
|
outgoing: 7,
|
||||||
|
operations: [
|
||||||
|
{ date: new Date().toISOString(), type: 'incoming', itemName: 'Товар 1', quantity: 5 },
|
||||||
|
{ date: new Date().toISOString(), type: 'outgoing', itemName: 'Товар 2', quantity: 3 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await warehouse.sendWeeklyReportEmailNotification(testReport);
|
||||||
|
addToLog('success', 'Тестовый еженедельный отчет отправлен', result);
|
||||||
|
auth.showNotification('Тестовый еженедельный отчет отправлен', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
addToLog('error', `Ошибка отправки еженедельного отчета: ${error.message}`);
|
||||||
|
auth.showNotification('Ошибка отправки отчета', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление записи в лог
|
||||||
|
function addToLog(type, message, data = null) {
|
||||||
|
const log = $('#emailLog');
|
||||||
|
const timestamp = new Date().toLocaleString('ru-RU');
|
||||||
|
const icon = type === 'success' ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-danger';
|
||||||
|
|
||||||
|
let logEntry = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<i class="${icon} me-2"></i>
|
||||||
|
<strong>${timestamp}</strong> - ${message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
logEntry += `<pre class="small text-muted ms-4">${JSON.stringify(data, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.append(logEntry);
|
||||||
|
log.scrollTop(log[0].scrollHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
173
admin/reports.html
Normal file
173
admin/reports.html
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Отчеты - Склад-Менеджер</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="../index.html"><i class="fas fa-warehouse me-2"></i>Склад-Менеджер</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../index.html"><i class="fas fa-home me-1"></i>Главная</a></li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-boxes me-1"></i>Каталог</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="../catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../history.html"><i class="fas fa-history me-1"></i>История</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="../search.html"><i class="fas fa-search me-1"></i>Поиск</a></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav" id="adminDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-cogs me-1"></i>Администрирование</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="categories.html"><i class="fas fa-tags me-2"></i>Категории позиций</a></li>
|
||||||
|
<li><a class="dropdown-item" href="users.html"><i class="fas fa-users me-2"></i>Управление пользователями</a></li>
|
||||||
|
<li><a class="dropdown-item active" href="reports.html"><i class="fas fa-chart-line me-2"></i>Отчеты</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="email-test.html"><i class="fas fa-envelope me-2"></i>Тест Email</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav" id="profileDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"><i class="fas fa-user me-1"></i>Профиль</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0"><i class="fas fa-chart-line me-2 text-primary"></i>Отчеты</h1>
|
||||||
|
<div class="text-muted">Статистика, операции и остатки за выбранный период</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary me-2" id="resetFiltersBtn"><i class="fas fa-rotate-left me-2"></i>Сбросить</button>
|
||||||
|
<button class="btn btn-outline-secondary me-2" id="exportCsvBtn"><i class="fas fa-file-csv me-2"></i>Экспорт CSV</button>
|
||||||
|
<button class="btn btn-outline-secondary" id="exportJsonBtn"><i class="fas fa-file-code me-2"></i>Экспорт JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="filtersForm" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Период от</label>
|
||||||
|
<input type="date" class="form-control" id="dateFrom">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Период до</label>
|
||||||
|
<input type="date" class="form-control" id="dateTo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Тип операции</label>
|
||||||
|
<select id="opType" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="incoming">Приход</option>
|
||||||
|
<option value="outgoing">Расход</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Категория</label>
|
||||||
|
<select id="categoryId" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Товар</label>
|
||||||
|
<input type="text" id="itemQuery" class="form-control" placeholder="Название/штрихкод">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Сотрудник</label>
|
||||||
|
<input type="text" id="employeeQuery" class="form-control" placeholder="Имя пользователя">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 align-self-end">
|
||||||
|
<button class="btn btn-primary w-100" id="applyFiltersBtn" type="submit"><i class="fas fa-sync me-2"></i>Применить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-success"><div class="card-body"><div class="small">Приход всего</div><div class="h4 mb-0" id="totalIncoming">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-danger"><div class="card-body"><div class="small">Расход всего</div><div class="h4 mb-0" id="totalOutgoing">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-primary"><div class="card-body"><div class="small">Операций</div><div class="h4 mb-0" id="totalOps">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-info"><div class="card-body"><div class="small">Уникальных товаров</div><div class="h4 mb-0" id="uniqueItems">0</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-list me-2"></i>Операции</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="operationsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Количество</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-box me-2"></i>Остатки по категориям</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="byCategoryTable">
|
||||||
|
<thead class="table-light"><tr><th>Категория</th><th>Всего позиций</th><th>Суммарный остаток</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="fas fa-star me-2"></i>ТОП товаров по движению</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" id="topItemsTable">
|
||||||
|
<thead class="table-light"><tr><th>Товар</th><th>Приход</th><th>Расход</th><th>Итого</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingState" class="text-center py-5" style="display:none;">
|
||||||
|
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/reports.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
570
admin/users.html
Normal file
570
admin/users.html
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Управление пользователями - Склад-Менеджер</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link href="../css/style.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-chip {
|
||||||
|
display: inline-block;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin: 0.1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="../index.html">
|
||||||
|
<i class="fas fa-warehouse me-2"></i>
|
||||||
|
Склад-Менеджер
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="../catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../history.html">
|
||||||
|
<i class="fas fa-history me-1"></i>История
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="../search.html">
|
||||||
|
<i class="fas fa-search me-1"></i>Поиск
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Admin Dropdown -->
|
||||||
|
<ul class="navbar-nav" id="adminDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-cogs me-1"></i>Администрирование
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="categories.html">
|
||||||
|
<i class="fas fa-tags me-2"></i>Категории позиций
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item active" href="users.html">
|
||||||
|
<i class="fas fa-users me-2"></i>Управление пользователями
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Profile Dropdown -->
|
||||||
|
<ul class="navbar-nav" id="profileDropdown" style="display: none;">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="../profile.html">
|
||||||
|
<i class="fas fa-user-circle me-2"></i>Мой профиль
|
||||||
|
</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="logoutBtn">
|
||||||
|
<i class="fas fa-sign-out-alt me-2"></i>Выйти
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">
|
||||||
|
<i class="fas fa-users me-2 text-primary"></i>
|
||||||
|
Управление пользователями
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Создание, редактирование и управление пользователями системы</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="addUserBtn" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||||
|
<i class="fas fa-plus me-2"></i>Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Search -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Поиск по имени, email, отделу...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<select class="form-select" id="roleFilter">
|
||||||
|
<option value="">Все роли</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
<option value="manager">Менеджер</option>
|
||||||
|
<option value="operator">Оператор</option>
|
||||||
|
<option value="viewer">Наблюдатель</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="totalUsers">0</h4>
|
||||||
|
<p class="card-text">Всего пользователей</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-users fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="activeUsers">0</h4>
|
||||||
|
<p class="card-text">Активных</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-check fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="adminUsers">0</h4>
|
||||||
|
<p class="card-text">Администраторов</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-shield fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-info text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="card-title" id="managerUsers">0</h4>
|
||||||
|
<p class="card-text">Менеджеров</p>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="fas fa-user-tie fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Grid -->
|
||||||
|
<div class="row" id="usersGrid">
|
||||||
|
<!-- Users will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loadingState" class="text-center py-5" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Загрузка пользователей...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="emptyState" class="text-center py-5" style="display: none;">
|
||||||
|
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">Пользователи не найдены</h4>
|
||||||
|
<p class="text-muted">Попробуйте изменить параметры поиска или добавьте первого пользователя</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add User Modal -->
|
||||||
|
<div class="modal fade" id="addUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
|
Добавить пользователя
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="addUserForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Имя пользователя *</label>
|
||||||
|
<input type="text" class="form-control" id="username" required>
|
||||||
|
<div class="form-text">Уникальное имя для входа в систему</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" id="email" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="fullName" class="form-label">Полное имя *</label>
|
||||||
|
<input type="text" class="form-control" id="fullName" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="department" class="form-label">Отдел</label>
|
||||||
|
<input type="text" class="form-control" id="department">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="position" class="form-label">Должность</label>
|
||||||
|
<input type="text" class="form-control" id="position">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль *</label>
|
||||||
|
<input type="password" class="form-control" id="password" required>
|
||||||
|
<div class="form-text">Минимум 6 символов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Роли *</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="roleAdmin">
|
||||||
|
<i class="fas fa-user-shield me-1"></i>Администратор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleManager" value="manager">
|
||||||
|
<label class="form-check-label" for="roleManager">
|
||||||
|
<i class="fas fa-user-tie me-1"></i>Менеджер
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleOperator" value="operator">
|
||||||
|
<label class="form-check-label" for="roleOperator">
|
||||||
|
<i class="fas fa-user-cog me-1"></i>Оператор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roleViewer" value="viewer">
|
||||||
|
<label class="form-check-label" for="roleViewer">
|
||||||
|
<i class="fas fa-user me-1"></i>Наблюдатель
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Разрешения</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permRead" value="read" checked>
|
||||||
|
<label class="form-check-label" for="permRead">Чтение</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permWrite" value="write">
|
||||||
|
<label class="form-check-label" for="permWrite">Запись</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permDelete" value="delete">
|
||||||
|
<label class="form-check-label" for="permDelete">Удаление</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="permAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="permAdmin">Администрирование</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit User Modal -->
|
||||||
|
<div class="modal fade" id="editUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-user-edit me-2"></i>
|
||||||
|
Редактировать пользователя
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="editUserForm">
|
||||||
|
<input type="hidden" id="editUserId">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editUsername" class="form-label">Имя пользователя *</label>
|
||||||
|
<input type="text" class="form-control" id="editUsername" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editEmail" class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" id="editEmail" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editFullName" class="form-label">Полное имя *</label>
|
||||||
|
<input type="text" class="form-control" id="editFullName" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editDepartment" class="form-label">Отдел</label>
|
||||||
|
<input type="text" class="form-control" id="editDepartment">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editPosition" class="form-label">Должность</label>
|
||||||
|
<input type="text" class="form-control" id="editPosition">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editPassword" class="form-label">Новый пароль</label>
|
||||||
|
<input type="password" class="form-control" id="editPassword">
|
||||||
|
<div class="form-text">Оставьте пустым, чтобы не изменять</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Роли *</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="editRoleAdmin">
|
||||||
|
<i class="fas fa-user-shield me-1"></i>Администратор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleManager" value="manager">
|
||||||
|
<label class="form-check-label" for="editRoleManager">
|
||||||
|
<i class="fas fa-user-tie me-1"></i>Менеджер
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleOperator" value="operator">
|
||||||
|
<label class="form-check-label" for="editRoleOperator">
|
||||||
|
<i class="fas fa-user-cog me-1"></i>Оператор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editRoleViewer" value="viewer">
|
||||||
|
<label class="form-check-label" for="editRoleViewer">
|
||||||
|
<i class="fas fa-user me-1"></i>Наблюдатель
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Разрешения</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermRead" value="read">
|
||||||
|
<label class="form-check-label" for="editPermRead">Чтение</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermWrite" value="write">
|
||||||
|
<label class="form-check-label" for="editPermWrite">Запись</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermDelete" value="delete">
|
||||||
|
<label class="form-check-label" for="editPermDelete">Удаление</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPermAdmin" value="admin">
|
||||||
|
<label class="form-check-label" for="editPermAdmin">Администрирование</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>Сохранить изменения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title text-danger">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
Подтверждение удаления
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Вы действительно хотите удалить пользователя <strong id="deleteUserName"></strong>?</p>
|
||||||
|
<p class="text-muted">Это действие нельзя отменить.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||||
|
<i class="fas fa-trash me-2"></i>Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="../js/auth.js"></script>
|
||||||
|
<script src="../js/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
catalog.html
72
catalog.html
@ -9,27 +9,46 @@
|
|||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Навигационная панель -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="index.html">
|
<a class="navbar-brand" href="index.html">
|
||||||
<i class="fas fa-warehouse me-2"></i>
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
Склад-Менеджер
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="index.html">
|
<a class="nav-link" href="index.html">
|
||||||
<i class="fas fa-home me-1"></i>Главная
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link active" href="catalog.html">
|
<a class="nav-link dropdown-toggle active" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="fas fa-boxes me-1"></i>Каталог
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="history.html">
|
<a class="nav-link" href="history.html">
|
||||||
@ -41,19 +60,17 @@
|
|||||||
<i class="fas fa-search me-1"></i>Поиск
|
<i class="fas fa-search me-1"></i>Поиск
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-cog me-1"></i>Администрирование
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/categories.html">Категории товаров</a></li>
|
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="profileDropdown">
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-user me-1"></i>Профиль
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
</a>
|
</a>
|
||||||
@ -68,6 +85,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<!-- Основной контент -->
|
||||||
<main class="container mt-4">
|
<main class="container mt-4">
|
||||||
<!-- Заголовок страницы -->
|
<!-- Заголовок страницы -->
|
||||||
@ -77,16 +112,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="mb-1">
|
<h1 class="mb-1">
|
||||||
<i class="fas fa-boxes me-2"></i>
|
<i class="fas fa-boxes me-2"></i>
|
||||||
Каталог товаров
|
Каталог позиций
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0">Управление товарными позициями склада</p>
|
<p class="text-muted mb-0">Управление позициями склада</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-primary" id="exportBtn">
|
<button class="btn btn-outline-primary" id="exportBtn" data-role="authenticated">
|
||||||
<i class="fas fa-download me-1"></i>Экспорт
|
<i class="fas fa-download me-1"></i>Экспорт
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addItemModal" id="addItemBtn">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addItemModal" id="addItemBtn"
|
||||||
<i class="fas fa-plus me-1"></i>Добавить товар
|
data-role="manager" style="display: none;" title="Требуются права администратора или менеджера">
|
||||||
|
<i class="fas fa-plus me-1"></i>Добавить позицию
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -97,7 +133,7 @@
|
|||||||
<div class="catalog-filters">
|
<div class="catalog-filters">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label for="searchInput" class="form-label">Поиск товаров</label>
|
<label for="searchInput" class="form-label">Поиск позиций</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
@ -154,7 +190,7 @@
|
|||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="card-title" id="totalItemsCount">0</h4>
|
<h4 class="card-title" id="totalItemsCount">0</h4>
|
||||||
<p class="card-text">Всего товаров</p>
|
<p class="card-text">Всего позиций</p>
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-boxes fa-2x"></i>
|
<i class="fas fa-boxes fa-2x"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
275
css/style.css
275
css/style.css
@ -1,17 +1,20 @@
|
|||||||
/* Основные стили для системы управления складом */
|
/* Основные стили для системы управления складом */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #0d6efd;
|
--primary-color: #2c5aa0;
|
||||||
--secondary-color: #6c757d;
|
--secondary-color: #6c757d;
|
||||||
--success-color: #198754;
|
--success-color: #28a745;
|
||||||
--warning-color: #ffc107;
|
--warning-color: #ffc107;
|
||||||
--danger-color: #dc3545;
|
--danger-color: #dc3545;
|
||||||
--info-color: #0dcaf0;
|
--info-color: #17a2b8;
|
||||||
--light-color: #f8f9fa;
|
--light-color: #f8f9fa;
|
||||||
--dark-color: #212529;
|
--dark-color: #343a40;
|
||||||
--border-radius: 0.375rem;
|
--border-radius: 0.25rem;
|
||||||
--box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
--box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
--transition: all 0.15s ease-in-out;
|
--transition: all 0.2s ease-in-out;
|
||||||
|
--header-bg: #1e3a5f;
|
||||||
|
--sidebar-bg: #f8f9fa;
|
||||||
|
--card-border: #e9ecef;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Общие стили */
|
/* Общие стили */
|
||||||
@ -21,40 +24,124 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Навигация */
|
|
||||||
|
|
||||||
|
/* Главная навигация */
|
||||||
|
.navbar {
|
||||||
|
background: #fff !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-search .input-group {
|
||||||
|
border-radius: 25px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-search .form-control {
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-search .btn {
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 0 25px 25px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-link {
|
.navbar-nav .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
color: var(--dark-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-link:hover {
|
.navbar-nav .nav-link:hover {
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
background-color: rgba(44, 90, 160, 0.1);
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
background-color: rgba(44, 90, 160, 0.15);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель категорий */
|
||||||
|
.categories-bar {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-nav {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-nav .nav-link {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
color: var(--dark-color);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-nav .nav-link:hover {
|
||||||
|
background-color: rgba(44, 90, 160, 0.1);
|
||||||
|
border-color: rgba(44, 90, 160, 0.2);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-nav .nav-link.active {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #1e3a5f 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 2px 8px rgba(44, 90, 160, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Карточки быстрых действий */
|
/* Карточки быстрых действий */
|
||||||
.quick-action-card {
|
.quick-action-card {
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
border: 1px solid var(--card-border);
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #f8f9fa 100%);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-action-card:hover {
|
.quick-action-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-action-card .card-body {
|
.quick-action-card .card-body {
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-action-card i {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #1e3a5f 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
/* Градиентный фон для приветственного блока */
|
/* Градиентный фон для приветственного блока */
|
||||||
.bg-gradient-primary {
|
.bg-gradient-primary {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
|
background: linear-gradient(135deg, var(--header-bg) 0%, var(--primary-color) 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Статистические карточки */
|
/* Статистические карточки */
|
||||||
@ -65,35 +152,56 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #1e3a5f 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-success {
|
||||||
|
background: linear-gradient(135deg, var(--success-color) 0%, #1e7e34 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-warning {
|
||||||
|
background: linear-gradient(135deg, var(--warning-color) 0%, #e0a800 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-info {
|
||||||
|
background: linear-gradient(135deg, var(--info-color) 0%, #138496 100%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.bg-primary:hover,
|
.card.bg-primary:hover,
|
||||||
.card.bg-success:hover,
|
.card.bg-success:hover,
|
||||||
.card.bg-warning:hover,
|
.card.bg-warning:hover,
|
||||||
.card.bg-info:hover {
|
.card.bg-info:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Таблицы */
|
/* Таблицы */
|
||||||
.table {
|
.table {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: var(--border-radius);
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table thead th {
|
.table thead th {
|
||||||
background-color: var(--light-color);
|
background: linear-gradient(135deg, var(--light-color) 0%, #e9ecef 100%);
|
||||||
border-bottom: 2px solid #dee2e6;
|
border-bottom: 2px solid var(--primary-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--dark-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table tbody tr:hover {
|
.table tbody tr:hover {
|
||||||
background-color: rgba(13, 110, 253, 0.05);
|
background-color: rgba(44, 90, 160, 0.05);
|
||||||
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Модальные окна */
|
/* Модальные окна */
|
||||||
@ -115,15 +223,23 @@ body {
|
|||||||
/* Формы */
|
/* Формы */
|
||||||
.form-control,
|
.form-control,
|
||||||
.form-select {
|
.form-select {
|
||||||
border-radius: var(--border-radius);
|
border-radius: 8px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus,
|
.form-control:focus,
|
||||||
.form-select:focus {
|
.form-select:focus {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
box-shadow: 0 0 0 0.2rem rgba(44, 90, 160, 0.25);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-text {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
background: linear-gradient(135deg, var(--light-color) 0%, #e9ecef 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@ -134,15 +250,26 @@ body {
|
|||||||
|
|
||||||
/* Кнопки */
|
/* Кнопки */
|
||||||
.btn {
|
.btn {
|
||||||
border-radius: var(--border-radius);
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #1e3a5f 100%);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, var(--primary-color) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@ -441,3 +568,103 @@ footer {
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для выпадающего меню категорий */
|
||||||
|
#catalogCategoriesDropdown {
|
||||||
|
min-width: 280px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item .category-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item .category-count {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item:hover .category-count {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-divider {
|
||||||
|
margin: 0.5rem 1rem;
|
||||||
|
border-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для пустых категорий */
|
||||||
|
#catalogCategoriesDropdown .dropdown-item.empty-category {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#catalogCategoriesDropdown .dropdown-item.empty-category .category-count {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация появления выпадающего меню */
|
||||||
|
.dropdown-menu.show {
|
||||||
|
animation: dropdownFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover dropdown functionality */
|
||||||
|
.nav-item.dropdown:hover .dropdown-menu {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.dropdown:hover .dropdown-toggle::after {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transition for dropdown arrow */
|
||||||
|
.dropdown-toggle::after {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
64
history.html
64
history.html
@ -9,51 +9,63 @@
|
|||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Навигационная панель -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="index.html">
|
<a class="navbar-brand" href="index.html">
|
||||||
<i class="fas fa-warehouse me-2"></i>
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
Склад-Менеджер
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="index.html">
|
<a class="nav-link" href="index.html">
|
||||||
<i class="fas fa-home me-1"></i>Главная
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="catalog.html">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="fas fa-boxes me-1"></i>Каталог
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" href="history.html">
|
<a class="nav-link active" href="history.html">
|
||||||
<i class="fas fa-history me-1"></i>История
|
<i class="fas fa-history me-1"></i>История
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="search.html">
|
|
||||||
<i class="fas fa-search me-1"></i>Поиск
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-cog me-1"></i>Администрирование
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/categories.html">Категории товаров</a></li>
|
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="profileDropdown">
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-user me-1"></i>Профиль
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
</a>
|
</a>
|
||||||
@ -68,6 +80,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<!-- Основной контент -->
|
||||||
<main class="container mt-4">
|
<main class="container mt-4">
|
||||||
<!-- Заголовок страницы -->
|
<!-- Заголовок страницы -->
|
||||||
@ -364,7 +394,7 @@
|
|||||||
<p class="mb-0">Эффективное управление товарными запасами</p>
|
<p class="mb-0">Эффективное управление товарными запасами</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
<div class="col-md-6 text-md-end">
|
||||||
<p class="mb-0">© 2024 Склад-Менеджер. Все права защищены.</p>
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
146
index.html
146
index.html
@ -9,27 +9,41 @@
|
|||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Навигационная панель -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="index.html">
|
<a class="navbar-brand" href="index.html">
|
||||||
<i class="fas fa-warehouse me-2"></i>
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
Склад-Менеджер
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link active" href="index.html">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="fas fa-home me-1"></i>Главная
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="catalog.html">
|
|
||||||
<i class="fas fa-boxes me-1"></i>Каталог
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="history.html">
|
<a class="nav-link" href="history.html">
|
||||||
@ -41,19 +55,17 @@
|
|||||||
<i class="fas fa-search me-1"></i>Поиск
|
<i class="fas fa-search me-1"></i>Поиск
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-cog me-1"></i>Администрирование
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/categories.html">Категории товаров</a></li>
|
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="profileDropdown">
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-user me-1"></i>Профиль
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
</a>
|
</a>
|
||||||
@ -68,6 +80,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<!-- Основной контент -->
|
||||||
<main class="container mt-4">
|
<main class="container mt-4">
|
||||||
<!-- Приветственный блок -->
|
<!-- Приветственный блок -->
|
||||||
@ -79,7 +109,7 @@
|
|||||||
<i class="fas fa-warehouse me-3"></i>
|
<i class="fas fa-warehouse me-3"></i>
|
||||||
Добро пожаловать в систему управления складом
|
Добро пожаловать в систему управления складом
|
||||||
</h1>
|
</h1>
|
||||||
<p class="card-text">Эффективное управление товарными запасами и операциями склада</p>
|
<p class="card-text">Эффективное управление складом деталей и операциями</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -90,37 +120,37 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h2 class="mb-3">Быстрые действия</h2>
|
<h2 class="mb-3">Быстрые действия</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3" data-role="manager">
|
||||||
<div class="card h-100 quick-action-card">
|
<div class="card h-100 quick-action-card">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<i class="fas fa-plus-circle fa-3x text-primary mb-3"></i>
|
<i class="fas fa-plus-circle fa-3x text-primary mb-3"></i>
|
||||||
<h5 class="card-title">Добавить товар</h5>
|
<h5 class="card-title">Добавить позицию</h5>
|
||||||
<p class="card-text">Добавить новый товар в каталог</p>
|
<p class="card-text">Добавить новую позицию в каталог</p>
|
||||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addItemModal">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addItemModal" id="addItemBtn">
|
||||||
Добавить
|
Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3" data-role="manager">
|
||||||
<div class="card h-100 quick-action-card">
|
<div class="card h-100 quick-action-card">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<i class="fas fa-box-open fa-3x text-success mb-3"></i>
|
<i class="fas fa-box-open fa-3x text-success mb-3"></i>
|
||||||
<h5 class="card-title">Приход</h5>
|
<h5 class="card-title">Приход</h5>
|
||||||
<p class="card-text">Оформить приход товара</p>
|
<p class="card-text">Оформить приход позиции</p>
|
||||||
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#incomingModal">
|
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#incomingModal" id="incomingBtn">
|
||||||
Оформить
|
Оформить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3" data-role="manager">
|
||||||
<div class="card h-100 quick-action-card">
|
<div class="card h-100 quick-action-card">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<i class="fas fa-truck fa-3x text-warning mb-3"></i>
|
<i class="fas fa-truck fa-3x text-warning mb-3"></i>
|
||||||
<h5 class="card-title">Расход</h5>
|
<h5 class="card-title">Расход</h5>
|
||||||
<p class="card-text">Оформить расход товара</p>
|
<p class="card-text">Оформить расход позиции</p>
|
||||||
<button class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#outgoingModal">
|
<button class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#outgoingModal" id="outgoingBtn">
|
||||||
Оформить
|
Оформить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -131,13 +161,57 @@
|
|||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<i class="fas fa-search fa-3x text-info mb-3"></i>
|
<i class="fas fa-search fa-3x text-info mb-3"></i>
|
||||||
<h5 class="card-title">Поиск</h5>
|
<h5 class="card-title">Поиск</h5>
|
||||||
<p class="card-text">Найти товар или операцию</p>
|
<p class="card-text">Найти позицию или операцию</p>
|
||||||
<a href="search.html" class="btn btn-info">Найти</a>
|
<a href="search.html" class="btn btn-info">Найти</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Дополнительные действия -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 quick-action-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-user fa-3x text-secondary mb-3"></i>
|
||||||
|
<h5 class="card-title">Мой профиль</h5>
|
||||||
|
<p class="card-text">Управление профилем и настройками</p>
|
||||||
|
<a href="profile.html" class="btn btn-secondary">Открыть</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 quick-action-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-history fa-3x text-dark mb-3"></i>
|
||||||
|
<h5 class="card-title">История операций</h5>
|
||||||
|
<p class="card-text">Просмотр истории всех операций</p>
|
||||||
|
<a href="history.html" class="btn btn-dark">Просмотреть</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 quick-action-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-boxes fa-3x text-primary mb-3"></i>
|
||||||
|
<h5 class="card-title">Каталог позиций</h5>
|
||||||
|
<p class="card-text">Просмотр и управление позициями</p>
|
||||||
|
<a href="catalog.html" class="btn btn-primary">Открыть</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3" data-role="admin">
|
||||||
|
<div class="card h-100 quick-action-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-cog fa-3x text-warning mb-3"></i>
|
||||||
|
<h5 class="card-title">Администрирование</h5>
|
||||||
|
<p class="card-text">Управление системой и пользователями</p>
|
||||||
|
<a href="admin/categories.html" class="btn btn-warning">Управлять</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Статистика -->
|
<!-- Статистика -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@ -149,7 +223,7 @@
|
|||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="card-title" id="totalItems">0</h4>
|
<h4 class="card-title" id="totalItems">0</h4>
|
||||||
<p class="card-text">Всего товаров</p>
|
<p class="card-text">Всего позиций</p>
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-boxes fa-2x"></i>
|
<i class="fas fa-boxes fa-2x"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -247,7 +321,7 @@
|
|||||||
<form id="addItemForm">
|
<form id="addItemForm">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="itemName" class="form-label">Название товара *</label>
|
<label for="itemName" class="form-label">Название позиции *</label>
|
||||||
<input type="text" class="form-control" id="itemName" required>
|
<input type="text" class="form-control" id="itemName" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
@ -305,9 +379,9 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="incomingForm">
|
<form id="incomingForm">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="incomingItem" class="form-label">Товар *</label>
|
<label for="incomingItem" class="form-label">Позиция *</label>
|
||||||
<select class="form-select" id="incomingItem" required>
|
<select class="form-select" id="incomingItem" required>
|
||||||
<option value="">Выберите товар</option>
|
<option value="">Выберите позицию</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -348,9 +422,9 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="outgoingForm">
|
<form id="outgoingForm">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="outgoingItem" class="form-label">Товар *</label>
|
<label for="outgoingItem" class="form-label">Позиция *</label>
|
||||||
<select class="form-select" id="outgoingItem" required>
|
<select class="form-select" id="outgoingItem" required>
|
||||||
<option value="">Выберите товар</option>
|
<option value="">Выберите позицию</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -383,10 +457,10 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h5>Система управления складом</h5>
|
<h5>Система управления складом</h5>
|
||||||
<p class="mb-0">Эффективное управление товарными запасами</p>
|
<p class="mb-0">Эффективное управление складом деталей</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
<div class="col-md-6 text-md-end">
|
||||||
<p class="mb-0">© 2024 Склад-Менеджер. Все права защищены.</p>
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
141
js/auth.js
141
js/auth.js
@ -39,14 +39,17 @@ class AuthSystem {
|
|||||||
try {
|
try {
|
||||||
this.currentUser = JSON.parse(userData);
|
this.currentUser = JSON.parse(userData);
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
|
// Пользователь авторизован
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке данных пользователя:', error);
|
// Ошибка при загрузке данных пользователя
|
||||||
this.logout();
|
this.logout();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Пользователь не авторизован
|
||||||
// Если нет сессии и мы не на странице входа, перенаправляем
|
// Если нет сессии и мы не на странице входа, перенаправляем
|
||||||
if (!window.location.pathname.includes('login.html')) {
|
if (!window.location.pathname.includes('login.html')) {
|
||||||
|
// Перенаправление на страницу входа
|
||||||
window.location.href = 'login.html';
|
window.location.href = 'login.html';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,8 +57,8 @@ class AuthSystem {
|
|||||||
|
|
||||||
// Вход в систему
|
// Вход в систему
|
||||||
login() {
|
login() {
|
||||||
const username = $('#username').val();
|
const username = $('#username').val().trim();
|
||||||
const password = $('#password').val();
|
const password = $('#password').val().trim();
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
this.showNotification('Пожалуйста, заполните все поля', 'warning');
|
this.showNotification('Пожалуйста, заполните все поля', 'warning');
|
||||||
@ -77,11 +80,24 @@ class AuthSystem {
|
|||||||
|
|
||||||
this.showNotification('Успешный вход в систему', 'success');
|
this.showNotification('Успешный вход в систему', 'success');
|
||||||
|
|
||||||
|
// Скрываем индикатор загрузки и восстанавливаем кнопку
|
||||||
|
$('#loginLoading').hide();
|
||||||
|
$('#loginBtn').prop('disabled', false);
|
||||||
|
|
||||||
// Перенаправляем на главную страницу
|
// Перенаправляем на главную страницу
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = 'index.html';
|
window.location.href = 'index.html';
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
|
// Скрываем индикатор загрузки и восстанавливаем кнопку
|
||||||
|
$('#loginLoading').hide();
|
||||||
|
$('#loginBtn').prop('disabled', false);
|
||||||
|
|
||||||
|
// Показываем ошибку в полях
|
||||||
|
$('#username').addClass('is-invalid');
|
||||||
|
$('#password').addClass('is-invalid');
|
||||||
|
$('#usernameError').text('Неверное имя пользователя или пароль').show();
|
||||||
|
|
||||||
this.showNotification('Неверное имя пользователя или пароль', 'danger');
|
this.showNotification('Неверное имя пользователя или пароль', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,18 +117,29 @@ class AuthSystem {
|
|||||||
|
|
||||||
// Обновление интерфейса в зависимости от прав доступа
|
// Обновление интерфейса в зависимости от прав доступа
|
||||||
updateUI() {
|
updateUI() {
|
||||||
if (!this.currentUser) return;
|
if (!this.currentUser) {
|
||||||
|
// Нет текущего пользователя для обновления UI
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI для пользователя
|
||||||
|
|
||||||
// Обновляем информацию о пользователе в навигации
|
// Обновляем информацию о пользователе в навигации
|
||||||
$('#profileDropdown .dropdown-toggle').html(`
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
<i class="fas fa-user me-1"></i>${this.currentUser.name}
|
if (profileToggle.length) {
|
||||||
`);
|
profileToggle.html(`<i class="fas fa-user me-1"></i>${this.currentUser.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем профиль для всех авторизованных пользователей
|
||||||
|
$('#profileDropdown').show();
|
||||||
|
|
||||||
// Показываем/скрываем элементы администрирования
|
// Показываем/скрываем элементы администрирования
|
||||||
if (this.hasRole('admin')) {
|
if (this.hasRole('admin')) {
|
||||||
$('#adminDropdown').show();
|
$('#adminDropdown').show();
|
||||||
|
// Показана панель администратора
|
||||||
} else {
|
} else {
|
||||||
$('#adminDropdown').hide();
|
$('#adminDropdown').hide();
|
||||||
|
// Скрыта панель администратора
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем права доступа для кнопок
|
// Обновляем права доступа для кнопок
|
||||||
@ -148,24 +175,108 @@ class AuthSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление прав доступа для кнопок
|
// Обновление прав доступа для кнопок и элементов интерфейса
|
||||||
updateButtonPermissions() {
|
updateButtonPermissions() {
|
||||||
// Кнопки, доступные только администраторам
|
// Элементы, доступные только администраторам
|
||||||
const adminButtons = [
|
const adminElements = [
|
||||||
'#addItemBtn',
|
'#addItemBtn',
|
||||||
'#editItemBtn',
|
'#editItemBtn',
|
||||||
'#deleteItemBtn',
|
'#deleteItemBtn',
|
||||||
'#manageUsersBtn'
|
'#manageUsersBtn',
|
||||||
|
'.admin-only',
|
||||||
|
'[data-role="admin"]'
|
||||||
];
|
];
|
||||||
|
|
||||||
adminButtons.forEach(buttonId => {
|
// Элементы, доступные администраторам и менеджерам
|
||||||
|
const managerElements = [
|
||||||
|
'.manager-or-admin',
|
||||||
|
'[data-role="manager"]',
|
||||||
|
'#addCategoryBtn',
|
||||||
|
'#editCategoryBtn'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Элементы, доступные всем авторизованным пользователям
|
||||||
|
const authenticatedElements = [
|
||||||
|
'.authenticated-only',
|
||||||
|
'[data-role="authenticated"]',
|
||||||
|
'#exportBtn',
|
||||||
|
'#searchBtn'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Скрываем/показываем элементы для администраторов
|
||||||
|
adminElements.forEach(selector => {
|
||||||
|
const elements = $(selector);
|
||||||
|
elements.each((index, element) => {
|
||||||
|
const $element = $(element);
|
||||||
|
if (this.hasRole('admin')) {
|
||||||
|
$element.show();
|
||||||
|
$element.prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$element.hide();
|
||||||
|
$element.prop('disabled', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Скрываем/показываем элементы для менеджеров и администраторов
|
||||||
|
managerElements.forEach(selector => {
|
||||||
|
const elements = $(selector);
|
||||||
|
elements.each((index, element) => {
|
||||||
|
const $element = $(element);
|
||||||
|
if (this.hasRole('admin') || this.hasRole('manager')) {
|
||||||
|
$element.show();
|
||||||
|
$element.prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$element.hide();
|
||||||
|
$element.prop('disabled', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Скрываем/показываем элементы для авторизованных пользователей
|
||||||
|
authenticatedElements.forEach(selector => {
|
||||||
|
const elements = $(selector);
|
||||||
|
elements.each((index, element) => {
|
||||||
|
const $element = $(element);
|
||||||
|
if (this.isUserAuthenticated()) {
|
||||||
|
$element.show();
|
||||||
|
$element.prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$element.hide();
|
||||||
|
$element.prop('disabled', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Специальная обработка для кнопок с подсказками
|
||||||
|
this.updateButtonTooltips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление подсказок для кнопок
|
||||||
|
updateButtonTooltips() {
|
||||||
|
// Кнопки с подсказками о правах доступа
|
||||||
|
const buttonPermissions = {
|
||||||
|
'#addItemBtn': { roles: ['admin', 'manager'], message: 'Требуются права администратора или менеджера' },
|
||||||
|
'#editItemBtn': { roles: ['admin', 'manager'], message: 'Требуются права администратора или менеджера' },
|
||||||
|
'#deleteItemBtn': { roles: ['admin'], message: 'Требуются права администратора' },
|
||||||
|
'#addCategoryBtn': { roles: ['admin', 'manager'], message: 'Требуются права администратора или менеджера' },
|
||||||
|
'#editCategoryBtn': { roles: ['admin', 'manager'], message: 'Требуются права администратора или менеджера' },
|
||||||
|
'#deleteCategoryBtn': { roles: ['admin'], message: 'Требуются права администратора' },
|
||||||
|
'#exportBtn': { roles: ['admin', 'manager', 'operator', 'viewer'], message: 'Требуется авторизация' }
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(buttonPermissions).forEach(buttonId => {
|
||||||
const button = $(buttonId);
|
const button = $(buttonId);
|
||||||
if (button.length) {
|
if (button.length) {
|
||||||
if (this.hasRole('admin')) {
|
const permission = buttonPermissions[buttonId];
|
||||||
|
const hasAccess = permission.roles.some(role => this.hasRole(role));
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
button.prop('disabled', false);
|
button.prop('disabled', false);
|
||||||
|
button.removeAttr('title');
|
||||||
} else {
|
} else {
|
||||||
button.prop('disabled', true);
|
button.prop('disabled', true);
|
||||||
button.attr('title', 'Требуются права администратора');
|
button.attr('title', permission.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -188,7 +299,7 @@ class AuthSystem {
|
|||||||
|
|
||||||
// Проверка аутентификации
|
// Проверка аутентификации
|
||||||
isUserAuthenticated() {
|
isUserAuthenticated() {
|
||||||
return this.isAuthenticated;
|
return this.isAuthenticated && this.currentUser !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение списка пользователей (имитация базы данных)
|
// Получение списка пользователей (имитация базы данных)
|
||||||
|
|||||||
300
js/catalog.js
300
js/catalog.js
@ -1,4 +1,4 @@
|
|||||||
// JavaScript для страницы каталога товаров
|
// JavaScript для страницы каталога позиций
|
||||||
|
|
||||||
class CatalogManager {
|
class CatalogManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -14,10 +14,45 @@ class CatalogManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
$(document).ready(() => {
|
$(document).ready(async () => {
|
||||||
this.loadCatalog();
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
console.log('Пользователь не авторизован, перенаправление на login.html');
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Инициализация каталога для пользователя:', auth.getCurrentUser()?.name);
|
||||||
|
|
||||||
|
// Загружаем каталог
|
||||||
|
await this.loadCatalog();
|
||||||
|
|
||||||
|
// Загружаем категории в навигацию
|
||||||
|
await this.loadCategoriesNav();
|
||||||
|
|
||||||
|
// Загружаем категории в выпадающее меню
|
||||||
|
await this.loadCategoriesDropdown();
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Инициализируем фильтры
|
||||||
this.initializeFilters();
|
this.initializeFilters();
|
||||||
|
|
||||||
|
// Проверяем параметр категории в URL
|
||||||
|
this.handleCategoryFromUrl();
|
||||||
|
|
||||||
|
// Обновляем права доступа
|
||||||
|
this.updatePermissions();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +76,13 @@ class CatalogManager {
|
|||||||
|
|
||||||
// Экспорт
|
// Экспорт
|
||||||
$('#exportBtn').on('click', () => this.exportCatalog());
|
$('#exportBtn').on('click', () => this.exportCatalog());
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка каталога
|
// Загрузка каталога
|
||||||
@ -48,12 +90,17 @@ class CatalogManager {
|
|||||||
try {
|
try {
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
// Загружаем товары
|
// Загружаем позиции
|
||||||
this.currentItems = await this.warehouse.getAllItems();
|
this.currentItems = await this.warehouse.getAllItems();
|
||||||
|
|
||||||
// Загружаем категории
|
// Загружаем категории
|
||||||
const categories = await this.warehouse.getCategories();
|
const categories = await this.warehouse.getCategories();
|
||||||
|
if (categories && categories.length > 0) {
|
||||||
this.populateCategoryFilters(categories);
|
this.populateCategoryFilters(categories);
|
||||||
|
} else {
|
||||||
|
console.warn('Категории не найдены');
|
||||||
|
auth.showNotification('Категории не загружены', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
// Применяем фильтры и рендерим
|
// Применяем фильтры и рендерим
|
||||||
this.applyFilters();
|
this.applyFilters();
|
||||||
@ -65,6 +112,9 @@ class CatalogManager {
|
|||||||
console.error('Ошибка при загрузке каталога:', error);
|
console.error('Ошибка при загрузке каталога:', error);
|
||||||
auth.showNotification('Ошибка при загрузке каталога', 'danger');
|
auth.showNotification('Ошибка при загрузке каталога', 'danger');
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
} finally {
|
||||||
|
// Проверяем уведомления о низком остатке
|
||||||
|
await this.warehouse.checkAndSendNotifications();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,14 +142,24 @@ class CatalogManager {
|
|||||||
addItemCategory.find('option:not(:first)').remove();
|
addItemCategory.find('option:not(:first)').remove();
|
||||||
editItemCategory.find('option:not(:first)').remove();
|
editItemCategory.find('option:not(:first)').remove();
|
||||||
|
|
||||||
|
// Добавляем опцию "Без категории"
|
||||||
|
const uncategorizedOption = `<option value="uncategorized">Без категории</option>`;
|
||||||
|
categoryFilter.append(uncategorizedOption);
|
||||||
|
addItemCategory.append(uncategorizedOption);
|
||||||
|
editItemCategory.append(uncategorizedOption);
|
||||||
|
|
||||||
// Добавляем категории
|
// Добавляем категории
|
||||||
|
if (categories && categories.length > 0) {
|
||||||
categories.forEach(category => {
|
categories.forEach(category => {
|
||||||
|
if (category && category.id && category.name) {
|
||||||
const option = `<option value="${category.id}">${category.name}</option>`;
|
const option = `<option value="${category.id}">${category.name}</option>`;
|
||||||
categoryFilter.append(option);
|
categoryFilter.append(option);
|
||||||
addItemCategory.append(option);
|
addItemCategory.append(option);
|
||||||
editItemCategory.append(option);
|
editItemCategory.append(option);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Обработка поиска
|
// Обработка поиска
|
||||||
handleSearch(query) {
|
handleSearch(query) {
|
||||||
@ -125,7 +185,7 @@ class CatalogManager {
|
|||||||
this.filters.inStock = $('#inStockFilter').is(':checked');
|
this.filters.inStock = $('#inStockFilter').is(':checked');
|
||||||
this.filters.lowStock = $('#lowStockFilter').is(':checked');
|
this.filters.lowStock = $('#lowStockFilter').is(':checked');
|
||||||
|
|
||||||
// Фильтруем товары
|
// Фильтруем позиции
|
||||||
this.filteredItems = this.currentItems.filter(item => {
|
this.filteredItems = this.currentItems.filter(item => {
|
||||||
// Поиск по названию, описанию и штрих-коду
|
// Поиск по названию, описанию и штрих-коду
|
||||||
if (this.filters.search) {
|
if (this.filters.search) {
|
||||||
@ -191,7 +251,7 @@ class CatalogManager {
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<h4>Товары не найдены</h4>
|
<h4>Позиции не найдены</h4>
|
||||||
<p>Попробуйте изменить параметры поиска или фильтры</p>
|
<p>Попробуйте изменить параметры поиска или фильтры</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -205,7 +265,7 @@ class CatalogManager {
|
|||||||
const endIndex = startIndex + this.itemsPerPage;
|
const endIndex = startIndex + this.itemsPerPage;
|
||||||
const pageItems = this.filteredItems.slice(startIndex, endIndex);
|
const pageItems = this.filteredItems.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Рендерим товары
|
// Рендерим позиции
|
||||||
pageItems.forEach(item => {
|
pageItems.forEach(item => {
|
||||||
const itemHtml = this.currentView === 'grid' ?
|
const itemHtml = this.currentView === 'grid' ?
|
||||||
this.createGridItem(item) : this.createListItem(item);
|
this.createGridItem(item) : this.createListItem(item);
|
||||||
@ -216,7 +276,7 @@ class CatalogManager {
|
|||||||
this.renderPagination(totalPages);
|
this.renderPagination(totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создание карточки товара (сетка)
|
// Создание карточки позиции (сетка)
|
||||||
createGridItem(item) {
|
createGridItem(item) {
|
||||||
const category = this.getCategoryInfo(item.category);
|
const category = this.getCategoryInfo(item.category);
|
||||||
const stockStatus = this.getStockStatus(item);
|
const stockStatus = this.getStockStatus(item);
|
||||||
@ -248,7 +308,7 @@ class CatalogManager {
|
|||||||
<button class="btn btn-outline-primary btn-sm" onclick="catalogManager.viewItem(${item.id})">
|
<button class="btn btn-outline-primary btn-sm" onclick="catalogManager.viewItem(${item.id})">
|
||||||
<i class="fas fa-eye me-1"></i>Просмотр
|
<i class="fas fa-eye me-1"></i>Просмотр
|
||||||
</button>
|
</button>
|
||||||
${auth.hasPermission('write') ? `
|
${(auth.hasRole('admin') || auth.hasRole('manager')) ? `
|
||||||
<button class="btn btn-outline-warning btn-sm" onclick="catalogManager.editItem(${item.id})">
|
<button class="btn btn-outline-warning btn-sm" onclick="catalogManager.editItem(${item.id})">
|
||||||
<i class="fas fa-edit me-1"></i>Редактировать
|
<i class="fas fa-edit me-1"></i>Редактировать
|
||||||
</button>
|
</button>
|
||||||
@ -260,7 +320,7 @@ class CatalogManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создание строки товара (список)
|
// Создание строки позиции (список)
|
||||||
createListItem(item) {
|
createListItem(item) {
|
||||||
const category = this.getCategoryInfo(item.category);
|
const category = this.getCategoryInfo(item.category);
|
||||||
const stockStatus = this.getStockStatus(item);
|
const stockStatus = this.getStockStatus(item);
|
||||||
@ -300,12 +360,12 @@ class CatalogManager {
|
|||||||
<li><a class="dropdown-item" href="#" onclick="catalogManager.viewItem(${item.id})">
|
<li><a class="dropdown-item" href="#" onclick="catalogManager.viewItem(${item.id})">
|
||||||
<i class="fas fa-eye me-2"></i>Просмотр
|
<i class="fas fa-eye me-2"></i>Просмотр
|
||||||
</a></li>
|
</a></li>
|
||||||
${auth.hasPermission('write') ? `
|
${(auth.hasRole('admin') || auth.hasRole('manager')) ? `
|
||||||
<li><a class="dropdown-item" href="#" onclick="catalogManager.editItem(${item.id})">
|
<li><a class="dropdown-item" href="#" onclick="catalogManager.editItem(${item.id})">
|
||||||
<i class="fas fa-edit me-2"></i>Редактировать
|
<i class="fas fa-edit me-2"></i>Редактировать
|
||||||
</a></li>
|
</a></li>
|
||||||
` : ''}
|
` : ''}
|
||||||
${auth.hasPermission('delete') ? `
|
${auth.hasRole('admin') ? `
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item text-danger" href="#" onclick="catalogManager.deleteItem(${item.id})">
|
<li><a class="dropdown-item text-danger" href="#" onclick="catalogManager.deleteItem(${item.id})">
|
||||||
<i class="fas fa-trash me-2"></i>Удалить
|
<i class="fas fa-trash me-2"></i>Удалить
|
||||||
@ -370,10 +430,14 @@ class CatalogManager {
|
|||||||
|
|
||||||
// Обновление статистики
|
// Обновление статистики
|
||||||
updateStatistics() {
|
updateStatistics() {
|
||||||
|
if (!this.filteredItems) {
|
||||||
|
this.filteredItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
const totalItems = this.filteredItems.length;
|
const totalItems = this.filteredItems.length;
|
||||||
const inStockItems = this.filteredItems.filter(item => item.quantity > 0).length;
|
const inStockItems = this.filteredItems.filter(item => item && item.quantity > 0).length;
|
||||||
const lowStockItems = this.filteredItems.filter(item => item.quantity <= item.minQuantity).length;
|
const lowStockItems = this.filteredItems.filter(item => item && item.quantity <= (item.minQuantity || 0)).length;
|
||||||
const categories = [...new Set(this.filteredItems.map(item => item.category))].length;
|
const categories = [...new Set(this.filteredItems.filter(item => item && item.category).map(item => item.category))].length;
|
||||||
|
|
||||||
$('#totalItemsCount').text(totalItems);
|
$('#totalItemsCount').text(totalItems);
|
||||||
$('#inStockCount').text(inStockItems);
|
$('#inStockCount').text(inStockItems);
|
||||||
@ -383,17 +447,21 @@ class CatalogManager {
|
|||||||
|
|
||||||
// Получение информации о категории
|
// Получение информации о категории
|
||||||
getCategoryInfo(categoryId) {
|
getCategoryInfo(categoryId) {
|
||||||
const categories = [
|
if (!categoryId || categoryId === 'uncategorized') {
|
||||||
{ id: 'electronics', name: 'Электроника', icon: 'fas fa-laptop' },
|
return { id: 'uncategorized', name: 'Без категории', icon: 'fas fa-box' };
|
||||||
{ id: 'clothing', name: 'Одежда', icon: 'fas fa-tshirt' },
|
}
|
||||||
{ id: 'tools', name: 'Инструменты', icon: 'fas fa-tools' },
|
|
||||||
{ id: 'office', name: 'Офисные принадлежности', icon: 'fas fa-briefcase' },
|
|
||||||
{ id: 'furniture', name: 'Мебель', icon: 'fas fa-couch' },
|
|
||||||
{ id: 'books', name: 'Книги', icon: 'fas fa-book' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return categories.find(cat => cat.id === categoryId) ||
|
// Используем категории из warehouse
|
||||||
{ id: 'unknown', name: 'Неизвестная категория', icon: 'fas fa-box' };
|
const category = this.warehouse.categories.find(cat => cat.id === categoryId);
|
||||||
|
if (category) {
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
icon: category.icon || 'fas fa-box'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: 'unknown', name: 'Неизвестная категория', icon: 'fas fa-box' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение статуса запаса
|
// Получение статуса запаса
|
||||||
@ -407,16 +475,16 @@ class CatalogManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Просмотр товара
|
// Просмотр позиции
|
||||||
viewItem(itemId) {
|
viewItem(itemId) {
|
||||||
const item = this.currentItems.find(i => i.id === itemId);
|
const item = this.currentItems.find(i => i.id === itemId);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
// Здесь можно открыть модальное окно с подробной информацией
|
// Здесь можно открыть модальное окно с подробной информацией
|
||||||
auth.showNotification(`Просмотр товара: ${item.name}`, 'info');
|
auth.showNotification(`Просмотр позиции: ${item.name}`, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Редактирование товара
|
// Редактирование позиции
|
||||||
async editItem(itemId) {
|
async editItem(itemId) {
|
||||||
const item = this.currentItems.find(i => i.id === itemId);
|
const item = this.currentItems.find(i => i.id === itemId);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@ -467,19 +535,19 @@ class CatalogManager {
|
|||||||
this.currentItems[itemIndex] = { ...this.currentItems[itemIndex], ...updateData };
|
this.currentItems[itemIndex] = { ...this.currentItems[itemIndex], ...updateData };
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.showNotification('Товар успешно обновлен', 'success');
|
auth.showNotification('Позиция успешно обновлена', 'success');
|
||||||
|
|
||||||
// Закрываем модальное окно и обновляем каталог
|
// Закрываем модальное окно и обновляем каталог
|
||||||
$('#editItemModal').modal('hide');
|
$('#editItemModal').modal('hide');
|
||||||
this.applyFilters();
|
this.applyFilters();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при обновлении товара:', error);
|
console.error('Ошибка при обновлении позиции:', error);
|
||||||
auth.showNotification('Ошибка при обновлении товара', 'danger');
|
auth.showNotification('Ошибка при обновлении позиции', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавление нового товара
|
// Добавление новой позиции
|
||||||
async addNewItem() {
|
async addNewItem() {
|
||||||
const formData = this.getFormData('#addItemForm');
|
const formData = this.getFormData('#addItemForm');
|
||||||
|
|
||||||
@ -493,7 +561,7 @@ class CatalogManager {
|
|||||||
// Добавляем в локальные данные
|
// Добавляем в локальные данные
|
||||||
this.currentItems.push(newItem);
|
this.currentItems.push(newItem);
|
||||||
|
|
||||||
auth.showNotification('Товар успешно добавлен', 'success');
|
auth.showNotification('Позиция успешно добавлена', 'success');
|
||||||
|
|
||||||
// Закрываем модальное окно и обновляем каталог
|
// Закрываем модальное окно и обновляем каталог
|
||||||
$('#addItemModal').modal('hide');
|
$('#addItemModal').modal('hide');
|
||||||
@ -503,14 +571,14 @@ class CatalogManager {
|
|||||||
$('#addItemForm')[0].reset();
|
$('#addItemForm')[0].reset();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при добавлении товара:', error);
|
console.error('Ошибка при добавлении позиции:', error);
|
||||||
auth.showNotification('Ошибка при добавлении товара', 'danger');
|
auth.showNotification('Ошибка при добавлении позиции', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление товара
|
// Удаление позиции
|
||||||
async deleteItem(itemId) {
|
async deleteItem(itemId) {
|
||||||
if (!confirm('Вы уверены, что хотите удалить этот товар?')) {
|
if (!confirm('Вы уверены, что хотите удалить эту позицию?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,12 +588,12 @@ class CatalogManager {
|
|||||||
// Удаляем из локальных данных
|
// Удаляем из локальных данных
|
||||||
this.currentItems = this.currentItems.filter(i => i.id !== itemId);
|
this.currentItems = this.currentItems.filter(i => i.id !== itemId);
|
||||||
|
|
||||||
auth.showNotification('Товар успешно удален', 'success');
|
auth.showNotification('Позиция успешно удалена', 'success');
|
||||||
this.applyFilters();
|
this.applyFilters();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при удалении товара:', error);
|
console.error('Ошибка при удалении позиции:', error);
|
||||||
auth.showNotification('Ошибка при удалении товара', 'danger');
|
auth.showNotification('Ошибка при удалении позиции', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -632,6 +700,158 @@ class CatalogManager {
|
|||||||
hideLoading() {
|
hideLoading() {
|
||||||
// Загрузка завершается в renderCatalog()
|
// Загрузка завершается в renderCatalog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
updateUserInfo() {
|
||||||
|
const user = auth.getCurrentUser();
|
||||||
|
console.log('Обновление информации о пользователе в каталоге:', user);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Обновляем имя пользователя в профиле
|
||||||
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
|
if (profileToggle.length) {
|
||||||
|
profileToggle.html(`<i class="fas fa-user me-1"></i>${user.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем профиль для всех авторизованных пользователей
|
||||||
|
$('#profileDropdown').show();
|
||||||
|
|
||||||
|
// Показываем админ панель только для администраторов
|
||||||
|
if (auth.hasRole('admin')) {
|
||||||
|
$('#adminDropdown').show();
|
||||||
|
console.log('Показана панель администратора в каталоге');
|
||||||
|
} else {
|
||||||
|
$('#adminDropdown').hide();
|
||||||
|
console.log('Скрыта панель администратора в каталоге');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Пользователь не авторизован в каталоге');
|
||||||
|
$('#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 = `
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
${category.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
categoriesNav.append(categoryLink);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки категорий:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление прав доступа
|
||||||
|
updatePermissions() {
|
||||||
|
// Используем централизованную систему управления правами из auth.js
|
||||||
|
auth.updateButtonPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка категорий в выпадающее меню
|
||||||
|
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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка параметра категории из URL
|
||||||
|
handleCategoryFromUrl() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const categoryParam = urlParams.get('category');
|
||||||
|
|
||||||
|
if (categoryParam) {
|
||||||
|
// Устанавливаем фильтр категории
|
||||||
|
$('#categoryFilter').val(categoryParam);
|
||||||
|
|
||||||
|
// Применяем фильтры
|
||||||
|
this.applyFilters();
|
||||||
|
|
||||||
|
// Показываем уведомление о примененном фильтре
|
||||||
|
const categoryInfo = this.getCategoryInfo(categoryParam);
|
||||||
|
auth.showNotification(`Отфильтровано по категории: ${categoryInfo.name}`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация менеджера каталога
|
// Инициализация менеджера каталога
|
||||||
|
|||||||
451
js/categories.js
Normal file
451
js/categories.js
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
// JavaScript для управления категориями
|
||||||
|
class CategoryManager {
|
||||||
|
constructor() {
|
||||||
|
this.warehouse = new WarehouseManager();
|
||||||
|
this.categories = [];
|
||||||
|
this.currentCategory = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
console.log('Пользователь не авторизован, перенаправление на login.html');
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права доступа
|
||||||
|
if (!auth.hasRole('admin')) {
|
||||||
|
console.log('Пользователь не имеет прав администратора');
|
||||||
|
auth.showNotification('Недостаточно прав для доступа к этой странице', 'danger');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Инициализация управления категориями для администратора:', auth.getCurrentUser()?.name);
|
||||||
|
|
||||||
|
// Загружаем данные
|
||||||
|
await this.loadCategories();
|
||||||
|
this.updateStatistics();
|
||||||
|
this.renderCategoriesTable();
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Загружаем категории в выпадающее меню
|
||||||
|
await this.loadCategoriesDropdown();
|
||||||
|
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка категорий
|
||||||
|
async loadCategories() {
|
||||||
|
this.categories = await this.warehouse.getCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
updateStatistics() {
|
||||||
|
const totalCategories = this.categories.length;
|
||||||
|
const activeCategories = this.categories.filter(cat => cat.active).length;
|
||||||
|
const emptyCategories = this.categories.filter(cat => cat.itemCount === 0).length;
|
||||||
|
|
||||||
|
// Получаем общее количество позиций
|
||||||
|
const allItems = this.warehouse.getAllItemsSync();
|
||||||
|
const totalItems = allItems.length;
|
||||||
|
|
||||||
|
$('#totalCategories').text(totalCategories);
|
||||||
|
$('#totalItems').text(totalItems);
|
||||||
|
$('#activeCategories').text(activeCategories);
|
||||||
|
$('#emptyCategories').text(emptyCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг таблицы категорий
|
||||||
|
renderCategoriesTable() {
|
||||||
|
const tbody = $('#categoriesTable tbody');
|
||||||
|
tbody.empty();
|
||||||
|
|
||||||
|
if (this.categories.length === 0) {
|
||||||
|
tbody.append(`
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-4">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-tags fa-3x text-muted mb-3"></i>
|
||||||
|
<h5>Категории не найдены</h5>
|
||||||
|
<p class="text-muted">Добавьте первую категорию для начала работы</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.categories.forEach(category => {
|
||||||
|
const row = this.createCategoryRow(category);
|
||||||
|
tbody.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание строки категории
|
||||||
|
createCategoryRow(category) {
|
||||||
|
const statusBadge = category.active ?
|
||||||
|
'<span class="badge bg-success">Активна</span>' :
|
||||||
|
'<span class="badge bg-secondary">Неактивна</span>';
|
||||||
|
|
||||||
|
const iconPreview = category.icon ?
|
||||||
|
`<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>` :
|
||||||
|
'<i class="fas fa-tag text-muted"></i>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-category-id="${category.id}">
|
||||||
|
<td>${category.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="me-2">${iconPreview}</span>
|
||||||
|
<strong>${category.name}</strong>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${category.description || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<code>${category.icon || 'fas fa-tag'}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">${category.itemCount || 0}</span>
|
||||||
|
</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="categoryManager.editCategory('${category.id}')" title="Редактировать">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="categoryManager.deleteCategory('${category.id}')" title="Удалить">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка событий
|
||||||
|
bindEvents() {
|
||||||
|
// Добавление категории
|
||||||
|
$('#saveCategoryBtn').on('click', () => this.addCategory());
|
||||||
|
|
||||||
|
// Редактирование категории
|
||||||
|
$('#updateCategoryBtn').on('click', () => this.updateCategory());
|
||||||
|
|
||||||
|
// Очистка формы при закрытии модального окна
|
||||||
|
$('#addCategoryModal').on('hidden.bs.modal', () => this.clearAddForm());
|
||||||
|
$('#editCategoryModal').on('hidden.bs.modal', () => this.clearEditForm());
|
||||||
|
|
||||||
|
// Валидация формы
|
||||||
|
$('#addCategoryForm').on('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addCategory();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#editCategoryForm').on('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.updateCategory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление категории
|
||||||
|
async addCategory() {
|
||||||
|
try {
|
||||||
|
const formData = this.getAddFormData();
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
auth.showNotification('Введите название категории', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем уникальность названия
|
||||||
|
const existingCategory = this.categories.find(cat =>
|
||||||
|
cat.name.toLowerCase() === formData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
auth.showNotification('Категория с таким названием уже существует', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCategory = await this.warehouse.addCategory(formData);
|
||||||
|
this.categories.push(newCategory);
|
||||||
|
|
||||||
|
this.updateStatistics();
|
||||||
|
this.renderCategoriesTable();
|
||||||
|
|
||||||
|
$('#addCategoryModal').modal('hide');
|
||||||
|
auth.showNotification('Категория успешно добавлена', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при добавлении категории:', error);
|
||||||
|
auth.showNotification('Ошибка при добавлении категории', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактирование категории
|
||||||
|
async editCategory(categoryId) {
|
||||||
|
try {
|
||||||
|
const category = this.categories.find(cat => cat.id === categoryId);
|
||||||
|
if (!category) {
|
||||||
|
auth.showNotification('Категория не найдена', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentCategory = category;
|
||||||
|
this.fillEditForm(category);
|
||||||
|
$('#editCategoryModal').modal('show');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при редактировании категории:', error);
|
||||||
|
auth.showNotification('Ошибка при загрузке категории', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление категории
|
||||||
|
async updateCategory() {
|
||||||
|
try {
|
||||||
|
if (!this.currentCategory) {
|
||||||
|
auth.showNotification('Категория не выбрана', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = this.getEditFormData();
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
auth.showNotification('Введите название категории', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем уникальность названия (исключая текущую категорию)
|
||||||
|
const existingCategory = this.categories.find(cat =>
|
||||||
|
cat.id !== this.currentCategory.id &&
|
||||||
|
cat.name.toLowerCase() === formData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
auth.showNotification('Категория с таким названием уже существует', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCategory = await this.warehouse.updateCategory(this.currentCategory.id, formData);
|
||||||
|
|
||||||
|
// Обновляем категорию в списке
|
||||||
|
const index = this.categories.findIndex(cat => cat.id === this.currentCategory.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.categories[index] = updatedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateStatistics();
|
||||||
|
this.renderCategoriesTable();
|
||||||
|
|
||||||
|
$('#editCategoryModal').modal('hide');
|
||||||
|
auth.showNotification('Категория успешно обновлена', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при обновлении категории:', error);
|
||||||
|
auth.showNotification('Ошибка при обновлении категории', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление категории
|
||||||
|
async deleteCategory(categoryId) {
|
||||||
|
try {
|
||||||
|
const category = this.categories.find(cat => cat.id === categoryId);
|
||||||
|
if (!category) {
|
||||||
|
auth.showNotification('Категория не найдена', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли позиции в этой категории
|
||||||
|
if (category.itemCount > 0) {
|
||||||
|
const confirmDelete = confirm(
|
||||||
|
`В категории "${category.name}" есть ${category.itemCount} позиций. ` +
|
||||||
|
'Удаление категории приведет к удалению всех позиций. Продолжить?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const confirmDelete = confirm(`Вы уверены, что хотите удалить категорию "${category.name}"?`);
|
||||||
|
if (!confirmDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.warehouse.deleteCategory(categoryId);
|
||||||
|
|
||||||
|
// Удаляем категорию из списка
|
||||||
|
this.categories = this.categories.filter(cat => cat.id !== categoryId);
|
||||||
|
|
||||||
|
this.updateStatistics();
|
||||||
|
this.renderCategoriesTable();
|
||||||
|
|
||||||
|
auth.showNotification('Категория успешно удалена', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при удалении категории:', error);
|
||||||
|
auth.showNotification('Ошибка при удалении категории', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных формы добавления
|
||||||
|
getAddFormData() {
|
||||||
|
return {
|
||||||
|
name: $('#categoryName').val().trim(),
|
||||||
|
description: $('#categoryDescription').val().trim(),
|
||||||
|
icon: $('#categoryIcon').val().trim() || 'fas fa-tag',
|
||||||
|
color: $('#categoryColor').val(),
|
||||||
|
active: $('#categoryActive').is(':checked')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных формы редактирования
|
||||||
|
getEditFormData() {
|
||||||
|
return {
|
||||||
|
name: $('#editCategoryName').val().trim(),
|
||||||
|
description: $('#editCategoryDescription').val().trim(),
|
||||||
|
icon: $('#editCategoryIcon').val().trim() || 'fas fa-tag',
|
||||||
|
color: $('#editCategoryColor').val(),
|
||||||
|
active: $('#editCategoryActive').is(':checked')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполнение формы редактирования
|
||||||
|
fillEditForm(category) {
|
||||||
|
$('#editCategoryId').val(category.id);
|
||||||
|
$('#editCategoryName').val(category.name);
|
||||||
|
$('#editCategoryDescription').val(category.description || '');
|
||||||
|
$('#editCategoryIcon').val(category.icon || 'fas fa-tag');
|
||||||
|
$('#editCategoryColor').val(category.color || '#2c5aa0');
|
||||||
|
$('#editCategoryActive').prop('checked', category.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка формы добавления
|
||||||
|
clearAddForm() {
|
||||||
|
$('#addCategoryForm')[0].reset();
|
||||||
|
$('#categoryColor').val('#2c5aa0');
|
||||||
|
$('#categoryActive').prop('checked', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка формы редактирования
|
||||||
|
clearEditForm() {
|
||||||
|
$('#editCategoryForm')[0].reset();
|
||||||
|
$('#editCategoryColor').val('#2c5aa0');
|
||||||
|
this.currentCategory = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
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 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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="../catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
let categoryManager;
|
||||||
|
|
||||||
|
$(document).ready(() => {
|
||||||
|
categoryManager = new CategoryManager();
|
||||||
|
|
||||||
|
// Обработка выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
});
|
||||||
|
});
|
||||||
170
js/history.js
170
js/history.js
@ -13,10 +13,39 @@ class HistoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
$(document).ready(() => {
|
$(document).ready(async () => {
|
||||||
this.loadHistory();
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
console.log('Пользователь не авторизован, перенаправление на login.html');
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Инициализация истории для пользователя:', auth.getCurrentUser()?.name);
|
||||||
|
|
||||||
|
// Загружаем историю операций
|
||||||
|
await this.loadHistory();
|
||||||
|
|
||||||
|
// Загружаем категории в навигацию
|
||||||
|
await this.loadCategoriesNav();
|
||||||
|
|
||||||
|
// Загружаем категории в выпадающее меню
|
||||||
|
await this.loadCategoriesDropdown();
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Инициализируем фильтры
|
||||||
this.initializeFilters();
|
this.initializeFilters();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +61,13 @@ class HistoryManager {
|
|||||||
$('#exportBtn').on('click', () => this.exportOperations());
|
$('#exportBtn').on('click', () => this.exportOperations());
|
||||||
$('#generateReportBtn').on('click', () => this.generateReport());
|
$('#generateReportBtn').on('click', () => this.generateReport());
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
|
|
||||||
// Установка дат по умолчанию
|
// Установка дат по умолчанию
|
||||||
this.setDefaultDates();
|
this.setDefaultDates();
|
||||||
}
|
}
|
||||||
@ -44,7 +80,7 @@ class HistoryManager {
|
|||||||
// Загружаем операции
|
// Загружаем операции
|
||||||
this.operations = await this.warehouse.getOperations();
|
this.operations = await this.warehouse.getOperations();
|
||||||
|
|
||||||
// Загружаем товары для фильтра
|
// Загружаем позиции для фильтра
|
||||||
const items = await this.warehouse.getAllItems();
|
const items = await this.warehouse.getAllItems();
|
||||||
this.populateItemFilter(items);
|
this.populateItemFilter(items);
|
||||||
|
|
||||||
@ -89,7 +125,7 @@ class HistoryManager {
|
|||||||
$('#reportEndDate').val(today.toISOString().split('T')[0]);
|
$('#reportEndDate').val(today.toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заполнение фильтра товаров
|
// Заполнение фильтра позиций
|
||||||
populateItemFilter(items) {
|
populateItemFilter(items) {
|
||||||
const itemFilter = $('#itemFilter');
|
const itemFilter = $('#itemFilter');
|
||||||
const reportItem = $('#reportItem');
|
const reportItem = $('#reportItem');
|
||||||
@ -147,7 +183,7 @@ class HistoryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтр по товару
|
// Фильтр по позиции
|
||||||
if (this.filters.itemId && operation.itemId !== parseInt(this.filters.itemId)) {
|
if (this.filters.itemId && operation.itemId !== parseInt(this.filters.itemId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -405,10 +441,10 @@ class HistoryManager {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h6>Детали товара</h6>
|
<h6>Детали позиции</h6>
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Товар:</strong></td>
|
<td><strong>Позиция:</strong></td>
|
||||||
<td>${operation.itemName}</td>
|
<td>${operation.itemName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -614,6 +650,126 @@ class HistoryManager {
|
|||||||
hideLoading() {
|
hideLoading() {
|
||||||
// Загрузка завершается в renderOperations()
|
// Загрузка завершается в renderOperations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
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 = `
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
${category.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация менеджера истории
|
// Инициализация менеджера истории
|
||||||
|
|||||||
190
js/main.js
190
js/main.js
@ -8,10 +8,44 @@ class MainApp {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Инициализация после загрузки DOM
|
// Инициализация после загрузки DOM
|
||||||
$(document).ready(() => {
|
$(document).ready(async () => {
|
||||||
this.loadDashboardData();
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
// Пользователь не авторизован, перенаправление на login.html
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация главной страницы
|
||||||
|
|
||||||
|
// Загружаем данные дашборда
|
||||||
|
await this.loadDashboardData();
|
||||||
|
|
||||||
|
// Загружаем категории в навигацию
|
||||||
|
await this.loadCategoriesNav();
|
||||||
|
|
||||||
|
// Загружаем категории в выпадающее меню
|
||||||
|
await this.loadCategoriesDropdown();
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
// Принудительно обновляем еще раз через небольшую задержку
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateUserInfo();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Инициализируем модальные окна
|
||||||
this.initializeModals();
|
this.initializeModals();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Ошибка инициализации
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +63,13 @@ class MainApp {
|
|||||||
|
|
||||||
// Обработчики для экспорта
|
// Обработчики для экспорта
|
||||||
$('#exportBtn').on('click', () => this.exportData());
|
$('#exportBtn').on('click', () => this.exportData());
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка данных для дашборда
|
// Загрузка данных для дашборда
|
||||||
@ -38,20 +79,23 @@ class MainApp {
|
|||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
// Загружаем статистику
|
// Загружаем статистику
|
||||||
const stats = await this.warehouse.getStatistics();
|
const stats = this.warehouse.getStatistics();
|
||||||
this.updateStatistics(stats);
|
this.updateStatistics(stats);
|
||||||
|
|
||||||
// Загружаем последние операции
|
// Загружаем последние операции
|
||||||
const operations = await this.warehouse.getRecentOperations(10);
|
const operations = await this.warehouse.getRecentOperations(10);
|
||||||
this.updateOperationsTable(operations);
|
this.updateOperationsTable(operations);
|
||||||
|
|
||||||
// Загружаем товары для модальных окон
|
// Загружаем позиции для модальных окон
|
||||||
const items = await this.warehouse.getAllItems();
|
const items = await this.warehouse.getAllItems();
|
||||||
this.populateItemSelects(items);
|
this.populateItemSelects(items);
|
||||||
|
|
||||||
// Скрываем индикатор загрузки
|
// Скрываем индикатор загрузки
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
|
||||||
|
// Проверяем уведомления о низком остатке
|
||||||
|
await this.warehouse.checkAndSendNotifications();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке данных:', error);
|
console.error('Ошибка при загрузке данных:', error);
|
||||||
auth.showNotification('Ошибка при загрузке данных', 'danger');
|
auth.showNotification('Ошибка при загрузке данных', 'danger');
|
||||||
@ -146,7 +190,7 @@ class MainApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заполнение селектов товаров
|
// Заполнение селектов позиций
|
||||||
populateItemSelects(items) {
|
populateItemSelects(items) {
|
||||||
const selects = ['#incomingItem', '#outgoingItem'];
|
const selects = ['#incomingItem', '#outgoingItem'];
|
||||||
|
|
||||||
@ -160,7 +204,7 @@ class MainApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавление нового товара
|
// Добавление новой позиции
|
||||||
async addNewItem() {
|
async addNewItem() {
|
||||||
const formData = this.getFormData('#addItemForm');
|
const formData = this.getFormData('#addItemForm');
|
||||||
|
|
||||||
@ -170,7 +214,7 @@ class MainApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const newItem = await this.warehouse.addItem(formData);
|
const newItem = await this.warehouse.addItem(formData);
|
||||||
auth.showNotification('Товар успешно добавлен', 'success');
|
auth.showNotification('Позиция успешно добавлена', 'success');
|
||||||
|
|
||||||
// Закрываем модальное окно
|
// Закрываем модальное окно
|
||||||
$('#addItemModal').modal('hide');
|
$('#addItemModal').modal('hide');
|
||||||
@ -182,8 +226,8 @@ class MainApp {
|
|||||||
$('#addItemForm')[0].reset();
|
$('#addItemForm')[0].reset();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при добавлении товара:', error);
|
console.error('Ошибка при добавлении позиции:', error);
|
||||||
auth.showNotification('Ошибка при добавлении товара', 'danger');
|
auth.showNotification('Ошибка при добавлении позиции', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,6 +468,134 @@ class MainApp {
|
|||||||
hideLoading() {
|
hideLoading() {
|
||||||
$('#loadingOverlay').remove();
|
$('#loadingOverlay').remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
updateUserInfo() {
|
||||||
|
const user = auth.getCurrentUser();
|
||||||
|
console.log('Обновление информации о пользователе:', user);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Обновляем имя пользователя в профиле
|
||||||
|
const profileToggle = $('#profileDropdown .dropdown-toggle');
|
||||||
|
if (profileToggle.length) {
|
||||||
|
profileToggle.html(`<i class="fas fa-user me-1"></i>${user.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем профиль для всех авторизованных пользователей
|
||||||
|
$('#profileDropdown').show();
|
||||||
|
|
||||||
|
// Показываем админ панель только для администраторов
|
||||||
|
if (auth.hasRole('admin')) {
|
||||||
|
$('#adminDropdown').show();
|
||||||
|
console.log('Показана панель администратора');
|
||||||
|
} else {
|
||||||
|
$('#adminDropdown').hide();
|
||||||
|
console.log('Скрыта панель администратора');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Пользователь не авторизован');
|
||||||
|
$('#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 = `
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
${category.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация приложения
|
// Инициализация приложения
|
||||||
|
|||||||
170
js/profile.js
170
js/profile.js
@ -10,9 +10,36 @@ class ProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
$(document).ready(() => {
|
$(document).ready(async () => {
|
||||||
this.loadProfile();
|
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();
|
this.bindEvents();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +57,13 @@ class ProfileManager {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.saveProfile();
|
this.saveProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка профиля
|
// Загрузка профиля
|
||||||
@ -231,9 +265,6 @@ class ProfileManager {
|
|||||||
$('#emailNotifications').prop('checked', settings.emailNotifications !== false);
|
$('#emailNotifications').prop('checked', settings.emailNotifications !== false);
|
||||||
$('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
|
$('#lowStockAlerts').prop('checked', settings.lowStockAlerts !== false);
|
||||||
$('#operationReports').prop('checked', settings.operationReports || false);
|
$('#operationReports').prop('checked', settings.operationReports || false);
|
||||||
|
|
||||||
$('#themeSelect').val(settings.theme || 'light');
|
|
||||||
$('#languageSelect').val(settings.language || 'ru');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохранение настроек
|
// Сохранение настроек
|
||||||
@ -241,9 +272,7 @@ class ProfileManager {
|
|||||||
const settings = {
|
const settings = {
|
||||||
emailNotifications: $('#emailNotifications').is(':checked'),
|
emailNotifications: $('#emailNotifications').is(':checked'),
|
||||||
lowStockAlerts: $('#lowStockAlerts').is(':checked'),
|
lowStockAlerts: $('#lowStockAlerts').is(':checked'),
|
||||||
operationReports: $('#operationReports').is(':checked'),
|
operationReports: $('#operationReports').is(':checked')
|
||||||
theme: $('#themeSelect').val(),
|
|
||||||
language: $('#languageSelect').val()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('user_settings', JSON.stringify(settings));
|
localStorage.setItem('user_settings', JSON.stringify(settings));
|
||||||
@ -336,6 +365,11 @@ class ProfileManager {
|
|||||||
auth.showNotification('Функция просмотра активности находится в разработке', 'info');
|
auth.showNotification('Функция просмотра активности находится в разработке', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Генерация еженедельного отчета
|
||||||
|
generateWeeklyReport() {
|
||||||
|
this.warehouse.generateWeeklyReports();
|
||||||
|
}
|
||||||
|
|
||||||
// Валидация email
|
// Валидация email
|
||||||
isValidEmail(email) {
|
isValidEmail(email) {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@ -427,6 +461,126 @@ class ProfileManager {
|
|||||||
|
|
||||||
return recommendations;
|
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 = `
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
${category.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация менеджера профиля
|
// Инициализация менеджера профиля
|
||||||
|
|||||||
194
js/reports.js
Normal file
194
js/reports.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// Логика страницы отчетов
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Проверка авторизации
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filters: {
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
opType: '',
|
||||||
|
categoryId: '',
|
||||||
|
itemQuery: '',
|
||||||
|
employeeQuery: ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
summary: null,
|
||||||
|
operations: [],
|
||||||
|
byCategory: [],
|
||||||
|
topItems: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
$(document).ready(() => {
|
||||||
|
bindEvents();
|
||||||
|
loadCategoriesForFilter();
|
||||||
|
applyFilters();
|
||||||
|
loadCategoriesNav();
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$('#filtersForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
state.filters = {
|
||||||
|
dateFrom: $('#dateFrom').val(),
|
||||||
|
dateTo: $('#dateTo').val(),
|
||||||
|
opType: $('#opType').val(),
|
||||||
|
categoryId: $('#categoryId').val(),
|
||||||
|
itemQuery: $('#itemQuery').val(),
|
||||||
|
employeeQuery: $('#employeeQuery').val()
|
||||||
|
};
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#exportCsvBtn').on('click', exportCsv);
|
||||||
|
$('#exportJsonBtn').on('click', exportJson);
|
||||||
|
$('#resetFiltersBtn').on('click', resetFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
try {
|
||||||
|
showLoading(true);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const params = new URLSearchParams(state.filters);
|
||||||
|
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||||
|
|
||||||
|
// Загружаем сводные данные и таблицы
|
||||||
|
const [summaryResp, opsResp, byCatResp, topItemsResp] = await Promise.all([
|
||||||
|
fetch(`/api/reports/summary?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/operations?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/by-category?${params.toString()}`, { headers }),
|
||||||
|
fetch(`/api/reports/top-items?${params.toString()}`, { headers })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summaryResp.ok || !opsResp.ok || !byCatResp.ok || !topItemsResp.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных отчетов');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data.summary = await summaryResp.json();
|
||||||
|
state.data.operations = await opsResp.json();
|
||||||
|
state.data.byCategory = await byCatResp.json();
|
||||||
|
state.data.topItems = await topItemsResp.json();
|
||||||
|
|
||||||
|
renderSummary();
|
||||||
|
renderTables();
|
||||||
|
} catch (e) {
|
||||||
|
auth.showNotification('Ошибка при загрузке отчетов: ' + e.message, 'danger');
|
||||||
|
// Очищаем таблицы при ошибке API
|
||||||
|
state.data = { summary: null, operations: [], byCategory: [], topItems: [] };
|
||||||
|
renderSummary();
|
||||||
|
renderTables();
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
// Сбрасываем значения полей
|
||||||
|
$('#dateFrom').val('');
|
||||||
|
$('#dateTo').val('');
|
||||||
|
$('#opType').val('');
|
||||||
|
$('#categoryId').val('');
|
||||||
|
$('#itemQuery').val('');
|
||||||
|
$('#employeeQuery').val('');
|
||||||
|
// Обнуляем состояние фильтров и перезагружаем
|
||||||
|
state.filters = { dateFrom: '', dateTo: '', opType: '', categoryId: '', itemQuery: '', employeeQuery: '' };
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const s = state.data.summary || { totalIncoming: 0, totalOutgoing: 0, totalOperations: 0, uniqueItems: 0 };
|
||||||
|
$('#totalIncoming').text(s.totalIncoming);
|
||||||
|
$('#totalOutgoing').text(s.totalOutgoing);
|
||||||
|
$('#totalOps').text(s.totalOperations);
|
||||||
|
$('#uniqueItems').text(s.uniqueItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTables() {
|
||||||
|
const opsBody = $('#operationsTable tbody');
|
||||||
|
opsBody.empty();
|
||||||
|
state.data.operations.forEach(op => {
|
||||||
|
opsBody.append(`<tr>
|
||||||
|
<td>${formatDate(op.date)}</td>
|
||||||
|
<td>${op.type === 'incoming' ? 'Приход' : 'Расход'}</td>
|
||||||
|
<td>${escapeHtml(op.itemName)}</td>
|
||||||
|
<td>${op.quantity}</td>
|
||||||
|
<td>${escapeHtml(op.employeeName || op.username || '')}</td>
|
||||||
|
<td>${escapeHtml(op.categoryName || '')}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCatBody = $('#byCategoryTable tbody');
|
||||||
|
byCatBody.empty();
|
||||||
|
state.data.byCategory.forEach(row => {
|
||||||
|
byCatBody.append(`<tr>
|
||||||
|
<td>${escapeHtml(row.categoryName)}</td>
|
||||||
|
<td>${row.itemCount}</td>
|
||||||
|
<td>${row.totalQuantity}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const topItemsBody = $('#topItemsTable tbody');
|
||||||
|
topItemsBody.empty();
|
||||||
|
state.data.topItems.forEach(row => {
|
||||||
|
topItemsBody.append(`<tr>
|
||||||
|
<td>${escapeHtml(row.itemName)}</td>
|
||||||
|
<td>${row.totalIncoming}</td>
|
||||||
|
<td>${row.totalOutgoing}</td>
|
||||||
|
<td>${row.totalIncoming - row.totalOutgoing}</td>
|
||||||
|
</tr>`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const rows = [
|
||||||
|
['Дата','Тип','Товар','Количество','Сотрудник','Категория'],
|
||||||
|
...state.data.operations.map(o => [formatDate(o.date), o.type, o.itemName, o.quantity, o.employeeName || '', o.categoryName || ''])
|
||||||
|
];
|
||||||
|
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
|
||||||
|
downloadFile('operations.csv', 'text/csv;charset=utf-8;', csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const blob = new Blob([JSON.stringify(state.data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'reports.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategoriesForFilter() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/categories');
|
||||||
|
const cats = await resp.json();
|
||||||
|
const select = $('#categoryId');
|
||||||
|
cats.forEach(c => select.append(`<option value="${c.id}">${escapeHtml(c.name)}</option>`));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategoriesNav() {
|
||||||
|
// На странице отчётов категории в меню не критичны; пропустим загрузку, чтобы не блокировать UI
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show) {
|
||||||
|
$('#loadingState').toggle(!!show);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[&<>"]+/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
})();
|
||||||
178
js/search.js
178
js/search.js
@ -16,10 +16,39 @@ class SearchManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
$(document).ready(() => {
|
$(document).ready(async () => {
|
||||||
this.loadCategories();
|
try {
|
||||||
|
// Проверяем авторизацию
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
console.log('Пользователь не авторизован, перенаправление на login.html');
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Инициализация поиска для пользователя:', auth.getCurrentUser()?.name);
|
||||||
|
|
||||||
|
// Загружаем категории
|
||||||
|
await this.loadCategories();
|
||||||
|
|
||||||
|
// Загружаем категории в навигацию
|
||||||
|
await this.loadCategoriesNav();
|
||||||
|
|
||||||
|
// Загружаем категории в выпадающее меню
|
||||||
|
await this.loadCategoriesDropdown();
|
||||||
|
|
||||||
|
// Обновляем информацию о пользователе
|
||||||
|
this.updateUserInfo();
|
||||||
|
|
||||||
|
// Привязываем события
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Инициализируем фильтры
|
||||||
this.initializeFilters();
|
this.initializeFilters();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации:', error);
|
||||||
|
auth.showNotification('Ошибка загрузки данных', 'danger');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +74,13 @@ class SearchManager {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$(e.target).tab('show');
|
$(e.target).tab('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обработчик выхода
|
||||||
|
$('#logoutBtn').on('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.logout();
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка категорий для фильтра
|
// Загрузка категорий для фильтра
|
||||||
@ -113,7 +149,7 @@ class SearchManager {
|
|||||||
this.filters.endDate = $('#endDate').val();
|
this.filters.endDate = $('#endDate').val();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поиск товаров
|
// Поиск позиций
|
||||||
async searchItems(query) {
|
async searchItems(query) {
|
||||||
if (this.filters.searchType === 'operations') {
|
if (this.filters.searchType === 'operations') {
|
||||||
this.searchResults.items = [];
|
this.searchResults.items = [];
|
||||||
@ -218,7 +254,7 @@ class SearchManager {
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-box-open"></i>
|
<i class="fas fa-box-open"></i>
|
||||||
<h5>Товары не найдены</h5>
|
<h5>Позиции не найдены</h5>
|
||||||
<p>Попробуйте изменить поисковый запрос</p>
|
<p>Попробуйте изменить поисковый запрос</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -226,7 +262,7 @@ class SearchManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пагинация для товаров
|
// Пагинация для позиций
|
||||||
const totalPages = Math.ceil(this.searchResults.items.length / this.resultsPerPage);
|
const totalPages = Math.ceil(this.searchResults.items.length / this.resultsPerPage);
|
||||||
const startIndex = (this.currentPage - 1) * this.resultsPerPage;
|
const startIndex = (this.currentPage - 1) * this.resultsPerPage;
|
||||||
const endIndex = startIndex + this.resultsPerPage;
|
const endIndex = startIndex + this.resultsPerPage;
|
||||||
@ -238,7 +274,7 @@ class SearchManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создание карточки товара
|
// Создание карточки позиции
|
||||||
createItemCard(item) {
|
createItemCard(item) {
|
||||||
const category = this.getCategoryInfo(item.category);
|
const category = this.getCategoryInfo(item.category);
|
||||||
const stockStatus = this.getStockStatus(item);
|
const stockStatus = this.getStockStatus(item);
|
||||||
@ -404,7 +440,7 @@ class SearchManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сортировка товаров
|
// Сортировка позиций
|
||||||
this.searchResults.items.sort((a, b) => {
|
this.searchResults.items.sort((a, b) => {
|
||||||
let aValue = a[sortBy];
|
let aValue = a[sortBy];
|
||||||
let bValue = b[sortBy];
|
let bValue = b[sortBy];
|
||||||
@ -475,7 +511,7 @@ class SearchManager {
|
|||||||
csv += `Запрос: ${this.currentQuery}\n`;
|
csv += `Запрос: ${this.currentQuery}\n`;
|
||||||
csv += `Дата поиска: ${new Date().toLocaleDateString('ru-RU')}\n\n`;
|
csv += `Дата поиска: ${new Date().toLocaleDateString('ru-RU')}\n\n`;
|
||||||
|
|
||||||
// Экспорт товаров
|
// Экспорт позиций
|
||||||
if (this.searchResults.items.length > 0) {
|
if (this.searchResults.items.length > 0) {
|
||||||
csv += 'ТОВАРЫ\n';
|
csv += 'ТОВАРЫ\n';
|
||||||
csv += 'ID,Название,Категория,Цена,Количество,Описание\n';
|
csv += 'ID,Название,Категория,Цена,Количество,Описание\n';
|
||||||
@ -513,7 +549,7 @@ class SearchManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Просмотр деталей товара
|
// Просмотр деталей позиции
|
||||||
async viewItemDetails(itemId) {
|
async viewItemDetails(itemId) {
|
||||||
try {
|
try {
|
||||||
const item = await this.warehouse.getItemById(itemId);
|
const item = await this.warehouse.getItemById(itemId);
|
||||||
@ -587,8 +623,8 @@ class SearchManager {
|
|||||||
$('#itemDetailsModal').modal('show');
|
$('#itemDetailsModal').modal('show');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке деталей товара:', error);
|
console.error('Ошибка при загрузке деталей позиции:', error);
|
||||||
auth.showNotification('Ошибка при загрузке деталей товара', 'danger');
|
auth.showNotification('Ошибка при загрузке деталей позиции', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -661,6 +697,126 @@ class SearchManager {
|
|||||||
hideLoading() {
|
hideLoading() {
|
||||||
$('#loadingState').hide();
|
$('#loadingState').hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновление информации о пользователе
|
||||||
|
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 = `
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
${category.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item ${isEmpty ? 'empty-category' : ''}" href="catalog.html?category=${category.id}" title="${category.description || ''}">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="category-count">${category.itemCount || 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация менеджера поиска
|
// Инициализация менеджера поиска
|
||||||
|
|||||||
791
js/users.js
Normal file
791
js/users.js
Normal file
@ -0,0 +1,791 @@
|
|||||||
|
// Система управления пользователями
|
||||||
|
|
||||||
|
class UserManager {
|
||||||
|
constructor() {
|
||||||
|
this.users = [];
|
||||||
|
this.filteredUsers = [];
|
||||||
|
this.currentUser = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Проверяем авторизацию и права доступа
|
||||||
|
if (!auth.isUserAuthenticated()) {
|
||||||
|
window.location.href = '../login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.hasRole('admin')) {
|
||||||
|
auth.showNotification('У вас нет прав для доступа к этой странице', 'danger');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentUser = auth.getCurrentUser();
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadUsers();
|
||||||
|
this.loadCategoriesNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Поиск и фильтрация
|
||||||
|
$('#searchInput').on('input', (e) => this.handleSearch(e.target.value));
|
||||||
|
$('#roleFilter').on('change', (e) => this.handleRoleFilter(e.target.value));
|
||||||
|
|
||||||
|
// Формы
|
||||||
|
$('#addUserForm').on('submit', (e) => this.handleAddUser(e));
|
||||||
|
$('#editUserForm').on('submit', (e) => this.handleEditUser(e));
|
||||||
|
|
||||||
|
// Удаление
|
||||||
|
$('#confirmDeleteBtn').on('click', () => this.handleDeleteUser());
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей
|
||||||
|
$('input[type="checkbox"][value^="role"]').on('change', () => this.updatePermissionsFromRoles());
|
||||||
|
$('input[type="checkbox"][value^="editRole"]').on('change', () => this.updateEditPermissionsFromRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка пользователей
|
||||||
|
async loadUsers() {
|
||||||
|
try {
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users = await response.json();
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
this.hideLoading();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию данных
|
||||||
|
this.users = this.getMockUsers();
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
this.hideLoading();
|
||||||
|
|
||||||
|
// Показываем уведомление только если это не демо-режим
|
||||||
|
if (!error.message.includes('Failed to fetch')) {
|
||||||
|
auth.showNotification('Ошибка при загрузке пользователей', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение имитационных данных пользователей
|
||||||
|
getMockUsers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
name: 'Администратор',
|
||||||
|
email: 'admin@company.com',
|
||||||
|
department: 'IT',
|
||||||
|
position: 'Системный администратор',
|
||||||
|
roles: ['admin'],
|
||||||
|
permissions: ['read', 'write', 'delete', 'admin'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-15T10:30:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'manager',
|
||||||
|
name: 'Менеджер склада',
|
||||||
|
email: 'manager@company.com',
|
||||||
|
department: 'Склад',
|
||||||
|
position: 'Менеджер склада',
|
||||||
|
roles: ['manager'],
|
||||||
|
permissions: ['read', 'write'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-01-15T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-15T09:15:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
username: 'operator',
|
||||||
|
name: 'Оператор',
|
||||||
|
email: 'operator@company.com',
|
||||||
|
department: 'Склад',
|
||||||
|
position: 'Оператор склада',
|
||||||
|
roles: ['operator'],
|
||||||
|
permissions: ['read'],
|
||||||
|
active: true,
|
||||||
|
createdAt: '2025-02-01T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-14T16:45:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
username: 'viewer',
|
||||||
|
name: 'Наблюдатель',
|
||||||
|
email: 'viewer@company.com',
|
||||||
|
department: 'Бухгалтерия',
|
||||||
|
position: 'Бухгалтер',
|
||||||
|
roles: ['viewer'],
|
||||||
|
permissions: ['read'],
|
||||||
|
active: false,
|
||||||
|
createdAt: '2025-02-15T00:00:00Z',
|
||||||
|
lastLogin: '2025-08-10T14:20:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовка пользователей
|
||||||
|
renderUsers() {
|
||||||
|
const grid = $('#usersGrid');
|
||||||
|
grid.empty();
|
||||||
|
|
||||||
|
if (this.filteredUsers.length === 0) {
|
||||||
|
$('#emptyState').show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#emptyState').hide();
|
||||||
|
|
||||||
|
this.filteredUsers.forEach(user => {
|
||||||
|
const userCard = this.createUserCard(user);
|
||||||
|
grid.append(userCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание карточки пользователя
|
||||||
|
createUserCard(user) {
|
||||||
|
const roleBadges = user.roles.map(role => {
|
||||||
|
const roleConfig = this.getRoleConfig(role);
|
||||||
|
return `<span class="badge ${roleConfig.class} role-badge">${roleConfig.icon} ${roleConfig.name}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const permissionChips = user.permissions.map(perm => {
|
||||||
|
const permConfig = this.getPermissionConfig(perm);
|
||||||
|
return `<span class="permission-chip">${permConfig.icon} ${permConfig.name}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const statusClass = user.active ? 'status-active' : 'status-inactive';
|
||||||
|
const statusIcon = user.active ? 'fa-user-check' : 'fa-user-times';
|
||||||
|
const statusText = user.active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="col-lg-4 col-md-6 mb-4">
|
||||||
|
<div class="card user-card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-user me-2"></i>
|
||||||
|
${this.highlightSearch(user.name)}
|
||||||
|
</h6>
|
||||||
|
<small class="text-muted">@${user.username}</small>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="userManager.editUser(${user.id})">
|
||||||
|
<i class="fas fa-edit me-2"></i>Редактировать
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="userManager.toggleUserStatus(${user.id})">
|
||||||
|
<i class="fas fa-toggle-on me-2"></i>${user.active ? 'Деактивировать' : 'Активировать'}
|
||||||
|
</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="#" onclick="userManager.deleteUser(${user.id})">
|
||||||
|
<i class="fas fa-trash me-2"></i>Удалить
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-envelope me-2 text-muted"></i>
|
||||||
|
<small>${this.highlightSearch(user.email)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-building me-2 text-muted"></i>
|
||||||
|
<small>${user.department || 'Не указан'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="fas fa-briefcase me-2 text-muted"></i>
|
||||||
|
<small>${user.position || 'Не указана'}</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas ${statusIcon} me-2 ${statusClass}"></i>
|
||||||
|
<small class="${statusClass}">${statusText}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Роли:</label>
|
||||||
|
<div>${roleBadges}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Разрешения:</label>
|
||||||
|
<div>${permissionChips}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
<div>Создан: ${this.formatDate(user.createdAt)}</div>
|
||||||
|
<div>Последний вход: ${this.formatDate(user.lastLogin)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конфигурация ролей
|
||||||
|
getRoleConfig(role) {
|
||||||
|
const configs = {
|
||||||
|
admin: { name: 'Администратор', class: 'bg-danger', icon: 'fas fa-user-shield' },
|
||||||
|
manager: { name: 'Менеджер', class: 'bg-warning', icon: 'fas fa-user-tie' },
|
||||||
|
operator: { name: 'Оператор', class: 'bg-info', icon: 'fas fa-user-cog' },
|
||||||
|
viewer: { name: 'Наблюдатель', class: 'bg-secondary', icon: 'fas fa-user' }
|
||||||
|
};
|
||||||
|
return configs[role] || { name: role, class: 'bg-secondary', icon: 'fas fa-user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конфигурация разрешений
|
||||||
|
getPermissionConfig(permission) {
|
||||||
|
const configs = {
|
||||||
|
read: { name: 'Чтение', icon: 'fas fa-eye' },
|
||||||
|
write: { name: 'Запись', icon: 'fas fa-edit' },
|
||||||
|
delete: { name: 'Удаление', icon: 'fas fa-trash' },
|
||||||
|
admin: { name: 'Администрирование', icon: 'fas fa-cogs' }
|
||||||
|
};
|
||||||
|
return configs[permission] || { name: permission, icon: 'fas fa-check' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсветка поиска
|
||||||
|
highlightSearch(text) {
|
||||||
|
const searchTerm = $('#searchInput').val().toLowerCase();
|
||||||
|
if (!searchTerm || !text) return text;
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${searchTerm})`, 'gi');
|
||||||
|
return text.toString().replace(regex, '<span class="search-highlight">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка поиска
|
||||||
|
handleSearch(query) {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
const roleFilter = $('#roleFilter').val();
|
||||||
|
|
||||||
|
this.filteredUsers = this.users.filter(user => {
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
user.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
user.username.toLowerCase().includes(searchTerm) ||
|
||||||
|
user.email.toLowerCase().includes(searchTerm) ||
|
||||||
|
(user.department && user.department.toLowerCase().includes(searchTerm));
|
||||||
|
|
||||||
|
const matchesRole = !roleFilter || user.roles.includes(roleFilter);
|
||||||
|
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка фильтра по ролям
|
||||||
|
handleRoleFilter(role) {
|
||||||
|
this.handleSearch($('#searchInput').val());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
updateStatistics() {
|
||||||
|
const totalUsers = this.users.length;
|
||||||
|
const activeUsers = this.users.filter(u => u.active).length;
|
||||||
|
const adminUsers = this.users.filter(u => u.roles.includes('admin')).length;
|
||||||
|
const managerUsers = this.users.filter(u => u.roles.includes('manager')).length;
|
||||||
|
|
||||||
|
$('#totalUsers').text(totalUsers);
|
||||||
|
$('#activeUsers').text(activeUsers);
|
||||||
|
$('#adminUsers').text(adminUsers);
|
||||||
|
$('#managerUsers').text(managerUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление пользователя
|
||||||
|
async handleAddUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = this.getFormData('#addUserForm');
|
||||||
|
|
||||||
|
if (!this.validateUserData(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка создания пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await response.json();
|
||||||
|
this.users.push(newUser);
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#addUserModal').modal('hide');
|
||||||
|
$('#addUserForm')[0].reset();
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно создан', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
const newUser = {
|
||||||
|
id: this.users.length + 1,
|
||||||
|
...formData,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLogin: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.users.push(newUser);
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#addUserModal').modal('hide');
|
||||||
|
$('#addUserForm')[0].reset();
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно создан (демо-режим)', 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при создании пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактирование пользователя
|
||||||
|
async handleEditUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const formData = this.getFormData('#editUserForm');
|
||||||
|
const userId = parseInt($('#editUserId').val());
|
||||||
|
|
||||||
|
if (!this.validateUserData(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка обновления пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await response.json();
|
||||||
|
const userIndex = this.users.findIndex(u => u.id === userId);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
this.users[userIndex] = updatedUser;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#editUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно обновлен', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
const userIndex = this.users.findIndex(u => u.id === userId);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
this.users[userIndex] = { ...this.users[userIndex], ...formData };
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#editUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно обновлен (демо-режим)', 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при обновлении пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление пользователя
|
||||||
|
deleteUser(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
$('#deleteUserName').text(user.name);
|
||||||
|
$('#deleteUserModal').modal('show');
|
||||||
|
|
||||||
|
// Сохраняем ID для подтверждения
|
||||||
|
$('#deleteUserModal').data('userId', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтверждение удаления
|
||||||
|
async handleDeleteUser() {
|
||||||
|
const userId = $('#deleteUserModal').data('userId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка удаления пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users = this.users.filter(u => u.id !== userId);
|
||||||
|
this.filteredUsers = this.filteredUsers.filter(u => u.id !== userId);
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#deleteUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно удален', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
this.users = this.users.filter(u => u.id !== userId);
|
||||||
|
this.filteredUsers = this.filteredUsers.filter(u => u.id !== userId);
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
$('#deleteUserModal').modal('hide');
|
||||||
|
|
||||||
|
auth.showNotification('Пользователь успешно удален (демо-режим)', 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при удалении пользователя', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение статуса пользователя
|
||||||
|
async toggleUserStatus(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Токен не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users/${userId}/toggle-status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Ошибка изменения статуса');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
user.active = result.active;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
auth.showNotification(result.message, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Если API недоступен, используем имитацию
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
user.active = !user.active;
|
||||||
|
this.filteredUsers = [...this.users];
|
||||||
|
|
||||||
|
this.renderUsers();
|
||||||
|
this.updateStatistics();
|
||||||
|
|
||||||
|
const status = user.active ? 'активирован' : 'деактивирован';
|
||||||
|
auth.showNotification(`Пользователь ${status} (демо-режим)`, 'success');
|
||||||
|
} else {
|
||||||
|
auth.showNotification(error.message || 'Ошибка при изменении статуса', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие формы редактирования
|
||||||
|
editUser(userId) {
|
||||||
|
const user = this.users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Заполняем форму данными пользователя
|
||||||
|
$('#editUserId').val(user.id);
|
||||||
|
$('#editUsername').val(user.username);
|
||||||
|
$('#editEmail').val(user.email);
|
||||||
|
$('#editFullName').val(user.name);
|
||||||
|
$('#editDepartment').val(user.department || '');
|
||||||
|
$('#editPosition').val(user.position || '');
|
||||||
|
$('#editPassword').val('');
|
||||||
|
|
||||||
|
// Устанавливаем роли
|
||||||
|
$('#editRoleAdmin').prop('checked', user.roles.includes('admin'));
|
||||||
|
$('#editRoleManager').prop('checked', user.roles.includes('manager'));
|
||||||
|
$('#editRoleOperator').prop('checked', user.roles.includes('operator'));
|
||||||
|
$('#editRoleViewer').prop('checked', user.roles.includes('viewer'));
|
||||||
|
|
||||||
|
// Устанавливаем разрешения
|
||||||
|
$('#editPermRead').prop('checked', user.permissions.includes('read'));
|
||||||
|
$('#editPermWrite').prop('checked', user.permissions.includes('write'));
|
||||||
|
$('#editPermDelete').prop('checked', user.permissions.includes('delete'));
|
||||||
|
$('#editPermAdmin').prop('checked', user.permissions.includes('admin'));
|
||||||
|
|
||||||
|
$('#editUserModal').modal('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных формы
|
||||||
|
getFormData(formSelector) {
|
||||||
|
const form = $(formSelector);
|
||||||
|
const formData = {};
|
||||||
|
|
||||||
|
// Основные поля
|
||||||
|
form.find('input[type="text"], input[type="email"], input[type="password"]').each(function() {
|
||||||
|
const field = $(this);
|
||||||
|
const name = field.attr('id');
|
||||||
|
const value = field.val();
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
formData[name] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Роли
|
||||||
|
const roles = [];
|
||||||
|
form.find('input[type="checkbox"][value^="admin"], input[type="checkbox"][value^="manager"], input[type="checkbox"][value^="operator"], input[type="checkbox"][value^="viewer"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.roles = roles;
|
||||||
|
|
||||||
|
// Разрешения
|
||||||
|
const permissions = [];
|
||||||
|
form.find('input[type="checkbox"][value^="read"], input[type="checkbox"][value^="write"], input[type="checkbox"][value^="delete"], input[type="checkbox"][value^="admin"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
permissions.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
formData.permissions = permissions;
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация данных пользователя
|
||||||
|
validateUserData(formData) {
|
||||||
|
if (!formData.username || formData.username.trim().length < 3) {
|
||||||
|
auth.showNotification('Имя пользователя должно содержать минимум 3 символа', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.fullName || formData.fullName.trim().length < 2) {
|
||||||
|
auth.showNotification('Полное имя должно содержать минимум 2 символа', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email || !this.isValidEmail(formData.email)) {
|
||||||
|
auth.showNotification('Введите корректный email адрес', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.roles.length === 0) {
|
||||||
|
auth.showNotification('Выберите хотя бы одну роль', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.permissions.length === 0) {
|
||||||
|
auth.showNotification('Выберите хотя бы одно разрешение', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const existingUser = this.users.find(u => u.username === formData.username);
|
||||||
|
if (existingUser && existingUser.id !== parseInt($('#editUserId').val() || 0)) {
|
||||||
|
auth.showNotification('Пользователь с таким именем уже существует', 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка email
|
||||||
|
isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей
|
||||||
|
updatePermissionsFromRoles() {
|
||||||
|
const roles = [];
|
||||||
|
$('input[type="checkbox"][value^="admin"], input[type="checkbox"][value^="manager"], input[type="checkbox"][value^="operator"], input[type="checkbox"][value^="viewer"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбрасываем все разрешения
|
||||||
|
$('input[type="checkbox"][value^="read"], input[type="checkbox"][value^="write"], input[type="checkbox"][value^="delete"], input[type="checkbox"][value^="admin"]').prop('checked', false);
|
||||||
|
|
||||||
|
// Устанавливаем разрешения в зависимости от ролей
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
$('#permRead, #permWrite, #permDelete, #permAdmin').prop('checked', true);
|
||||||
|
} else if (roles.includes('manager')) {
|
||||||
|
$('#permRead, #permWrite').prop('checked', true);
|
||||||
|
} else if (roles.includes('operator')) {
|
||||||
|
$('#permRead').prop('checked', true);
|
||||||
|
} else if (roles.includes('viewer')) {
|
||||||
|
$('#permRead').prop('checked', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое обновление разрешений при изменении ролей (для редактирования)
|
||||||
|
updateEditPermissionsFromRoles() {
|
||||||
|
const roles = [];
|
||||||
|
$('input[type="checkbox"][value^="editRole"]').each(function() {
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
roles.push($(this).val().replace('editRole', ''));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбрасываем все разрешения
|
||||||
|
$('input[type="checkbox"][value^="editPerm"]').prop('checked', false);
|
||||||
|
|
||||||
|
// Устанавливаем разрешения в зависимости от ролей
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
$('#editPermRead, #editPermWrite, #editPermDelete, #editPermAdmin').prop('checked', true);
|
||||||
|
} else if (roles.includes('manager')) {
|
||||||
|
$('#editPermRead, #editPermWrite').prop('checked', true);
|
||||||
|
} else if (roles.includes('operator')) {
|
||||||
|
$('#editPermRead').prop('checked', true);
|
||||||
|
} else if (roles.includes('viewer')) {
|
||||||
|
$('#editPermRead').prop('checked', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return 'Никогда';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать загрузку
|
||||||
|
showLoading() {
|
||||||
|
$('#loadingState').show();
|
||||||
|
$('#usersGrid').hide();
|
||||||
|
$('#emptyState').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрыть загрузку
|
||||||
|
hideLoading() {
|
||||||
|
$('#loadingState').hide();
|
||||||
|
$('#usersGrid').show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка категорий в навигацию
|
||||||
|
async loadCategoriesNav() {
|
||||||
|
try {
|
||||||
|
// В реальном приложении здесь был бы запрос к API
|
||||||
|
const categories = [
|
||||||
|
{ id: 1, name: 'Электроника', icon: 'fas fa-laptop', color: '#2c5aa0', active: true },
|
||||||
|
{ id: 2, name: 'Инструменты', icon: 'fas fa-tools', color: '#1a7f37', active: true },
|
||||||
|
{ id: 3, name: 'Офис', icon: 'fas fa-briefcase', color: '#8a2be2', active: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dropdown = $('#catalogCategoriesDropdown');
|
||||||
|
|
||||||
|
// Очищаем существующие категории (кроме "Все позиции" и разделителя)
|
||||||
|
dropdown.find('li:not(:first-child):not(:nth-child(2))').remove();
|
||||||
|
|
||||||
|
// Добавляем активные категории
|
||||||
|
categories.forEach(category => {
|
||||||
|
if (category.active) {
|
||||||
|
const categoryLink = `
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="../catalog.html?category=${category.id}" title="">
|
||||||
|
<div class="category-info">
|
||||||
|
<i class="${category.icon}" style="color: ${category.color || '#2c5aa0'}"></i>
|
||||||
|
<span>${category.name}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
dropdown.append(categoryLink);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Ошибка загрузки категорий не критична для этой страницы
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация менеджера пользователей
|
||||||
|
const userManager = new UserManager();
|
||||||
556
js/warehouse.js
556
js/warehouse.js
@ -2,12 +2,96 @@
|
|||||||
|
|
||||||
class WarehouseManager {
|
class WarehouseManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.apiBase = (window && window.API_BASE) ? window.API_BASE : 'http://localhost:3001/api';
|
||||||
this.items = this.loadItems();
|
this.items = this.loadItems();
|
||||||
this.operations = this.loadOperations();
|
this.operations = this.loadOperations();
|
||||||
this.categories = this.loadCategories();
|
this.categories = this.loadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка товаров из localStorage
|
// Универсальный вызов 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() {
|
loadItems() {
|
||||||
const items = localStorage.getItem('warehouse_items');
|
const items = localStorage.getItem('warehouse_items');
|
||||||
return items ? JSON.parse(items) : this.getDefaultItems();
|
return items ? JSON.parse(items) : this.getDefaultItems();
|
||||||
@ -21,14 +105,37 @@ class WarehouseManager {
|
|||||||
|
|
||||||
// Загрузка категорий
|
// Загрузка категорий
|
||||||
loadCategories() {
|
loadCategories() {
|
||||||
return [
|
const categories = localStorage.getItem('warehouse_categories');
|
||||||
{ id: 'electronics', name: 'Электроника', icon: 'fas fa-laptop' },
|
if (categories) {
|
||||||
{ id: 'clothing', name: 'Одежда', icon: 'fas fa-tshirt' },
|
this.categories = JSON.parse(categories);
|
||||||
{ id: 'tools', name: 'Инструменты', icon: 'fas fa-tools' },
|
} else {
|
||||||
{ id: 'office', name: 'Офисные принадлежности', icon: 'fas fa-briefcase' },
|
// Загружаем категории по умолчанию
|
||||||
{ id: 'furniture', name: 'Мебель', icon: 'fas fa-couch' },
|
this.categories = this.getDefaultCategories();
|
||||||
{ id: 'books', name: 'Книги', icon: 'fas fa-book' }
|
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
|
// Сохранение данных в localStorage
|
||||||
@ -40,84 +147,13 @@ class WarehouseManager {
|
|||||||
localStorage.setItem('warehouse_operations', JSON.stringify(this.operations));
|
localStorage.setItem('warehouse_operations', JSON.stringify(this.operations));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение товаров по умолчанию
|
// Получение позиций по умолчанию
|
||||||
getDefaultItems() {
|
getDefaultItems() {
|
||||||
return [
|
return [];
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Ноутбук Dell Inspiron',
|
|
||||||
category: 'electronics',
|
|
||||||
price: 45000,
|
|
||||||
quantity: 15,
|
|
||||||
description: '15.6" Full HD, Intel Core i5, 8GB RAM, 256GB SSD',
|
|
||||||
barcode: '1234567890123',
|
|
||||||
minQuantity: 5,
|
|
||||||
location: 'Стеллаж A-1',
|
|
||||||
supplier: 'Dell Inc.',
|
|
||||||
createdAt: '2024-01-15T10:00:00Z',
|
|
||||||
updatedAt: '2024-01-15T10:00:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Принтер HP LaserJet',
|
|
||||||
category: 'electronics',
|
|
||||||
price: 12000,
|
|
||||||
quantity: 8,
|
|
||||||
description: 'Лазерный принтер, монохромный, 20 стр/мин',
|
|
||||||
barcode: '1234567890124',
|
|
||||||
minQuantity: 3,
|
|
||||||
location: 'Стеллаж A-2',
|
|
||||||
supplier: 'HP Inc.',
|
|
||||||
createdAt: '2024-01-10T14:30:00Z',
|
|
||||||
updatedAt: '2024-01-10T14:30:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Офисный стул',
|
|
||||||
category: 'furniture',
|
|
||||||
price: 3500,
|
|
||||||
quantity: 25,
|
|
||||||
description: 'Офисный стул с подлокотниками, черный',
|
|
||||||
barcode: '1234567890125',
|
|
||||||
minQuantity: 10,
|
|
||||||
location: 'Стеллаж B-1',
|
|
||||||
supplier: 'МебельСтрой',
|
|
||||||
createdAt: '2024-01-05T09:15:00Z',
|
|
||||||
updatedAt: '2024-01-05T09:15:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Бумага А4',
|
|
||||||
category: 'office',
|
|
||||||
price: 250,
|
|
||||||
quantity: 100,
|
|
||||||
description: 'Бумага А4, 80 г/м², 500 листов',
|
|
||||||
barcode: '1234567890126',
|
|
||||||
minQuantity: 20,
|
|
||||||
location: 'Стеллаж C-1',
|
|
||||||
supplier: 'ОфисМаркет',
|
|
||||||
createdAt: '2024-01-12T11:45:00Z',
|
|
||||||
updatedAt: '2024-01-12T11:45:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Отвертка крестовая',
|
|
||||||
category: 'tools',
|
|
||||||
price: 150,
|
|
||||||
quantity: 50,
|
|
||||||
description: 'Отвертка крестовая PH2, 150мм',
|
|
||||||
barcode: '1234567890127',
|
|
||||||
minQuantity: 15,
|
|
||||||
location: 'Стеллаж D-1',
|
|
||||||
supplier: 'ИнструментСтрой',
|
|
||||||
createdAt: '2024-01-08T16:20:00Z',
|
|
||||||
updatedAt: '2024-01-08T16:20:00Z'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение статистики
|
// Получение статистики
|
||||||
async getStatistics() {
|
getStatistics() {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const totalItems = this.items.reduce((sum, item) => sum + item.quantity, 0);
|
const totalItems = this.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
@ -137,54 +173,134 @@ class WarehouseManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение всех товаров
|
// Получение всех позиций
|
||||||
async getAllItems() {
|
async getAllItems() {
|
||||||
|
try {
|
||||||
|
const data = await this.apiRequest('/items');
|
||||||
|
this.items = (data || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category_id || 'uncategorized',
|
||||||
|
price: Number(row.price || 0),
|
||||||
|
quantity: Number(row.quantity || 0),
|
||||||
|
description: row.description || '',
|
||||||
|
barcode: row.barcode || '',
|
||||||
|
minQuantity: Number(row.min_quantity || 0),
|
||||||
|
location: row.location || '',
|
||||||
|
supplier: row.supplier || '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}));
|
||||||
|
this.saveItems();
|
||||||
|
this.updateCategoryItemCounts();
|
||||||
|
return this.items;
|
||||||
|
} catch (e) {
|
||||||
|
return this.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронное получение всех позиций
|
||||||
|
getAllItemsSync() {
|
||||||
return this.items;
|
return this.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение товара по ID
|
// Получение позиции по ID
|
||||||
async getItemById(id) {
|
async getItemById(id) {
|
||||||
|
try {
|
||||||
|
const row = await this.apiRequest(`/items/${id}`);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category_id || 'uncategorized',
|
||||||
|
price: Number(row.price || 0),
|
||||||
|
quantity: Number(row.quantity || 0),
|
||||||
|
description: row.description || '',
|
||||||
|
barcode: row.barcode || '',
|
||||||
|
minQuantity: Number(row.min_quantity || 0),
|
||||||
|
location: row.location || '',
|
||||||
|
supplier: row.supplier || '',
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
return this.items.find(item => item.id === parseInt(id));
|
return this.items.find(item => item.id === parseInt(id));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Добавление нового товара
|
// Добавление новой позиции
|
||||||
async addItem(itemData) {
|
async addItem(itemData) {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: this.getNextItemId(),
|
id: this.getNextItemId(),
|
||||||
name: itemData.itemName,
|
name: itemData.itemName,
|
||||||
category: itemData.itemCategory,
|
category: itemData.itemCategory || 'uncategorized',
|
||||||
price: parseFloat(itemData.itemPrice),
|
price: parseFloat(itemData.itemPrice),
|
||||||
quantity: parseInt(itemData.itemQuantity),
|
quantity: parseInt(itemData.itemQuantity),
|
||||||
description: itemData.itemDescription || '',
|
description: itemData.itemDescription || '',
|
||||||
barcode: itemData.itemBarcode || '',
|
barcode: itemData.itemBarcode || '',
|
||||||
minQuantity: parseInt(itemData.itemQuantity) * 0.2, // 20% от начального количества
|
minQuantity: parseInt(itemData.itemQuantity) * 0.2, // 20% от начального количества
|
||||||
location: 'Новый стеллаж',
|
location: itemData.itemLocation || 'Новый стеллаж',
|
||||||
supplier: '',
|
supplier: itemData.itemSupplier || '',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Попытка создать через API
|
||||||
|
try {
|
||||||
|
const created = await this.apiRequest('/items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: newItem.name,
|
||||||
|
category_id: newItem.category === 'uncategorized' ? null : newItem.category,
|
||||||
|
price: newItem.price,
|
||||||
|
quantity: newItem.quantity,
|
||||||
|
description: newItem.description,
|
||||||
|
barcode: newItem.barcode,
|
||||||
|
min_quantity: newItem.minQuantity,
|
||||||
|
location: newItem.location,
|
||||||
|
supplier: newItem.supplier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (created && created.id) newItem.id = created.id;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
this.items.push(newItem);
|
this.items.push(newItem);
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
// Создаем операцию прихода для нового товара
|
|
||||||
await this.createOperation({
|
await this.createOperation({
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
itemId: newItem.id,
|
itemId: newItem.id,
|
||||||
quantity: newItem.quantity,
|
quantity: newItem.quantity,
|
||||||
notes: 'Добавление нового товара'
|
notes: 'Добавление новой позиции'
|
||||||
});
|
});
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление товара
|
// Обновление позиции
|
||||||
async updateItem(id, itemData) {
|
async updateItem(id, itemData) {
|
||||||
const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
|
const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
|
||||||
if (itemIndex === -1) {
|
if (itemIndex === -1) {
|
||||||
throw new Error('Товар не найден');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apiRequest(`/items/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
name: itemData.name,
|
||||||
|
category_id: itemData.category === 'uncategorized' ? null : itemData.category,
|
||||||
|
price: itemData.price,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
description: itemData.description,
|
||||||
|
barcode: itemData.barcode,
|
||||||
|
min_quantity: itemData.minQuantity,
|
||||||
|
location: itemData.location,
|
||||||
|
supplier: itemData.supplier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
this.items[itemIndex] = {
|
this.items[itemIndex] = {
|
||||||
...this.items[itemIndex],
|
...this.items[itemIndex],
|
||||||
...itemData,
|
...itemData,
|
||||||
@ -192,18 +308,28 @@ class WarehouseManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
|
||||||
|
// Обновляем количество товаров в категории
|
||||||
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
return this.items[itemIndex];
|
return this.items[itemIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление товара
|
// Удаление позиции
|
||||||
async deleteItem(id) {
|
async deleteItem(id) {
|
||||||
const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
|
const itemIndex = this.items.findIndex(item => item.id === parseInt(id));
|
||||||
if (itemIndex === -1) {
|
if (itemIndex === -1) {
|
||||||
throw new Error('Товар не найден');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { await this.apiRequest(`/items/${id}`, { method: 'DELETE' }); } catch (e) {}
|
||||||
|
|
||||||
this.items.splice(itemIndex, 1);
|
this.items.splice(itemIndex, 1);
|
||||||
this.saveItems();
|
this.saveItems();
|
||||||
|
|
||||||
|
// Обновляем количество товаров в категории
|
||||||
|
this.updateCategoryItemCounts();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,15 +340,25 @@ class WarehouseManager {
|
|||||||
|
|
||||||
const item = await this.getItemById(itemId);
|
const item = await this.getItemById(itemId);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new Error('Товар не найден');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем количество товара
|
// Обновляем количество позиции
|
||||||
item.quantity += quantity;
|
item.quantity += quantity;
|
||||||
item.updatedAt = new Date().toISOString();
|
item.updatedAt = new Date().toISOString();
|
||||||
this.saveItems();
|
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({
|
const operation = await this.createOperation({
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
@ -241,20 +377,30 @@ class WarehouseManager {
|
|||||||
|
|
||||||
const item = await this.getItemById(itemId);
|
const item = await this.getItemById(itemId);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new Error('Товар не найден');
|
throw new Error('Позиция не найдена');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем достаточность товара
|
// Проверяем достаточность позиции
|
||||||
if (item.quantity < quantity) {
|
if (item.quantity < quantity) {
|
||||||
throw new Error(`Недостаточно товара. Доступно: ${item.quantity} шт.`);
|
throw new Error(`Недостаточно позиций. Доступно: ${item.quantity} шт.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем количество товара
|
// Обновляем количество позиции
|
||||||
item.quantity -= quantity;
|
item.quantity -= quantity;
|
||||||
item.updatedAt = new Date().toISOString();
|
item.updatedAt = new Date().toISOString();
|
||||||
this.saveItems();
|
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({
|
const operation = await this.createOperation({
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
@ -289,18 +435,40 @@ class WarehouseManager {
|
|||||||
this.operations.push(operation);
|
this.operations.push(operation);
|
||||||
this.saveOperations();
|
this.saveOperations();
|
||||||
|
|
||||||
|
// Проверяем и отправляем уведомления
|
||||||
|
await this.checkAndSendNotifications(operation);
|
||||||
|
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение последних операций
|
// Получение последних операций
|
||||||
async getRecentOperations(limit = 10) {
|
async getRecentOperations(limit = 10) {
|
||||||
|
try {
|
||||||
|
const ops = await this.apiRequest(`/operations?sortBy=date&sortOrder=desc`);
|
||||||
|
this.syncOperationsFromApi(ops);
|
||||||
|
return this.operations.slice(0, limit);
|
||||||
|
} catch (e) {
|
||||||
return this.operations
|
return this.operations
|
||||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Получение операций по фильтрам
|
// Получение операций по фильтрам
|
||||||
async getOperations(filters = {}) {
|
async getOperations(filters = {}) {
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (filters.type) q.set('type', filters.type);
|
||||||
|
if (filters.startDate) q.set('startDate', filters.startDate);
|
||||||
|
if (filters.endDate) q.set('endDate', filters.endDate);
|
||||||
|
if (filters.itemId) q.set('itemId', filters.itemId);
|
||||||
|
if (filters.sortBy) q.set('sortBy', filters.sortBy);
|
||||||
|
if (filters.sortOrder) q.set('sortOrder', filters.sortOrder);
|
||||||
|
const ops = await this.apiRequest(`/operations?${q.toString()}`);
|
||||||
|
this.syncOperationsFromApi(ops);
|
||||||
|
return this.operations;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
let filteredOperations = [...this.operations];
|
let filteredOperations = [...this.operations];
|
||||||
|
|
||||||
// Фильтр по типу
|
// Фильтр по типу
|
||||||
@ -351,6 +519,25 @@ class WarehouseManager {
|
|||||||
return filteredOperations;
|
return filteredOperations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Приведение формата операций из API и сохранение в localStorage
|
||||||
|
syncOperationsFromApi(apiOps) {
|
||||||
|
this.operations = (apiOps || []).map(op => ({
|
||||||
|
id: op.id,
|
||||||
|
type: op.type,
|
||||||
|
itemId: op.itemId || op.item_id,
|
||||||
|
itemName: op.itemName || op.item_name || op.itemName,
|
||||||
|
quantity: Number(op.quantity || 0),
|
||||||
|
employeeId: op.employeeId || op.employee_id,
|
||||||
|
employeeName: op.employeeName || op.employee_name,
|
||||||
|
date: op.date,
|
||||||
|
status: op.status || 'completed',
|
||||||
|
supplier: op.supplier || '',
|
||||||
|
recipient: op.recipient || '',
|
||||||
|
notes: op.notes || ''
|
||||||
|
})).sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
this.saveOperations();
|
||||||
|
}
|
||||||
|
|
||||||
// Поиск товаров
|
// Поиск товаров
|
||||||
async searchItems(query, filters = {}) {
|
async searchItems(query, filters = {}) {
|
||||||
let results = [...this.items];
|
let results = [...this.items];
|
||||||
@ -396,23 +583,184 @@ class WarehouseManager {
|
|||||||
return this.items.filter(item => item.quantity <= item.minQuantity);
|
return this.items.filter(item => item.quantity <= item.minQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка и отправка уведомлений
|
||||||
|
async checkAndSendNotifications(operation = null) {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
|
||||||
|
|
||||||
|
// Проверяем оповещения о низком остатке
|
||||||
|
if (settings.lowStockAlerts) {
|
||||||
|
await this.checkLowStockAlerts(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем email уведомления об операциях
|
||||||
|
if (settings.emailNotifications && operation) {
|
||||||
|
await this.sendOperationEmailNotification(operation, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отправке уведомлений:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка товаров с низким запасом
|
||||||
|
async checkLowStockAlerts(user) {
|
||||||
|
try {
|
||||||
|
const lowStockItems = await this.getLowStockItems();
|
||||||
|
|
||||||
|
if (lowStockItems.length > 0) {
|
||||||
|
// Показываем уведомление в интерфейсе
|
||||||
|
const itemNames = lowStockItems.map(item => item.name).join(', ');
|
||||||
|
auth.showNotification(
|
||||||
|
`Внимание! Товары с низким запасом: ${itemNames}`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Отправляем email уведомление через API
|
||||||
|
await this.sendLowStockEmailNotification(lowStockItems);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при проверке низкого остатка:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
async sendOperationEmailNotification(operation, user) {
|
||||||
|
try {
|
||||||
|
const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
|
||||||
|
const message = `Операция ${operationType}: ${operation.itemName} - ${operation.quantity} шт.`;
|
||||||
|
|
||||||
|
// Отправляем email уведомление через API
|
||||||
|
await this.sendOperationEmailNotificationAPI(operation);
|
||||||
|
|
||||||
|
// Показываем уведомление в интерфейсе
|
||||||
|
auth.showNotification(`Уведомление об операции отправлено на ${user.email}`, 'info');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отправке уведомления об операции:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация и отправка еженедельных отчетов
|
||||||
|
async generateWeeklyReports() {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(localStorage.getItem('user_settings') || '{}');
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('warehouse_user') || '{}');
|
||||||
|
|
||||||
|
if (settings.operationReports) {
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const weeklyOperations = this.operations.filter(op =>
|
||||||
|
new Date(op.date) >= weekAgo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (weeklyOperations.length > 0) {
|
||||||
|
const report = {
|
||||||
|
period: 'За последнюю неделю',
|
||||||
|
totalOperations: weeklyOperations.length,
|
||||||
|
incoming: weeklyOperations.filter(op => op.type === 'incoming').length,
|
||||||
|
outgoing: weeklyOperations.filter(op => op.type === 'outgoing').length,
|
||||||
|
operations: weeklyOperations
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем еженедельный отчет через API
|
||||||
|
await this.sendWeeklyReportEmailNotification(report);
|
||||||
|
|
||||||
|
auth.showNotification('Еженедельный отчет отправлен на email', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при генерации еженедельного отчета:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Получение категорий
|
// Получение категорий
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
|
try {
|
||||||
|
const rows = await this.apiRequest('/categories');
|
||||||
|
this.categories = (rows || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description || '',
|
||||||
|
icon: r.icon || 'fas fa-tag',
|
||||||
|
color: r.color || '#2c5aa0',
|
||||||
|
active: !!(r.active ?? 1),
|
||||||
|
itemCount: Number(r.itemCount || 0)
|
||||||
|
}));
|
||||||
|
this.saveCategories();
|
||||||
return this.categories;
|
return this.categories;
|
||||||
|
} catch (e) {
|
||||||
|
return this.categories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение категории по ID
|
||||||
|
async getCategoryById(id) {
|
||||||
|
return this.categories.find(cat => cat.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавление категории
|
// Добавление категории
|
||||||
async addCategory(categoryData) {
|
async addCategory(categoryData) {
|
||||||
const newCategory = {
|
const newCategory = {
|
||||||
id: categoryData.id || this.generateCategoryId(),
|
id: this.generateCategoryId(),
|
||||||
name: categoryData.name,
|
...categoryData,
|
||||||
icon: categoryData.icon || 'fas fa-box'
|
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.categories.push(newCategory);
|
||||||
|
this.saveCategories();
|
||||||
return newCategory;
|
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 для товара
|
// Генерация следующего ID для товара
|
||||||
getNextItemId() {
|
getNextItemId() {
|
||||||
const maxId = Math.max(...this.items.map(item => item.id), 0);
|
const maxId = Math.max(...this.items.map(item => item.id), 0);
|
||||||
|
|||||||
88
login.html
88
login.html
@ -8,8 +8,8 @@
|
|||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="d-flex flex-column min-vh-100">
|
||||||
<div class="login-container">
|
<div class="login-container flex-grow-1">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<i class="fas fa-warehouse fa-3x mb-3"></i>
|
<i class="fas fa-warehouse fa-3x mb-3"></i>
|
||||||
@ -17,22 +17,30 @@
|
|||||||
<p class="mb-0">Система управления складом</p>
|
<p class="mb-0">Система управления складом</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-body">
|
<div class="login-body">
|
||||||
<form id="loginForm">
|
<form id="loginForm" novalidate>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">
|
<label for="username" class="form-label">
|
||||||
<i class="fas fa-user me-2"></i>Имя пользователя
|
<i class="fas fa-user me-2"></i>Имя пользователя
|
||||||
</label>
|
</label>
|
||||||
<input type="text" class="form-control" id="username" required>
|
<input type="text" class="form-control" id="username" required
|
||||||
|
placeholder="Введите имя пользователя" autocomplete="username">
|
||||||
|
<div class="invalid-feedback" id="usernameError"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="password" class="form-label">
|
<label for="password" class="form-label">
|
||||||
<i class="fas fa-lock me-2"></i>Пароль
|
<i class="fas fa-lock me-2"></i>Пароль
|
||||||
</label>
|
</label>
|
||||||
<input type="password" class="form-control" id="password" required>
|
<input type="password" class="form-control" id="password" required
|
||||||
|
placeholder="Введите пароль" autocomplete="current-password">
|
||||||
|
<div class="invalid-feedback" id="passwordError"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100 mb-3">
|
<button type="submit" class="btn btn-primary w-100 mb-3" id="loginBtn">
|
||||||
<i class="fas fa-sign-in-alt me-2"></i>Войти в систему
|
<i class="fas fa-sign-in-alt me-2"></i>Войти в систему
|
||||||
</button>
|
</button>
|
||||||
|
<div id="loginLoading" class="text-center" style="display: none;">
|
||||||
|
<div class="loading-spinner text-primary mb-2"></div>
|
||||||
|
<small class="text-muted">Выполняется вход...</small>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Информация о тестовых пользователях -->
|
<!-- Информация о тестовых пользователях -->
|
||||||
@ -100,9 +108,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-dark text-white py-3 mt-auto">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small>
|
||||||
|
<i class="fas fa-shield-alt me-2"></i>Безопасная система авторизации
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-md-end">
|
||||||
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<!-- Скрипты -->
|
<!-- Скрипты -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js"></script>
|
||||||
|
|
||||||
|
<!-- Дополнительный JavaScript для улучшения UX -->
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Обработка формы входа с улучшенной валидацией
|
||||||
|
$('#loginForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Сброс ошибок
|
||||||
|
$('.form-control').removeClass('is-invalid');
|
||||||
|
$('.invalid-feedback').hide();
|
||||||
|
|
||||||
|
const username = $('#username').val().trim();
|
||||||
|
const password = $('#password').val().trim();
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Валидация имени пользователя
|
||||||
|
if (!username) {
|
||||||
|
$('#username').addClass('is-invalid');
|
||||||
|
$('#usernameError').text('Введите имя пользователя').show();
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация пароля
|
||||||
|
if (!password) {
|
||||||
|
$('#password').addClass('is-invalid');
|
||||||
|
$('#passwordError').text('Введите пароль').show();
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
$('#loginBtn').prop('disabled', true);
|
||||||
|
$('#loginLoading').show();
|
||||||
|
|
||||||
|
// Вызываем метод входа из auth.js
|
||||||
|
auth.login();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Убираем ошибки при вводе
|
||||||
|
$('.form-control').on('input', function() {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
$(this).siblings('.invalid-feedback').hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Автофокус на поле имени пользователя
|
||||||
|
$('#username').focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
86
profile.html
86
profile.html
@ -9,51 +9,63 @@
|
|||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Навигационная панель -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="index.html">
|
<a class="navbar-brand" href="index.html">
|
||||||
<i class="fas fa-warehouse me-2"></i>
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
Склад-Менеджер
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="index.html">
|
<a class="nav-link" href="index.html">
|
||||||
<i class="fas fa-home me-1"></i>Главная
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="catalog.html">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="fas fa-boxes me-1"></i>Каталог
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="history.html">
|
<a class="nav-link" href="history.html">
|
||||||
<i class="fas fa-history me-1"></i>История
|
<i class="fas fa-history me-1"></i>История
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="search.html">
|
|
||||||
<i class="fas fa-search me-1"></i>Поиск
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-cog me-1"></i>Администрирование
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/categories.html">Категории товаров</a></li>
|
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="profileDropdown">
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-user me-1"></i>Профиль
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
</a>
|
</a>
|
||||||
@ -68,6 +80,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Заголовок профиля -->
|
<!-- Заголовок профиля -->
|
||||||
<div class="profile-header">
|
<div class="profile-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@ -137,6 +167,9 @@
|
|||||||
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
|
||||||
<i class="fas fa-chart-line me-1"></i>Моя активность
|
<i class="fas fa-chart-line me-1"></i>Моя активность
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-success btn-sm" onclick="profileManager.generateWeeklyReport()">
|
||||||
|
<i class="fas fa-file-alt me-1"></i>Сгенерировать отчет
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -259,24 +292,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<h6>Интерфейс</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="themeSelect" class="form-label">Тема оформления</label>
|
|
||||||
<select class="form-select" id="themeSelect">
|
|
||||||
<option value="light">Светлая</option>
|
|
||||||
<option value="dark">Темная</option>
|
|
||||||
<option value="auto">Автоматически</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="languageSelect" class="form-label">Язык</label>
|
|
||||||
<select class="form-select" id="languageSelect">
|
|
||||||
<option value="ru">Русский</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
|
||||||
@ -384,7 +400,7 @@
|
|||||||
<p class="mb-0">Эффективное управление товарными запасами</p>
|
<p class="mb-0">Эффективное управление товарными запасами</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
<div class="col-md-6 text-md-end">
|
||||||
<p class="mb-0">© 2024 Склад-Менеджер. Все права защищены.</p>
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
70
search.html
70
search.html
@ -9,51 +9,63 @@
|
|||||||
<link href="css/style.css" rel="stylesheet">
|
<link href="css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Навигационная панель -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
|
<!-- Главная навигационная панель -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="index.html">
|
<a class="navbar-brand" href="index.html">
|
||||||
<i class="fas fa-warehouse me-2"></i>
|
<i class="fas fa-warehouse me-2 text-primary"></i>
|
||||||
Склад-Менеджер
|
<span class="fw-bold">Склад-Менеджер</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Поиск в шапке -->
|
||||||
|
<div class="navbar-search me-auto ms-4">
|
||||||
|
<div class="input-group" style="width: 400px;">
|
||||||
|
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
|
||||||
|
<button class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="index.html">
|
<a class="nav-link" href="index.html">
|
||||||
<i class="fas fa-home me-1"></i>Главная
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="catalog.html">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="fas fa-boxes me-1"></i>Каталог
|
<i class="fas fa-boxes me-1"></i>Каталог
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="history.html">
|
<a class="nav-link" href="history.html">
|
||||||
<i class="fas fa-history me-1"></i>История
|
<i class="fas fa-history me-1"></i>История
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" href="search.html">
|
|
||||||
<i class="fas fa-search me-1"></i>Поиск
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-cog me-1"></i>Администрирование
|
<i class="fas fa-cog me-1"></i>Администрирование
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/categories.html">Категории товаров</a></li>
|
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
|
||||||
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown" id="profileDropdown">
|
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<i class="fas fa-user me-1"></i>Профиль
|
<i class="fas fa-user me-1"></i>Профиль
|
||||||
</a>
|
</a>
|
||||||
@ -68,6 +80,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Панель категорий -->
|
||||||
|
<div class="categories-bar bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-pills categories-nav" id="categoriesNav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<i class="fas fa-home me-1"></i>Главная
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Категории будут загружены динамически -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Основной контент -->
|
<!-- Основной контент -->
|
||||||
<main class="container mt-4">
|
<main class="container mt-4">
|
||||||
<!-- Заголовок страницы -->
|
<!-- Заголовок страницы -->
|
||||||
@ -176,7 +206,7 @@
|
|||||||
<ul class="nav nav-tabs mb-3" id="resultsTabs">
|
<ul class="nav nav-tabs mb-3" id="resultsTabs">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" id="items-tab" data-bs-toggle="tab" href="#itemsResults">
|
<a class="nav-link active" id="items-tab" data-bs-toggle="tab" href="#itemsResults">
|
||||||
<i class="fas fa-boxes me-1"></i>Товары <span class="badge bg-secondary" id="itemsCount">0</span>
|
<i class="fas fa-boxes me-1"></i>Позиции <span class="badge bg-secondary" id="itemsCount">0</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@ -233,7 +263,7 @@
|
|||||||
<div id="emptyState" class="text-center py-5">
|
<div id="emptyState" class="text-center py-5">
|
||||||
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||||||
<h4>Начните поиск</h4>
|
<h4>Начните поиск</h4>
|
||||||
<p class="text-muted">Введите запрос в поле поиска выше, чтобы найти товары или операции</p>
|
<p class="text-muted">Введите запрос в поле поиска выше, чтобы найти позиции или операции</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Состояние загрузки -->
|
<!-- Состояние загрузки -->
|
||||||
@ -250,7 +280,7 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">
|
<h5 class="modal-title">
|
||||||
<i class="fas fa-box me-2"></i>
|
<i class="fas fa-box me-2"></i>
|
||||||
Детали товара
|
Детали позиции
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
@ -276,7 +306,7 @@
|
|||||||
<p class="mb-0">Эффективное управление товарными запасами</p>
|
<p class="mb-0">Эффективное управление товарными запасами</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
<div class="col-md-6 text-md-end">
|
||||||
<p class="mb-0">© 2024 Склад-Менеджер. Все права защищены.</p>
|
<p class="mb-0">© 2025 Склад-Менеджер. Все права защищены.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
server/env.email.example
Normal file
31
server/env.email.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Email Configuration
|
||||||
|
# Скопируйте этот файл в .env и настройте параметры для вашего SMTP-сервера
|
||||||
|
|
||||||
|
# SMTP Server Configuration
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
|
||||||
|
# Для Gmail используйте App Password вместо обычного пароля
|
||||||
|
# Инструкция: https://support.google.com/accounts/answer/185833
|
||||||
|
|
||||||
|
# Альтернативные SMTP-серверы:
|
||||||
|
# Yandex: smtp.yandex.ru:587
|
||||||
|
# Mail.ru: smtp.mail.ru:587
|
||||||
|
# Outlook: smtp-mail.outlook.com:587
|
||||||
|
|
||||||
|
# Пример для Yandex:
|
||||||
|
# SMTP_HOST=smtp.yandex.ru
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@yandex.ru
|
||||||
|
# SMTP_PASS=your-password
|
||||||
|
# SMTP_FROM=your-email@yandex.ru
|
||||||
|
|
||||||
|
# Пример для Mail.ru:
|
||||||
|
# SMTP_HOST=smtp.mail.ru
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@mail.ru
|
||||||
|
# SMTP_PASS=your-password
|
||||||
|
# SMTP_FROM=your-email@mail.ru
|
||||||
30
server/env.production.example
Normal file
30
server/env.production.example
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Production Environment Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=warehouse_user
|
||||||
|
DB_PASSWORD=CHANGE_THIS_PASSWORD_IN_PRODUCTION
|
||||||
|
DB_NAME=warehouse_manager
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
JWT_SECRET=CHANGE_THIS_JWT_SECRET_IN_PRODUCTION_USE_STRONG_RANDOM_STRING
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGIN=https://your-domain.com
|
||||||
|
CORS_CREDENTIALS=true
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CONNECTION_LIMIT=20
|
||||||
|
QUEUE_LIMIT=0
|
||||||
33
server/package.json
Normal file
33
server/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "warehouse-manager-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"start:prod": "NODE_ENV=production node src/server.js",
|
||||||
|
"migrate": "node scripts/migrate.js",
|
||||||
|
"seed": "node scripts/seed.js",
|
||||||
|
"setup": "npm run migrate && npm run seed",
|
||||||
|
"setup:prod": "NODE_ENV=production npm run setup",
|
||||||
|
"test": "echo \"No tests specified\" && exit 0",
|
||||||
|
"lint": "echo \"No linter configured\" && exit 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"joi": "^17.13.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"pino": "^9.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
server/scripts/migrate.js
Normal file
26
server/scripts/migrate.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import pool from '../src/config/db.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const schemaPath = path.join(__dirname, '..', 'sql', 'schema.sql');
|
||||||
|
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await conn.query(stmt);
|
||||||
|
}
|
||||||
|
console.log('Migration completed');
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
|
||||||
|
|
||||||
26
server/scripts/seed.js
Normal file
26
server/scripts/seed.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import pool from '../src/config/db.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const seedPath = path.join(__dirname, '..', 'sql', 'seed.sql');
|
||||||
|
const sql = fs.readFileSync(seedPath, 'utf8');
|
||||||
|
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await conn.query(stmt);
|
||||||
|
}
|
||||||
|
console.log('Seed completed');
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
|
||||||
|
|
||||||
83
server/sql/schema.sql
Normal file
83
server/sql/schema.sql
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
-- MySQL schema for warehouse manager
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
email VARCHAR(128) NOT NULL,
|
||||||
|
department VARCHAR(128) DEFAULT NULL,
|
||||||
|
position VARCHAR(128) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(32) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(32) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, role_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
permission_id INT NOT NULL,
|
||||||
|
PRIMARY KEY(role_id, permission_id),
|
||||||
|
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon VARCHAR(64) DEFAULT 'fas fa-tag',
|
||||||
|
color VARCHAR(16) DEFAULT '#2c5aa0',
|
||||||
|
active TINYINT(1) DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NULL,
|
||||||
|
price DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
quantity INT NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
barcode VARCHAR(128),
|
||||||
|
min_quantity INT NOT NULL DEFAULT 0,
|
||||||
|
location VARCHAR(255),
|
||||||
|
supplier VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type ENUM('incoming','outgoing') NOT NULL,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
status ENUM('completed','pending','cancelled') DEFAULT 'completed',
|
||||||
|
notes TEXT,
|
||||||
|
supplier VARCHAR(255),
|
||||||
|
recipient VARCHAR(255),
|
||||||
|
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(employee_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
43
server/sql/seed.sql
Normal file
43
server/sql/seed.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- Seed roles and permissions
|
||||||
|
INSERT IGNORE INTO roles (id, name) VALUES
|
||||||
|
(1,'admin'),(2,'manager'),(3,'operator'),(4,'viewer');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO permissions (id, name) VALUES
|
||||||
|
(1,'read'),(2,'write'),(3,'delete'),(4,'admin');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(1,1),(1,2),(1,3),(1,4), -- admin: all
|
||||||
|
(2,1),(2,2), -- manager: read, write
|
||||||
|
(3,1), -- operator: read
|
||||||
|
(4,1); -- viewer: read
|
||||||
|
|
||||||
|
-- Users (CHANGE THESE PASSWORDS IN PRODUCTION!)
|
||||||
|
-- Default passwords: admin123, manager123, operator123, viewer123
|
||||||
|
-- Use bcrypt or similar for production hashing
|
||||||
|
INSERT INTO users (id, username, password_hash, name, email, department, position) VALUES
|
||||||
|
(1,'admin','admin123','Администратор','admin@company.com','IT','Системный администратор'),
|
||||||
|
(2,'manager','manager123','Менеджер склада','manager@company.com','Склад','Менеджер склада'),
|
||||||
|
(3,'operator','operator123','Оператор','operator@company.com','Склад','Оператор склада'),
|
||||||
|
(4,'viewer','viewer123','Наблюдатель','viewer@company.com','Бухгалтерия','Бухгалтер')
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
|
||||||
|
(1,1), (2,2), (3,3), (4,4);
|
||||||
|
|
||||||
|
-- Categories
|
||||||
|
INSERT INTO categories (id, name, description, icon, color, active) VALUES
|
||||||
|
(1,'Электроника','Техника и гаджеты','fas fa-laptop','#2c5aa0',1),
|
||||||
|
(2,'Инструменты','Строительные и слесарные инструменты','fas fa-tools','#1a7f37',1),
|
||||||
|
(3,'Офис','Канцелярия и офисные товары','fas fa-briefcase','#8a2be2',1)
|
||||||
|
ON DUPLICATE KEY UPDATE name=VALUES(name);
|
||||||
|
|
||||||
|
-- Items
|
||||||
|
INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier) VALUES
|
||||||
|
('Ноутбук Lenovo', 1, 65000, 10, '15.6" Ryzen 5', 'LN-12345', 2, 'Стеллаж A1', 'Lenovo'),
|
||||||
|
('Шуруповерт Bosch', 2, 8500, 25, 'Аккумуляторный 18В', 'BS-98765', 5, 'Стеллаж B2', 'Bosch'),
|
||||||
|
('Бумага A4', 3, 350, 200, 'Пачка 500 листов', 'A4-11111', 50, 'Стеллаж C3', 'Svetocopy');
|
||||||
|
|
||||||
|
-- Sample operations (today)
|
||||||
|
INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier)
|
||||||
|
VALUES ('incoming', 1, 5, 1, 'completed', 'Поступление партии', 'Lenovo');
|
||||||
|
|
||||||
18
server/src/config/db.js
Normal file
18
server/src/config/db.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'warehouse_manager',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
|
|
||||||
47
server/src/routes/auth.js
Normal file
47
server/src/routes/auth.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' });
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, username, name, email, department, position, password_hash FROM users WHERE username = ? LIMIT 1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const user = rows[0];
|
||||||
|
// NOTE: For simplicity, compare plain text if password_hash is null; otherwise use MySQL PASSWORD() or bcrypt in seed
|
||||||
|
const isValid = !user.password_hash ? password === 'admin123' : password === user.password_hash;
|
||||||
|
if (!isValid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
|
const [roleRows] = await pool.query(
|
||||||
|
'SELECT r.name as role FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
const [permRows] = await pool.query(
|
||||||
|
'SELECT p.name as permission FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE rp.role_id IN (SELECT role_id FROM user_roles WHERE user_id = ?)',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
roles: roleRows.map(r => r.role),
|
||||||
|
permissions: permRows.map(p => p.permission)
|
||||||
|
};
|
||||||
|
const token = jwt.sign(payload, process.env.JWT_SECRET || 'dev_secret', { expiresIn: '8h' });
|
||||||
|
res.json({ token, user: payload });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
46
server/src/routes/categories.js
Normal file
46
server/src/routes/categories.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT c.*, (SELECT COUNT(*) FROM items i WHERE i.category_id = c.id) AS itemCount FROM categories c');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, icon, color, active } = req.body;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO categories (name, description, icon, color, active) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, description, icon, color, active } = req.body;
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE categories SET name=?, description=?, icon=?, color=?, active=?, updated_at=NOW() WHERE id = ?',
|
||||||
|
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0, req.params.id]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM items WHERE category_id = ?', [req.params.id]);
|
||||||
|
await pool.query('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
54
server/src/routes/items.js
Normal file
54
server/src/routes/items.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '']
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE items SET name=?, category_id=?, price=?, quantity=?, description=?, barcode=?, min_quantity=?, location=?, supplier=?, updated_at=NOW() WHERE id=?`,
|
||||||
|
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '', req.params.id]
|
||||||
|
);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM items WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
199
server/src/routes/notifications.js
Normal file
199
server/src/routes/notifications.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import emailService from '../services/emailService.js';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Отправка уведомления о низком остатке
|
||||||
|
router.post('/low-stock', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items } = req.body;
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать список товаров' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('lowStockAlerts');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendLowStockAlert(user, items);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Уведомления о низком остатке отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений о низком остатке:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
router.post('/operation', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { operation } = req.body;
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать данные операции' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('emailNotifications');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendOperationNotification(user, operation);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Уведомления об операции отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомлений об операции:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка еженедельного отчета
|
||||||
|
router.post('/weekly-report', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { report } = req.body;
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать данные отчета' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей с включенными уведомлениями
|
||||||
|
const users = await emailService.getUsersWithNotifications('operationReports');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const result = await emailService.sendWeeklyReport(user, report);
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'success',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Еженедельные отчеты отправлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки еженедельных отчетов:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки отчетов' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Тестовая отправка email
|
||||||
|
router.post('/test', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, subject, message } = req.body;
|
||||||
|
|
||||||
|
if (!email || !subject || !message) {
|
||||||
|
return res.status(400).json({ error: 'Необходимо указать email, subject и message' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const testUser = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Тестовый пользователь',
|
||||||
|
email: email
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: subject,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2>Тестовое уведомление</h2>
|
||||||
|
<p>${message}</p>
|
||||||
|
<hr>
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это тестовое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Тестовое уведомление отправлено',
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки тестового уведомления:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка отправки тестового уведомления' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение статуса email-сервиса
|
||||||
|
router.get('/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = {
|
||||||
|
service: 'email',
|
||||||
|
status: 'active',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
features: {
|
||||||
|
lowStockAlerts: true,
|
||||||
|
operationNotifications: true,
|
||||||
|
weeklyReports: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения статуса email-сервиса:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка получения статуса' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
80
server/src/routes/operations.js
Normal file
80
server/src/routes/operations.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { type, startDate, endDate, itemId, sortBy = 'date', sortOrder = 'desc' } = req.query;
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
if (type) { where.push('o.type = ?'); params.push(type); }
|
||||||
|
if (startDate) { where.push('o.date >= ?'); params.push(startDate); }
|
||||||
|
if (endDate) { where.push('o.date <= ?'); params.push(endDate); }
|
||||||
|
if (itemId) { where.push('o.item_id = ?'); params.push(itemId); }
|
||||||
|
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
const orderSql = `ORDER BY o.${sortBy === 'quantity' ? 'quantity' : 'date'} ${sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'}`;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT o.id, o.date, o.type, o.quantity, o.status, o.notes, o.supplier, o.recipient,
|
||||||
|
i.name as itemName, i.id as itemId,
|
||||||
|
u.name as employeeName, u.id as employeeId
|
||||||
|
FROM operations o
|
||||||
|
JOIN items i ON i.id = o.item_id
|
||||||
|
JOIN users u ON u.id = o.employee_id
|
||||||
|
${whereSql}
|
||||||
|
${orderSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/incoming', async (req, res, next) => {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const { itemId, quantity, supplier, notes, employeeId } = req.body;
|
||||||
|
await conn.beginTransaction();
|
||||||
|
await conn.query('UPDATE items SET quantity = quantity + ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier) VALUES ("incoming", ?, ?, ?, "completed", ?, ?)',
|
||||||
|
[itemId, quantity, employeeId || 1, notes || '', supplier || '']
|
||||||
|
);
|
||||||
|
await conn.commit();
|
||||||
|
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
next(e);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/outgoing', async (req, res, next) => {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const { itemId, quantity, recipient, notes, employeeId } = req.body;
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [itemRows] = await conn.query('SELECT quantity FROM items WHERE id = ?', [itemId]);
|
||||||
|
if (!itemRows.length) throw new Error('Item not found');
|
||||||
|
if (itemRows[0].quantity < quantity) {
|
||||||
|
return res.status(400).json({ error: 'Insufficient quantity' });
|
||||||
|
}
|
||||||
|
await conn.query('UPDATE items SET quantity = quantity - ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, recipient) VALUES ("outgoing", ?, ?, ?, "completed", ?, ?)',
|
||||||
|
[itemId, quantity, employeeId || 1, notes || '', recipient || '']
|
||||||
|
);
|
||||||
|
await conn.commit();
|
||||||
|
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
next(e);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
142
server/src/routes/reports.js
Normal file
142
server/src/routes/reports.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import { logger } from '../config/logger.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Хелпер: построить условия фильтра
|
||||||
|
function buildFilters(query) {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (query.dateFrom) {
|
||||||
|
where.push('op.date >= ?');
|
||||||
|
params.push(query.dateFrom);
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
where.push('op.date <= ?');
|
||||||
|
params.push(query.dateTo + ' 23:59:59');
|
||||||
|
}
|
||||||
|
if (query.opType) {
|
||||||
|
where.push('op.type = ?');
|
||||||
|
params.push(query.opType);
|
||||||
|
}
|
||||||
|
if (query.categoryId) {
|
||||||
|
where.push('it.category_id = ?');
|
||||||
|
params.push(query.categoryId);
|
||||||
|
}
|
||||||
|
if (query.itemQuery) {
|
||||||
|
where.push('(it.name LIKE ? OR it.barcode LIKE ?)');
|
||||||
|
params.push(`%${query.itemQuery}%`, `%${query.itemQuery}%`);
|
||||||
|
}
|
||||||
|
if (query.employeeQuery) {
|
||||||
|
where.push('(u.username LIKE ? OR u.name LIKE ?)');
|
||||||
|
params.push(`%${query.employeeQuery}%`, `%${query.employeeQuery}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
return { whereSql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сводные показатели
|
||||||
|
router.get('/summary', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing,
|
||||||
|
COUNT(*) AS totalOperations,
|
||||||
|
COUNT(DISTINCT op.item_id) AS uniqueItems
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows[0] || { totalIncoming: 0, totalOutgoing: 0, totalOperations: 0, uniqueItems: 0 });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Операции (подробный список)
|
||||||
|
router.get('/operations', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
op.id,
|
||||||
|
op.date,
|
||||||
|
op.type,
|
||||||
|
op.quantity,
|
||||||
|
it.name AS itemName,
|
||||||
|
cat.name AS categoryName,
|
||||||
|
u.username AS username,
|
||||||
|
u.name AS employeeName
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN categories cat ON cat.id = it.category_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY op.date DESC
|
||||||
|
LIMIT 1000
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Остатки по категориям
|
||||||
|
router.get('/by-category', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Для by-category учитываем только фильтры по категории/дате не критично; считаем текущие остатки
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
cat.name AS categoryName,
|
||||||
|
COUNT(it.id) AS itemCount,
|
||||||
|
COALESCE(SUM(it.quantity), 0) AS totalQuantity
|
||||||
|
FROM categories cat
|
||||||
|
LEFT JOIN items it ON it.category_id = cat.id
|
||||||
|
GROUP BY cat.id
|
||||||
|
ORDER BY cat.name
|
||||||
|
`);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Топ товаров по движению
|
||||||
|
router.get('/top-items', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { whereSql, params } = buildFilters(req.query);
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
it.name AS itemName,
|
||||||
|
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing
|
||||||
|
FROM operations op
|
||||||
|
JOIN items it ON it.id = op.item_id
|
||||||
|
LEFT JOIN users u ON u.id = op.employee_id
|
||||||
|
${whereSql}
|
||||||
|
GROUP BY it.id
|
||||||
|
ORDER BY (SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) +
|
||||||
|
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END)) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
19
server/src/routes/stats.js
Normal file
19
server/src/routes/stats.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../config/db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[{ totalItems }]] = await pool.query('SELECT COALESCE(SUM(quantity),0) as totalItems FROM items');
|
||||||
|
const [[{ totalValue }]] = await pool.query('SELECT COALESCE(SUM(price*quantity),0) as totalValue FROM items');
|
||||||
|
const [[{ lowStockItems }]] = await pool.query('SELECT COUNT(*) as lowStockItems FROM items WHERE quantity <= min_quantity');
|
||||||
|
const [[{ todayOperations }]] = await pool.query('SELECT COUNT(*) as todayOperations FROM operations WHERE DATE(date) = CURDATE()');
|
||||||
|
const [[{ totalCategories }]] = await pool.query('SELECT COUNT(*) as totalCategories FROM categories');
|
||||||
|
const [[{ totalOperations }]] = await pool.query('SELECT COUNT(*) as totalOperations FROM operations');
|
||||||
|
res.json({ totalItems, totalValue, lowStockItems, todayOperations, totalCategories, totalOperations });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
522
server/src/routes/users.js
Normal file
522
server/src/routes/users.js
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { authenticateToken, requireRole } from '../middleware/auth.js';
|
||||||
|
import { logger } from '../config/logger.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Получить всех пользователей (только для админов)
|
||||||
|
router.get('/', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Преобразуем данные в нужный формат
|
||||||
|
const users = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
department: row.department,
|
||||||
|
position: row.position,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
lastLogin: row.last_login,
|
||||||
|
roles: row.roles ? row.roles.split(',') : [],
|
||||||
|
permissions: row.permissions ? row.permissions.split(',') : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении пользователей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить пользователя по ID (только для админов)
|
||||||
|
router.get('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(userData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создать нового пользователя (только для админов)
|
||||||
|
router.post('/', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, name, email, department, position, password, roles } = req.body;
|
||||||
|
|
||||||
|
// Валидация данных
|
||||||
|
if (!username || !name || !email || !password || !roles || roles.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const [existingUsers] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE username = ?',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности email
|
||||||
|
const [existingEmails] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE email = ?',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем пользователя
|
||||||
|
const [result] = await connection.execute(`
|
||||||
|
INSERT INTO users (username, name, email, department, position, password_hash, active, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, NOW())
|
||||||
|
`, [username, name, email, department || null, position || null, password]);
|
||||||
|
|
||||||
|
const userId = result.insertId;
|
||||||
|
|
||||||
|
// Добавляем роли пользователю
|
||||||
|
for (const roleName of roles) {
|
||||||
|
const [roleResult] = await connection.execute(
|
||||||
|
'SELECT id FROM roles WHERE name = ?',
|
||||||
|
[roleName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roleResult.length > 0) {
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
|
||||||
|
[userId, roleResult[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
// Получаем созданного пользователя
|
||||||
|
const [newUser] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
const user = newUser[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Создан новый пользователь: ${username} (ID: ${userId})`);
|
||||||
|
res.status(201).json(userData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при создании пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить пользователя (только для админов)
|
||||||
|
router.put('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
const { username, name, email, department, position, password, roles } = req.body;
|
||||||
|
|
||||||
|
// Валидация данных
|
||||||
|
if (!username || !name || !email || !roles || roles.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password && password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности username
|
||||||
|
const [existingUsers] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE username = ? AND id != ?',
|
||||||
|
[username, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности email
|
||||||
|
const [existingEmails] = await pool.execute(
|
||||||
|
'SELECT id FROM users WHERE email = ? AND id != ?',
|
||||||
|
[email, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Обновляем основные данные пользователя
|
||||||
|
const updateFields = ['username = ?', 'name = ?', 'email = ?', 'department = ?', 'position = ?'];
|
||||||
|
const updateValues = [username, name, email, department || null, position || null];
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
updateFields.push('password_hash = ?');
|
||||||
|
updateValues.push(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValues.push(userId);
|
||||||
|
|
||||||
|
await connection.execute(`
|
||||||
|
UPDATE users
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = ?
|
||||||
|
`, updateValues);
|
||||||
|
|
||||||
|
// Удаляем старые роли
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM user_roles WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Добавляем новые роли
|
||||||
|
for (const roleName of roles) {
|
||||||
|
const [roleResult] = await connection.execute(
|
||||||
|
'SELECT id FROM roles WHERE name = ?',
|
||||||
|
[roleName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roleResult.length > 0) {
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
|
||||||
|
[userId, roleResult[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
// Получаем обновленного пользователя
|
||||||
|
const [updatedUser] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.department,
|
||||||
|
u.position,
|
||||||
|
u.active,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name) as roles,
|
||||||
|
GROUP_CONCAT(DISTINCT p.name) as permissions
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN roles r ON ur.role_id = r.id
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
const user = updatedUser[0];
|
||||||
|
const userData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
department: user.department,
|
||||||
|
position: user.position,
|
||||||
|
active: Boolean(user.active),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLogin: user.last_login,
|
||||||
|
roles: user.roles ? user.roles.split(',') : [],
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : []
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Обновлен пользователь: ${username} (ID: ${userId})`);
|
||||||
|
res.json(userData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при обновлении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалить пользователя (только для админов)
|
||||||
|
router.delete('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT username FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем роли пользователя
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM user_roles WHERE user_id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Удаляем пользователя
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
logger.info(`Удален пользователь: ${existingUser[0].username} (ID: ${userId})`);
|
||||||
|
res.json({ message: 'Пользователь успешно удален' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при удалении пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переключить статус пользователя (активен/неактивен)
|
||||||
|
router.patch('/:id/toggle-status', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
const [existingUser] = await pool.execute(
|
||||||
|
'SELECT username, active FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !existingUser[0].active;
|
||||||
|
|
||||||
|
// Обновляем статус
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE users SET active = ? WHERE id = ?',
|
||||||
|
[newStatus, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Изменен статус пользователя: ${existingUser[0].username} (ID: ${userId}) на ${newStatus ? 'активен' : 'неактивен'}`);
|
||||||
|
res.json({
|
||||||
|
message: `Пользователь ${newStatus ? 'активирован' : 'деактивирован'}`,
|
||||||
|
active: newStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при изменении статуса пользователя:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить статистику пользователей (только для админов)
|
||||||
|
router.get('/stats/overview', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [stats] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
SUM(active) as active_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'admin') THEN 1 ELSE 0 END) as admin_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'manager') THEN 1 ELSE 0 END) as manager_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'operator') THEN 1 ELSE 0 END) as operator_users,
|
||||||
|
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'viewer') THEN 1 ELSE 0 END) as viewer_users
|
||||||
|
FROM users
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(stats[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении статистики пользователей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить роли для выбора
|
||||||
|
router.get('/roles/available', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [roles] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.description,
|
||||||
|
GROUP_CONCAT(p.name) as permissions
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN role_permissions rp ON r.id = rp.role_id
|
||||||
|
LEFT JOIN permissions p ON rp.permission_id = p.id
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY r.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rolesData = roles.map(role => ({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissions: role.permissions ? role.permissions.split(',') : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(rolesData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении ролей:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить разрешения для выбора
|
||||||
|
router.get('/permissions/available', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [permissions] = await pool.execute(`
|
||||||
|
SELECT id, name, description
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(permissions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка при получении разрешений:', error);
|
||||||
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
55
server/src/server.js
Normal file
55
server/src/server.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import pino from 'pino';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import itemRoutes from './routes/items.js';
|
||||||
|
import categoryRoutes from './routes/categories.js';
|
||||||
|
import operationRoutes from './routes/operations.js';
|
||||||
|
import statsRoutes from './routes/stats.js';
|
||||||
|
import usersRoutes from './routes/users.js';
|
||||||
|
import reportsRoutes from './routes/reports.js';
|
||||||
|
import notificationsRoutes from './routes/notifications.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const logger = pino({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' });
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/items', itemRoutes);
|
||||||
|
app.use('/api/categories', categoryRoutes);
|
||||||
|
app.use('/api/operations', operationRoutes);
|
||||||
|
app.use('/api/stats', statsRoutes);
|
||||||
|
app.use('/api/users', usersRoutes);
|
||||||
|
app.use('/api/reports', reportsRoutes);
|
||||||
|
app.use('/api/notifications', notificationsRoutes);
|
||||||
|
|
||||||
|
// Not found handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not Found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3001);
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info(`API listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
248
server/src/services/emailService.js
Normal file
248
server/src/services/emailService.js
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
|
||||||
|
class EmailService {
|
||||||
|
constructor() {
|
||||||
|
this.transporter = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// Создаем транспортер для отправки email
|
||||||
|
this.transporter = nodemailer.createTransporter({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
|
port: process.env.SMTP_PORT || 587,
|
||||||
|
secure: false, // true для 465, false для других портов
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем соединение
|
||||||
|
await this.transporter.verify();
|
||||||
|
console.log('Email сервис инициализирован успешно');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации email сервиса:', error);
|
||||||
|
// Создаем мок-транспортер для демо-режима
|
||||||
|
this.transporter = {
|
||||||
|
sendMail: async (options) => {
|
||||||
|
console.log('📧 ДЕМО-РЕЖИМ: Email отправлен:', {
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
text: options.text
|
||||||
|
});
|
||||||
|
return { messageId: 'demo-' + Date.now() };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления о низком остатке
|
||||||
|
async sendLowStockAlert(user, items) {
|
||||||
|
try {
|
||||||
|
const itemList = items.map(item =>
|
||||||
|
`- ${item.name}: ${item.quantity} шт. (мин. ${item.minQuantity} шт.)`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: '⚠️ Внимание! Товары с низким запасом',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #dc3545;">⚠️ Внимание! Товары с низким запасом</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Система обнаружила товары с низким запасом на складе:</p>
|
||||||
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<pre style="margin: 0; font-family: monospace;">${itemList}</pre>
|
||||||
|
</div>
|
||||||
|
<p><strong>Рекомендуется:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Проверить остатки на складе</li>
|
||||||
|
<li>Оформить заказ на пополнение</li>
|
||||||
|
<li>Связаться с поставщиками</li>
|
||||||
|
</ul>
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматическое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомления о низком остатке:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка уведомления об операции
|
||||||
|
async sendOperationNotification(user, operation) {
|
||||||
|
try {
|
||||||
|
const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
|
||||||
|
const typeColor = operation.type === 'incoming' ? '#28a745' : '#ffc107';
|
||||||
|
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: `📦 Операция: ${operationType} - ${operation.itemName}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: ${typeColor};">📦 Операция: ${operationType}</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Была выполнена операция на складе:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Товар:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.itemName}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Количество:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.quantity} шт.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Тип операции:</td>
|
||||||
|
<td style="padding: 8px 0; color: ${typeColor}; font-weight: bold;">${operationType}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Дата:</td>
|
||||||
|
<td style="padding: 8px 0;">${new Date(operation.date).toLocaleString('ru-RU')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; font-weight: bold;">Сотрудник:</td>
|
||||||
|
<td style="padding: 8px 0;">${operation.employeeName}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматическое уведомление от системы управления складом.<br>
|
||||||
|
Дата: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомления об операции:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка еженедельного отчета
|
||||||
|
async sendWeeklyReport(user, report) {
|
||||||
|
try {
|
||||||
|
const emailContent = {
|
||||||
|
to: user.email,
|
||||||
|
subject: '📊 Еженедельный отчет по операциям склада',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #007bff;">📊 Еженедельный отчет</h2>
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Представляем вашему вниманию еженедельный отчет по операциям склада:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Статистика за неделю:</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr style="background-color: #e9ecef;">
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Показатель</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Значение</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Всего операций</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">${report.totalOperations}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Приходы</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; color: #28a745;">${report.incoming}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6;">Расходы</td>
|
||||||
|
<td style="padding: 12px; border: 1px solid #dee2e6; color: #ffc107;">${report.outgoing}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${report.operations.length > 0 ? `
|
||||||
|
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Последние операции:</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
|
||||||
|
<tr style="background-color: #e9ecef;">
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Дата</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Тип</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Товар</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Кол-во</td>
|
||||||
|
</tr>
|
||||||
|
${report.operations.slice(0, 10).map(op => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${new Date(op.date).toLocaleDateString('ru-RU')}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6; color: ${op.type === 'incoming' ? '#28a745' : '#ffc107'};">${op.type === 'incoming' ? 'Приход' : 'Расход'}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.itemName}</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.quantity}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Это автоматический еженедельный отчет от системы управления складом.<br>
|
||||||
|
Дата формирования: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendEmail(emailContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки еженедельного отчета:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общая функция отправки email
|
||||||
|
async sendEmail(emailContent) {
|
||||||
|
try {
|
||||||
|
const mailOptions = {
|
||||||
|
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||||
|
to: emailContent.to,
|
||||||
|
subject: emailContent.subject,
|
||||||
|
html: emailContent.html
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.transporter.sendMail(mailOptions);
|
||||||
|
console.log('Email отправлен успешно:', result.messageId);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение пользователей с включенными уведомлениями
|
||||||
|
async getUsersWithNotifications(notificationType) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(`
|
||||||
|
SELECT u.id, u.name, u.email, u.department, u.position
|
||||||
|
FROM users u
|
||||||
|
WHERE u.active = 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
// В реальном приложении здесь была бы проверка настроек пользователей
|
||||||
|
// Пока возвращаем всех активных пользователей
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения пользователей:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EmailService();
|
||||||
Reference in New Issue
Block a user