Final version

This commit is contained in:
cyrteL
2025-08-22 19:26:43 +03:00
committed by GitHub
parent b4c1a2caa9
commit 558cd96cdf
31 changed files with 6181 additions and 1632 deletions

367
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,367 @@
# 🚀 Инструкции по развертыванию в продакшн
## 📋 Предварительные требования
### Системные требования
- **Node.js** 18+ LTS
- **MySQL** 8.0+
- **Nginx** (рекомендуется для прокси)
- **PM2** (для управления процессами)
### Минимальные характеристики сервера
- **CPU**: 2 ядра
- **RAM**: 4 GB
- **Диск**: 20 GB SSD
- **Сеть**: 100 Mbps
## 🔧 Установка и настройка
### 1. Подготовка сервера
```bash
# Обновление системы
sudo apt update && sudo apt upgrade -y
# Установка Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Установка MySQL
sudo apt install mysql-server -y
sudo mysql_secure_installation
# Установка PM2
sudo npm install -g pm2
# Установка Nginx
sudo apt install nginx -y
```
### 2. Настройка базы данных
```sql
-- Подключение к MySQL
sudo mysql
-- Создание базы данных
CREATE DATABASE warehouse_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Создание пользователя
CREATE USER 'warehouse_user'@'localhost' IDENTIFIED BY 'YOUR_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON warehouse_manager.* TO 'warehouse_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
### 3. Настройка приложения
```bash
# Клонирование проекта
git clone https://github.com/your-repo/warehouse-manager.git
cd warehouse-manager
# Установка зависимостей
cd server
npm install --production
# Создание production конфигурации
cp env.production.example .env
nano .env
```
### 4. Конфигурация .env
```env
# Production Environment Configuration
NODE_ENV=production
PORT=3001
# Database Configuration
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=warehouse_user
DB_PASSWORD=YOUR_STRONG_PASSWORD
DB_NAME=warehouse_manager
# Security Configuration
JWT_SECRET=YOUR_VERY_LONG_RANDOM_SECRET_KEY_HERE
JWT_EXPIRES_IN=8h
# CORS Configuration
CORS_ORIGIN=https://your-domain.com
CORS_CREDENTIALS=true
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# Logging
LOG_LEVEL=info
LOG_FILE=logs/app.log
# Performance
CONNECTION_LIMIT=20
QUEUE_LIMIT=0
```
### 5. Инициализация базы данных
```bash
# Создание таблиц
npm run migrate
# Заполнение тестовыми данными
npm run seed
# ИЛИ полная настройка
npm run setup:prod
```
### 6. Настройка PM2
```bash
# Создание PM2 конфигурации
cat > ecosystem.config.js << EOF
module.exports = {
apps: [{
name: 'warehouse-api',
script: 'src/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3001
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true,
max_memory_restart: '1G',
restart_delay: 4000,
max_restarts: 10
}]
};
EOF
# Создание директории для логов
mkdir -p logs
# Запуск приложения
pm2 start ecosystem.config.js
pm2 save
pm2 startup
```
### 7. Настройка Nginx
```bash
# Создание конфигурации
sudo nano /etc/nginx/sites-available/warehouse-manager
```
```nginx
server {
listen 80;
server_name your-domain.com www.your-domain.com;
# Редирект на HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com www.your-domain.com;
# SSL сертификаты (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL настройки
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Статические файлы фронтенда
location / {
root /var/www/warehouse-manager;
index index.html;
try_files $uri $uri/ /index.html;
# Кэширование
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# API прокси
location /api/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Rate limiting
limit_req zone=api burst=20 nodelay;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
}
# Безопасность
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
}
```
```bash
# Активация конфигурации
sudo ln -s /etc/nginx/sites-available/warehouse-manager /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 8. SSL сертификат (Let's Encrypt)
```bash
# Установка Certbot
sudo apt install certbot python3-certbot-nginx -y
# Получение сертификата
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# Автоматическое обновление
sudo crontab -e
# Добавить строку: 0 12 * * * /usr/bin/certbot renew --quiet
```
### 9. Развертывание фронтенда
```bash
# Копирование файлов
sudo cp -r . /var/www/warehouse-manager/
sudo chown -R www-data:www-data /var/www/warehouse-manager/
sudo chmod -R 755 /var/www/warehouse-manager/
```
## 🔒 Безопасность
### Обязательные меры безопасности
1. **Измените пароли по умолчанию:**
```sql
UPDATE users SET password_hash = 'NEW_HASHED_PASSWORD' WHERE username = 'admin';
```
2. **Настройте файрвол:**
```bash
sudo ufw enable
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw deny 3001 # Блокируем прямой доступ к API
```
3. **Регулярные обновления:**
```bash
# Автоматические обновления безопасности
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
```
4. **Мониторинг логов:**
```bash
# Просмотр логов приложения
pm2 logs warehouse-api
# Просмотр логов Nginx
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
```
## 📊 Мониторинг и обслуживание
### PM2 команды
```bash
# Статус приложений
pm2 status
# Перезапуск
pm2 restart warehouse-api
# Обновление кода
pm2 reload warehouse-api
# Просмотр логов
pm2 logs warehouse-api
# Мониторинг ресурсов
pm2 monit
```
### Резервное копирование
```bash
# Скрипт резервного копирования
cat > backup.sh << 'EOF'
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/warehouse-manager"
# Создание резервной копии БД
mysqldump -u warehouse_user -p warehouse_manager > $BACKUP_DIR/db_$DATE.sql
# Сжатие
gzip $BACKUP_DIR/db_$DATE.sql
# Удаление старых резервных копий (старше 30 дней)
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete
echo "Backup completed: $BACKUP_DIR/db_$DATE.sql.gz"
EOF
chmod +x backup.sh
# Добавление в cron (ежедневно в 2:00)
echo "0 2 * * * /path/to/backup.sh" | crontab -
```
## 🚨 Устранение неполадок
### Частые проблемы
1. **Приложение не запускается:**
```bash
pm2 logs warehouse-api
tail -f logs/err.log
```
2. **Ошибки подключения к БД:**
```bash
mysql -u warehouse_user -p warehouse_manager
```
3. **Проблемы с Nginx:**
```bash
sudo nginx -t
sudo systemctl status nginx
```
4. **Проблемы с SSL:**
```bash
sudo certbot certificates
sudo certbot renew --dry-run
```
## 📞 Поддержка
При возникновении проблем:
1. Проверьте логи: `pm2 logs warehouse-api`
2. Проверьте статус сервисов: `pm2 status`, `sudo systemctl status nginx`
3. Проверьте конфигурацию: `nginx -t`, `node -c src/server.js`

123
README.md
View File

@ -64,7 +64,8 @@ warehouse-system/
├── search.html # Поиск
├── profile.html # Профиль пользователя
├── admin/
── categories.html # Управление категориями
── categories.html # Управление категориями
│ └── users.html # Управление пользователями
├── css/
│ └── style.css # Основные стили
├── js/
@ -75,23 +76,14 @@ warehouse-system/
│ ├── history.js # История операций
│ ├── search.js # Поиск
│ ├── profile.js # Профиль пользователя
── categories.js # Управление категориями
── categories.js # Управление категориями
│ └── users.js # Управление пользователями
└── README.md # Документация
```
## 🛠️ Технологии
## 🚀 Старт
- **HTML5** - семантическая разметка
- **CSS3** - современные стили с CSS переменными
- **Bootstrap 5** - адаптивный дизайн
- **JavaScript (ES6+)** - интерактивность
- **jQuery** - упрощение работы с DOM
- **Font Awesome** - иконки
- **LocalStorage** - локальное хранение данных
## 🚀 Быстрый старт
### 1. Первый вход
### Для разработки/демо
1. Откройте `login.html`
2. Используйте тестовые учетные записи:
- **Администратор**: `admin` / `admin123`
@ -99,6 +91,18 @@ warehouse-system/
- **Оператор**: `operator` / `operator123`
- **Наблюдатель**: `viewer` / `viewer123`
### Настройка Email-уведомлений
1. Скопируйте `server/env.email.example` в `server/.env`
2. Настройте параметры SMTP-сервера:
- Для Gmail: используйте App Password
- Для Yandex/Mail.ru: используйте обычный пароль
3. Перезапустите сервер
4. Протестируйте отправку в разделе "Тест Email" (только для администраторов)
### Для продакшна
Система полностью готова к развертыванию в продакшн! См. подробные инструкции:
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - пошаговое руководство по развертыванию
### 2. Начальная настройка
1. Войдите как администратор
2. Создайте категории позиций через "Администрирование" → "Категории позиций"
@ -157,27 +161,32 @@ warehouse-system/
- **Настройка иконок**: выбор иконок Font Awesome для категорий
- **Цветовая схема**: настройка цветов категорий
- **Активность категорий**: включение/отключение категорий
- **Управление пользователями**: полный CRUD для пользователей системы
- **Назначение ролей**: гибкая система ролей и разрешений
- **Статус пользователей**: активация/деактивация учетных записей
- **Отчеты**: фильтры по периоду, категории, сотруднику; экспорт CSV/JSON
## 🔧 Администрирование
## 📊 Отчеты
### Управление пользователями
1. Войдите как администратор
2. Перейдите в раздел "Администрирование"
3. Выберите "Управление пользователями"
4. Создавайте, редактируйте, удаляйте пользователей
### Доступ
Откройте "Администрирование" → "Отчеты" (`admin/reports.html`). Доступен пользователям с правами `admin` или `manager` (чтение).
### Настройка категорий
1. Откройте "Администрирование" → "Категории позиций"
2. Добавьте нужные категории с иконками и цветами
3. Настройте описания и активность
4. Категории автоматически появятся в hover-меню
5. Изменения применяются мгновенно на всех страницах
### Фильтры
- Период: дата от/до
- Тип операции: приход/расход/все
- Категория: выбор категории
- Товар: название/штрихкод
- Сотрудник: логин или ФИО
### Генерация отчетов
1. Выберите тип отчета
2. Укажите период
3. Настройте параметры
4. Экспортируйте в нужном формате
### Разделы
- Сводка: суммарный приход, расход, количество операций, уникальные товары
- Операции: таблица операций с деталями
- Остатки по категориям: количество позиций и суммарный остаток
- ТОП товаров по движению: лидеры по приходу/расходу
### Экспорт
- CSV: экспорт таблицы операций
- JSON: экспорт всей структуры отчета (сводка, таблицы)
## 📊 Структура данных
@ -304,11 +313,20 @@ warehouse-system/
- **Валидация данных**: проверка всех входных данных
- **Информативные подсказки**: кнопки показывают требуемые права
### Production-готовность
-**Удалены отладочные логи** из фронтенда
-**Созданы production конфигурации** (.env файлы)
-**Настроена безопасность** (JWT, CORS, rate limiting)
-**Готовы инструкции** по развертыванию
### Рекомендации по безопасности
1. **Измените пароли по умолчанию** при первом входе
2. **Регулярно обновляйте** права доступа
3. **Ведите логи** всех операций
4. **Делайте резервные копии** данных
2. **Настройте JWT_SECRET** в production окружении
3. **Включите HTTPS** с SSL сертификатом
4. **Настройте файрвол** и rate limiting
5. **Регулярно обновляйте** права доступа
6. **Ведите логи** всех операций
7. **Делайте резервные копии** данных
## 📈 Производительность
@ -318,6 +336,13 @@ warehouse-system/
- Минимизируйте количество одновременных операций
- Регулярно очищайте старые данные
### Production-оптимизация
-**Удалены отладочные логи** - улучшена производительность
-**Оптимизирован код** - убраны лишние console.log
-**Готовы production скрипты** - npm run start:prod
-**Настроено кэширование** - статические файлы
-**Оптимизированы запросы** - эффективная фильтрация
### Технические улучшения
- **Безопасная обработка данных**: проверка на null/undefined
- **Валидация форм**: клиентская проверка ввода
@ -329,18 +354,6 @@ warehouse-system/
- **Кэширование данных**: оптимизация производительности
- **Hover-функциональность**: плавные анимации и переходы
## 🔄 Последние обновления
### Версия 1.3.0 (Август 2025)
-**Система управления правами доступа**: автоматическое скрытие элементов согласно ролям
-**Hover-навигация**: выпадающее меню категорий при наведении мыши
-**Удаление данных по умолчанию**: чистая система для самостоятельного заполнения
-**Улучшенная безопасность**: проверка прав доступа для всех операций
-**Централизованное управление правами**: единая система в auth.js
-**Информативные подсказки**: кнопки показывают требуемые права
-**Плавные анимации**: hover-эффекты и переходы
-**Умное поведение меню**: предотвращение закрытия при наведении
## 🐛 Устранение неполадок
### Частые проблемы
@ -386,6 +399,9 @@ warehouse-system/
## 🤝 Поддержка
### Документация
- 📋 **[DEPLOYMENT.md](DEPLOYMENT.md)** - подробные инструкции по развертыванию
### Сообщение об ошибках
При обнаружении ошибок:
1. Опишите проблему подробно
@ -401,16 +417,11 @@ warehouse-system/
## 👥 Авторы
- Разработка: Roman Pylaev
- Дизайн: Roman Pylaev
- Тестирование: Roman Pylaev
- **Разработка**: Roman Pylaev
- **Дизайн**: Roman Pylaev
- **Тестирование**: Roman Pylaev
- **Production-готовность**: Roman Pylaev
## 📞 Контакты
- Telegram/Discord: @dark7es
---
**Версия**: 1.3.0
**Дата**: Август 2025
**Последнее обновление**: 15.08.2025
- **Telegram/Discord**: @dark7es

420
admin/email-test.html Normal file
View 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">&copy; 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
View 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
View 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>

View File

@ -39,17 +39,17 @@ class AuthSystem {
try {
this.currentUser = JSON.parse(userData);
this.isAuthenticated = true;
console.log('Пользователь авторизован:', this.currentUser);
// Пользователь авторизован
this.updateUI();
} catch (error) {
console.error('Ошибка при загрузке данных пользователя:', error);
// Ошибка при загрузке данных пользователя
this.logout();
}
} else {
console.log('Пользователь не авторизован');
// Пользователь не авторизован
// Если нет сессии и мы не на странице входа, перенаправляем
if (!window.location.pathname.includes('login.html')) {
console.log('Перенаправление на страницу входа');
// Перенаправление на страницу входа
window.location.href = 'login.html';
}
}
@ -118,11 +118,11 @@ class AuthSystem {
// Обновление интерфейса в зависимости от прав доступа
updateUI() {
if (!this.currentUser) {
console.log('Нет текущего пользователя для обновления UI');
// Нет текущего пользователя для обновления UI
return;
}
console.log('Обновление UI для пользователя:', this.currentUser.name);
// Обновление UI для пользователя
// Обновляем информацию о пользователе в навигации
const profileToggle = $('#profileDropdown .dropdown-toggle');
@ -136,10 +136,10 @@ class AuthSystem {
// Показываем/скрываем элементы администрирования
if (this.hasRole('admin')) {
$('#adminDropdown').show();
console.log('Показана панель администратора');
// Показана панель администратора
} else {
$('#adminDropdown').hide();
console.log('Скрыта панель администратора');
// Скрыта панель администратора
}
// Обновляем права доступа для кнопок

View File

@ -112,6 +112,9 @@ class CatalogManager {
console.error('Ошибка при загрузке каталога:', error);
auth.showNotification('Ошибка при загрузке каталога', 'danger');
this.hideLoading();
} finally {
// Проверяем уведомления о низком остатке
await this.warehouse.checkAndSendNotifications();
}
}

View File

@ -12,12 +12,12 @@ class MainApp {
try {
// Проверяем авторизацию
if (!auth.isUserAuthenticated()) {
console.log('Пользователь не авторизован, перенаправление на login.html');
// Пользователь не авторизован, перенаправление на login.html
window.location.href = 'login.html';
return;
}
console.log('Инициализация главной страницы для пользователя:', auth.getCurrentUser()?.name);
// Инициализация главной страницы
// Загружаем данные дашборда
await this.loadDashboardData();
@ -43,7 +43,7 @@ class MainApp {
this.initializeModals();
} catch (error) {
console.error('Ошибка инициализации:', error);
// Ошибка инициализации
auth.showNotification('Ошибка загрузки данных', 'danger');
}
});
@ -93,6 +93,9 @@ class MainApp {
// Скрываем индикатор загрузки
this.hideLoading();
// Проверяем уведомления о низком остатке
await this.warehouse.checkAndSendNotifications();
} catch (error) {
console.error('Ошибка при загрузке данных:', error);
auth.showNotification('Ошибка при загрузке данных', 'danger');

File diff suppressed because it is too large Load Diff

194
js/reports.js Normal file
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}
})();

791
js/users.js Normal file
View 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();

File diff suppressed because it is too large Load Diff

View File

@ -1,430 +1,416 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Профиль - Склад-Менеджер</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<!-- Главная навигационная панель -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container">
<a class="navbar-brand" href="index.html">
<i class="fas fa-warehouse me-2 text-primary"></i>
<span class="fw-bold">Склад-Менеджер</span>
</a>
<!-- Поиск в шапке -->
<div class="navbar-search me-auto ms-4">
<div class="input-group" style="width: 400px;">
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
<button class="btn btn-primary" type="button">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="index.html">
<i class="fas fa-home me-1"></i>Главная
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-boxes me-1"></i>Каталог
</a>
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
<li><hr class="dropdown-divider"></li>
<!-- Категории будут загружены динамически -->
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="history.html">
<i class="fas fa-history me-1"></i>История
</a>
</li>
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-cog me-1"></i>Администрирование
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
</ul>
</li>
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user me-1"></i>Профиль
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item active" href="profile.html">Мой профиль</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" id="logoutBtn">Выйти</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Панель категорий -->
<div class="categories-bar bg-light border-bottom">
<div class="container">
<div class="row">
<div class="col-12">
<ul class="nav nav-pills categories-nav" id="categoriesNav">
<li class="nav-item">
<a class="nav-link" href="index.html">
<i class="fas fa-home me-1"></i>Главная
</a>
</li>
<!-- Категории будут загружены динамически -->
</ul>
</div>
</div>
</div>
</div>
<!-- Заголовок профиля -->
<div class="profile-header">
<div class="container">
<div class="row align-items-center">
<div class="col-md-3 text-center">
<div class="profile-avatar">
<i class="fas fa-user"></i>
</div>
</div>
<div class="col-md-9">
<h1 id="userName">Загрузка...</h1>
<p class="mb-2" id="userPosition">Загрузка...</p>
<p class="mb-0" id="userDepartment">Загрузка...</p>
</div>
</div>
</div>
</div>
<!-- Основной контент -->
<main class="container mt-4">
<div class="row">
<!-- Боковая панель -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-user-circle me-2"></i>
Информация
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<small class="text-muted">Email</small>
<div id="userEmail">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Роли</small>
<div id="userRoles">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Разрешения</small>
<div id="userPermissions">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Дата регистрации</small>
<div id="userRegistrationDate">Загрузка...</div>
</div>
</div>
</div>
<!-- Быстрые действия -->
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-bolt me-2"></i>
Быстрые действия
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline-primary btn-sm" onclick="profileManager.changePassword()">
<i class="fas fa-key me-1"></i>Сменить пароль
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="profileManager.editProfile()">
<i class="fas fa-edit me-1"></i>Редактировать профиль
</button>
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
<i class="fas fa-chart-line me-1"></i>Моя активность
</button>
</div>
</div>
</div>
</div>
<!-- Основная область -->
<div class="col-md-9">
<!-- Статистика пользователя -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="totalOperations">0</h4>
<p class="card-text">Всего операций</p>
</div>
<i class="fas fa-list fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="thisMonthOperations">0</h4>
<p class="card-text">В этом месяце</p>
</div>
<i class="fas fa-calendar fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="todayOperations">0</h4>
<p class="card-text">Сегодня</p>
</div>
<i class="fas fa-clock fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="lastLogin">-</h4>
<p class="card-text">Последний вход</p>
</div>
<i class="fas fa-sign-in-alt fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Последние операции пользователя -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Мои последние операции
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="userOperationsTable">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Товар</th>
<th>Количество</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<!-- Данные будут загружены через JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Настройки -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cog me-2"></i>
Настройки
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Уведомления</h6>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="emailNotifications" checked>
<label class="form-check-label" for="emailNotifications">
Email уведомления
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="lowStockAlerts" checked>
<label class="form-check-label" for="lowStockAlerts">
Уведомления о низком запасе
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="operationReports">
<label class="form-check-label" for="operationReports">
Еженедельные отчеты
</label>
</div>
</div>
<div class="col-md-6">
<h6>Интерфейс</h6>
<div class="mb-3">
<label for="themeSelect" class="form-label">Тема оформления</label>
<select class="form-select" id="themeSelect">
<option value="light">Светлая</option>
<option value="dark">Темная</option>
<option value="auto">Автоматически</option>
</select>
</div>
<div class="mb-3">
<label for="languageSelect" class="form-label">Язык</label>
<select class="form-select" id="languageSelect">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
<i class="fas fa-save me-1"></i>Сохранить настройки
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Модальное окно смены пароля -->
<div class="modal fade" id="changePasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-key me-2"></i>
Смена пароля
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="changePasswordForm">
<div class="mb-3">
<label for="currentPassword" class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Подтвердите новый пароль</label>
<input type="password" class="form-control" id="confirmPassword" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="profileManager.savePassword()">
<i class="fas fa-save me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно редактирования профиля -->
<div class="modal fade" id="editProfileModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-edit me-2"></i>
Редактирование профиля
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editProfileForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="editName" class="form-label">Имя</label>
<input type="text" class="form-control" id="editName" required>
</div>
<div class="col-md-6 mb-3">
<label for="editEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editDepartment" class="form-label">Отдел</label>
<input type="text" class="form-control" id="editDepartment">
</div>
<div class="col-md-6 mb-3">
<label for="editPosition" class="form-label">Должность</label>
<input type="text" class="form-control" id="editPosition">
</div>
</div>
<div class="mb-3">
<label for="editPhone" class="form-label">Телефон</label>
<input type="tel" class="form-control" id="editPhone">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="profileManager.saveProfile()">
<i class="fas fa-save me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<!-- Подвал -->
<footer class="bg-dark text-white mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>Система управления складом</h5>
<p class="mb-0">Эффективное управление товарными запасами</p>
</div>
<div class="col-md-6 text-md-end">
<p class="mb-0">&copy; 2025 Склад-Менеджер. Все права защищены.</p>
</div>
</div>
</div>
</footer>
<!-- Скрипты -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="js/auth.js"></script>
<script src="js/warehouse.js"></script>
<script src="js/profile.js"></script>
</body>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Профиль - Склад-Менеджер</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<!-- Главная навигационная панель -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container">
<a class="navbar-brand" href="index.html">
<i class="fas fa-warehouse me-2 text-primary"></i>
<span class="fw-bold">Склад-Менеджер</span>
</a>
<!-- Поиск в шапке -->
<div class="navbar-search me-auto ms-4">
<div class="input-group" style="width: 400px;">
<input type="text" class="form-control" placeholder="Поиск позиций..." id="headerSearch">
<button class="btn btn-primary" type="button">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="index.html">
<i class="fas fa-home me-1"></i>Главная
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-boxes me-1"></i>Каталог
</a>
<ul class="dropdown-menu" id="catalogCategoriesDropdown">
<li><a class="dropdown-item" href="catalog.html">Все позиции</a></li>
<li><hr class="dropdown-divider"></li>
<!-- Категории будут загружены динамически -->
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="history.html">
<i class="fas fa-history me-1"></i>История
</a>
</li>
<li class="nav-item dropdown" id="adminDropdown" style="display: none;">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-cog me-1"></i>Администрирование
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="admin/users.html">Управление пользователями</a></li>
<li><a class="dropdown-item" href="admin/categories.html">Категории позиций</a></li>
<li><a class="dropdown-item" href="admin/reports.html">Отчеты</a></li>
</ul>
</li>
<li class="nav-item dropdown" id="profileDropdown" style="display: none;">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user me-1"></i>Профиль
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item active" href="profile.html">Мой профиль</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" id="logoutBtn">Выйти</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<!-- Панель категорий -->
<div class="categories-bar bg-light border-bottom">
<div class="container">
<div class="row">
<div class="col-12">
<ul class="nav nav-pills categories-nav" id="categoriesNav">
<li class="nav-item">
<a class="nav-link" href="index.html">
<i class="fas fa-home me-1"></i>Главная
</a>
</li>
<!-- Категории будут загружены динамически -->
</ul>
</div>
</div>
</div>
</div>
<!-- Заголовок профиля -->
<div class="profile-header">
<div class="container">
<div class="row align-items-center">
<div class="col-md-3 text-center">
<div class="profile-avatar">
<i class="fas fa-user"></i>
</div>
</div>
<div class="col-md-9">
<h1 id="userName">Загрузка...</h1>
<p class="mb-2" id="userPosition">Загрузка...</p>
<p class="mb-0" id="userDepartment">Загрузка...</p>
</div>
</div>
</div>
</div>
<!-- Основной контент -->
<main class="container mt-4">
<div class="row">
<!-- Боковая панель -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-user-circle me-2"></i>
Информация
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<small class="text-muted">Email</small>
<div id="userEmail">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Роли</small>
<div id="userRoles">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Разрешения</small>
<div id="userPermissions">Загрузка...</div>
</div>
<div class="mb-3">
<small class="text-muted">Дата регистрации</small>
<div id="userRegistrationDate">Загрузка...</div>
</div>
</div>
</div>
<!-- Быстрые действия -->
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-bolt me-2"></i>
Быстрые действия
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline-primary btn-sm" onclick="profileManager.changePassword()">
<i class="fas fa-key me-1"></i>Сменить пароль
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="profileManager.editProfile()">
<i class="fas fa-edit me-1"></i>Редактировать профиль
</button>
<button class="btn btn-outline-info btn-sm" onclick="profileManager.viewActivity()">
<i class="fas fa-chart-line me-1"></i>Моя активность
</button>
<button class="btn btn-outline-success btn-sm" onclick="profileManager.generateWeeklyReport()">
<i class="fas fa-file-alt me-1"></i>Сгенерировать отчет
</button>
</div>
</div>
</div>
</div>
<!-- Основная область -->
<div class="col-md-9">
<!-- Статистика пользователя -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="totalOperations">0</h4>
<p class="card-text">Всего операций</p>
</div>
<i class="fas fa-list fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="thisMonthOperations">0</h4>
<p class="card-text">В этом месяце</p>
</div>
<i class="fas fa-calendar fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="todayOperations">0</h4>
<p class="card-text">Сегодня</p>
</div>
<i class="fas fa-clock fa-2x"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="card-title" id="lastLogin">-</h4>
<p class="card-text">Последний вход</p>
</div>
<i class="fas fa-sign-in-alt fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Последние операции пользователя -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Мои последние операции
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="userOperationsTable">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Товар</th>
<th>Количество</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<!-- Данные будут загружены через JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Настройки -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cog me-2"></i>
Настройки
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Уведомления</h6>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="emailNotifications" checked>
<label class="form-check-label" for="emailNotifications">
Email уведомления
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="lowStockAlerts" checked>
<label class="form-check-label" for="lowStockAlerts">
Уведомления о низком запасе
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="operationReports">
<label class="form-check-label" for="operationReports">
Еженедельные отчеты
</label>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" onclick="profileManager.saveSettings()">
<i class="fas fa-save me-1"></i>Сохранить настройки
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Модальное окно смены пароля -->
<div class="modal fade" id="changePasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-key me-2"></i>
Смена пароля
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="changePasswordForm">
<div class="mb-3">
<label for="currentPassword" class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Подтвердите новый пароль</label>
<input type="password" class="form-control" id="confirmPassword" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="profileManager.savePassword()">
<i class="fas fa-save me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно редактирования профиля -->
<div class="modal fade" id="editProfileModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-edit me-2"></i>
Редактирование профиля
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editProfileForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="editName" class="form-label">Имя</label>
<input type="text" class="form-control" id="editName" required>
</div>
<div class="col-md-6 mb-3">
<label for="editEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editDepartment" class="form-label">Отдел</label>
<input type="text" class="form-control" id="editDepartment">
</div>
<div class="col-md-6 mb-3">
<label for="editPosition" class="form-label">Должность</label>
<input type="text" class="form-control" id="editPosition">
</div>
</div>
<div class="mb-3">
<label for="editPhone" class="form-label">Телефон</label>
<input type="tel" class="form-control" id="editPhone">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="profileManager.saveProfile()">
<i class="fas fa-save me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<!-- Подвал -->
<footer class="bg-dark text-white mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>Система управления складом</h5>
<p class="mb-0">Эффективное управление товарными запасами</p>
</div>
<div class="col-md-6 text-md-end">
<p class="mb-0">&copy; 2025 Склад-Менеджер. Все права защищены.</p>
</div>
</div>
</div>
</footer>
<!-- Скрипты -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="js/auth.js"></script>
<script src="js/warehouse.js"></script>
<script src="js/profile.js"></script>
</body>
</html>

31
server/env.email.example Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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}`);
});

View 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();