From bb88a1183f2672ddd783a5cfcad029d4b3486bcb Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Mon, 26 Jan 2026 21:02:00 +0500 Subject: [PATCH] h --- .gitignore | 1 + auth.js | 644 +++---- database.js | 1684 ++++++++--------- package.json | 48 +- public/index.html | 736 ++++---- public/style.css | 4556 ++++++++++++++++++++++----------------------- server.js | 1720 ++++++++--------- 7 files changed, 4695 insertions(+), 4694 deletions(-) diff --git a/.gitignore b/.gitignore index 59aa427..ef0adde 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ promt package-lock.json data promt +LICENSE \ No newline at end of file diff --git a/auth.js b/auth.js index d23faa2..400c58e 100644 --- a/auth.js +++ b/auth.js @@ -1,323 +1,323 @@ -const bcrypt = require('bcryptjs'); -const fetch = require('node-fetch'); - -class AuthService { - constructor() { - this.db = null; - this.initialized = false; - } - - setDatabase(database) { - this.db = database; - this.initialized = true; - console.log('✅ База данных установлена в AuthService'); - this.initUsers(); - } - - async initUsers() { - if (!this.db) { - console.log('⚠️ База данных не установлена, откладываем создание пользователей'); - return; - } - - try { - // Создаем пользователей из .env - const users = [ - { - login: process.env.USER_1_LOGIN, - password: process.env.USER_1_PASSWORD, - name: process.env.USER_1_NAME, - email: process.env.USER_1_EMAIL, - auth_type: 'local' - }, - { - login: process.env.USER_2_LOGIN, - password: process.env.USER_2_PASSWORD, - name: process.env.USER_2_NAME, - email: process.env.USER_2_EMAIL, - auth_type: 'local' - }, - { - login: process.env.USER_3_LOGIN, - password: process.env.USER_3_PASSWORD, - name: process.env.USER_3_NAME, - email: process.env.USER_3_EMAIL, - auth_type: 'local' - } - ]; - - for (const userData of users) { - if (userData.login && userData.password) { - await this.createUserIfNotExists(userData); - } - } - } catch (error) { - console.error('❌ Ошибка инициализации пользователей:', error.message); - } - } - - async createUserIfNotExists(userData) { - return new Promise((resolve, reject) => { - if (!this.db) { - console.error('❌ База данных не доступна в createUserIfNotExists'); - reject(new Error('База данных не инициализирована')); - return; - } - - this.db.get("SELECT id FROM users WHERE login = ?", [userData.login], async (err, row) => { - if (err) { - reject(err); - return; - } - - if (!row) { - const hashedPassword = await bcrypt.hash(userData.password, 10); - this.db.run( - "INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))", - [ - userData.login, - hashedPassword, - userData.name, - userData.email, - 'teacher', - userData.auth_type || 'local' - ], - function(err) { - if (err) { - reject(err); - } else { - console.log(`✅ Создан пользователь: ${userData.name}`); - resolve(this.lastID); - } - } - ); - } else { - resolve(row.id); - } - }); - }); - } - - async authenticateLocal(login, password) { - if (!this.db) { - throw new Error('База данных не инициализирована в AuthService'); - } - - return new Promise((resolve, reject) => { - this.db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'local'", [login], async (err, user) => { - if (err) { - reject(err); - return; - } - - if (!user) { - resolve(null); - return; - } - - try { - const isValid = await bcrypt.compare(password, user.password); - if (isValid) { - // Обновляем last_login - this.db.run("UPDATE users SET last_login = datetime('now') WHERE id = ?", [user.id]); - - // Не возвращаем пароль - const { password, ...userWithoutPassword } = user; - resolve(userWithoutPassword); - } else { - resolve(null); - } - } catch (error) { - reject(error); - } - }); - }); - } - - async authenticateLDAP(username, password) { - if (!this.db) { - throw new Error('База данных не инициализирована в AuthService'); - } - - try { - // Проверяем наличие URL для LDAP - if (!process.env.LDAP_AUTH_URL) { - console.log('⚠️ LDAP_AUTH_URL не задан в .env'); - return null; - } - - const response = await fetch(process.env.LDAP_AUTH_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ username, password }) - }); - - if (!response.ok) { - console.log(`⚠️ LDAP сервер вернул ошибку: ${response.status}`); - return null; - } - - const data = await response.json(); - - if (data.success) { - return this.processLDAPUser(data); - } else { - return null; - } - } catch (error) { - console.error('❌ Ошибка LDAP аутентификации:', error.message); - return null; - } - } - - async processLDAPUser(ldapData) { - if (!this.db) { - throw new Error('База данных не инициализирована в AuthService'); - } - - const { username, full_name, groups, description } = ldapData; - - // Определяем роль пользователя на основе групп - const allowedGroups = process.env.ALLOWED_GROUPS ? - process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : []; - - // ВАЖНО: Проверяем актуальные группы при каждом входе - const isAdmin = groups && groups.some(group => - allowedGroups.includes(group) - ); - - const role = isAdmin ? 'admin' : 'teacher'; - - // Сохраняем/обновляем пользователя в базе - return new Promise((resolve, reject) => { - this.db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'ldap'", [username], async (err, existingUser) => { - if (err) { - reject(err); - return; - } - - const userData = { - login: username, - name: full_name || username, - email: `${username}@school25.ru`, - role: role, // Всегда обновляем роль из актуальных групп - auth_type: 'ldap', - groups: groups ? JSON.stringify(groups) : '[]', - description: description || '', - last_login: new Date().toISOString() - }; - - if (existingUser) { - // Всегда обновляем роль, даже если пользователь уже существует - this.db.run( - `UPDATE users SET - name = ?, email = ?, role = ?, groups = ?, description = ?, last_login = datetime('now'), - updated_at = datetime('now') - WHERE id = ?`, - [userData.name, userData.email, userData.role, userData.groups, userData.description, existingUser.id], - function(err) { - if (err) { - reject(err); - } else { - console.log(`✅ Обновлены данные LDAP пользователя ${username}. Роль: ${userData.role}, Группы: ${groups}`); - resolve({ - id: existingUser.id, - login: userData.login, - name: userData.name, - email: userData.email, - role: userData.role, - auth_type: userData.auth_type, - groups: userData.groups, - description: userData.description, - last_login: new Date().toISOString() - }); - } - } - ); - } else { - // Создаем нового пользователя - this.db.run( - `INSERT INTO users (login, name, email, role, auth_type, groups, description, created_at, last_login) - VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, - [userData.login, userData.name, userData.email, userData.role, userData.auth_type, - userData.groups, userData.description], - function(err) { - if (err) { - reject(err); - } else { - console.log(`✅ Создан новый LDAP пользователь ${username}. Роль: ${userData.role}, Группы: ${groups}`); - resolve({ - id: this.lastID, - login: userData.login, - name: userData.name, - email: userData.email, - role: userData.role, - auth_type: userData.auth_type, - groups: userData.groups, - description: userData.description, - last_login: new Date().toISOString() - }); - } - } - ); - } - }); - }); - } - - async authenticate(login, password) { - if (!this.db) { - throw new Error('База данных не инициализирована в AuthService'); - } - - // Сначала пробуем локальную авторизацию - try { - const localUser = await this.authenticateLocal(login, password); - if (localUser) { - return localUser; - } - } catch (error) { - console.error('Ошибка локальной аутентификации:', error.message); - } - - // Если локальная не сработала, пробуем LDAP - try { - const ldapUser = await this.authenticateLDAP(login, password); - if (ldapUser) { - return ldapUser; - } - } catch (error) { - console.error('Ошибка LDAP аутентификации:', error.message); - } - - return null; - } - - getUserById(id) { - if (!this.db) { - throw new Error('База данных не инициализирована в AuthService'); - } - - return new Promise((resolve, reject) => { - this.db.get("SELECT id, login, name, email, role, auth_type, groups, description, last_login FROM users WHERE id = ?", [id], (err, user) => { - if (err) { - reject(err); - } else { - resolve(user); - } - }); - }); - } - - // Метод для проверки готовности сервиса - isReady() { - return this.db !== null; - } -} - -// Создаем singleton экземпляр -const authService = new AuthService(); - +const bcrypt = require('bcryptjs'); +const fetch = require('node-fetch'); + +class AuthService { + constructor() { + this.db = null; + this.initialized = false; + } + + setDatabase(database) { + this.db = database; + this.initialized = true; + console.log('✅ База данных установлена в AuthService'); + this.initUsers(); + } + + async initUsers() { + if (!this.db) { + console.log('⚠️ База данных не установлена, откладываем создание пользователей'); + return; + } + + try { + // Создаем пользователей из .env + const users = [ + { + login: process.env.USER_1_LOGIN, + password: process.env.USER_1_PASSWORD, + name: process.env.USER_1_NAME, + email: process.env.USER_1_EMAIL, + auth_type: 'local' + }, + { + login: process.env.USER_2_LOGIN, + password: process.env.USER_2_PASSWORD, + name: process.env.USER_2_NAME, + email: process.env.USER_2_EMAIL, + auth_type: 'local' + }, + { + login: process.env.USER_3_LOGIN, + password: process.env.USER_3_PASSWORD, + name: process.env.USER_3_NAME, + email: process.env.USER_3_EMAIL, + auth_type: 'local' + } + ]; + + for (const userData of users) { + if (userData.login && userData.password) { + await this.createUserIfNotExists(userData); + } + } + } catch (error) { + console.error('❌ Ошибка инициализации пользователей:', error.message); + } + } + + async createUserIfNotExists(userData) { + return new Promise((resolve, reject) => { + if (!this.db) { + console.error('❌ База данных не доступна в createUserIfNotExists'); + reject(new Error('База данных не инициализирована')); + return; + } + + this.db.get("SELECT id FROM users WHERE login = ?", [userData.login], async (err, row) => { + if (err) { + reject(err); + return; + } + + if (!row) { + const hashedPassword = await bcrypt.hash(userData.password, 10); + this.db.run( + "INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))", + [ + userData.login, + hashedPassword, + userData.name, + userData.email, + 'teacher', + userData.auth_type || 'local' + ], + function(err) { + if (err) { + reject(err); + } else { + console.log(`✅ Создан пользователь: ${userData.name}`); + resolve(this.lastID); + } + } + ); + } else { + resolve(row.id); + } + }); + }); + } + + async authenticateLocal(login, password) { + if (!this.db) { + throw new Error('База данных не инициализирована в AuthService'); + } + + return new Promise((resolve, reject) => { + this.db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'local'", [login], async (err, user) => { + if (err) { + reject(err); + return; + } + + if (!user) { + resolve(null); + return; + } + + try { + const isValid = await bcrypt.compare(password, user.password); + if (isValid) { + // Обновляем last_login + this.db.run("UPDATE users SET last_login = datetime('now') WHERE id = ?", [user.id]); + + // Не возвращаем пароль + const { password, ...userWithoutPassword } = user; + resolve(userWithoutPassword); + } else { + resolve(null); + } + } catch (error) { + reject(error); + } + }); + }); + } + + async authenticateLDAP(username, password) { + if (!this.db) { + throw new Error('База данных не инициализирована в AuthService'); + } + + try { + // Проверяем наличие URL для LDAP + if (!process.env.LDAP_AUTH_URL) { + console.log('⚠️ LDAP_AUTH_URL не задан в .env'); + return null; + } + + const response = await fetch(process.env.LDAP_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + console.log(`⚠️ LDAP сервер вернул ошибку: ${response.status}`); + return null; + } + + const data = await response.json(); + + if (data.success) { + return this.processLDAPUser(data); + } else { + return null; + } + } catch (error) { + console.error('❌ Ошибка LDAP аутентификации:', error.message); + return null; + } + } + + async processLDAPUser(ldapData) { + if (!this.db) { + throw new Error('База данных не инициализирована в AuthService'); + } + + const { username, full_name, groups, description } = ldapData; + + // Определяем роль пользователя на основе групп + const allowedGroups = process.env.ALLOWED_GROUPS ? + process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : []; + + // ВАЖНО: Проверяем актуальные группы при каждом входе + const isAdmin = groups && groups.some(group => + allowedGroups.includes(group) + ); + + const role = isAdmin ? 'admin' : 'teacher'; + + // Сохраняем/обновляем пользователя в базе + return new Promise((resolve, reject) => { + this.db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'ldap'", [username], async (err, existingUser) => { + if (err) { + reject(err); + return; + } + + const userData = { + login: username, + name: full_name || username, + email: `${username}@school25.ru`, + role: role, // Всегда обновляем роль из актуальных групп + auth_type: 'ldap', + groups: groups ? JSON.stringify(groups) : '[]', + description: description || '', + last_login: new Date().toISOString() + }; + + if (existingUser) { + // Всегда обновляем роль, даже если пользователь уже существует + this.db.run( + `UPDATE users SET + name = ?, email = ?, role = ?, groups = ?, description = ?, last_login = datetime('now'), + updated_at = datetime('now') + WHERE id = ?`, + [userData.name, userData.email, userData.role, userData.groups, userData.description, existingUser.id], + function(err) { + if (err) { + reject(err); + } else { + console.log(`✅ Обновлены данные LDAP пользователя ${username}. Роль: ${userData.role}, Группы: ${groups}`); + resolve({ + id: existingUser.id, + login: userData.login, + name: userData.name, + email: userData.email, + role: userData.role, + auth_type: userData.auth_type, + groups: userData.groups, + description: userData.description, + last_login: new Date().toISOString() + }); + } + } + ); + } else { + // Создаем нового пользователя + this.db.run( + `INSERT INTO users (login, name, email, role, auth_type, groups, description, created_at, last_login) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + [userData.login, userData.name, userData.email, userData.role, userData.auth_type, + userData.groups, userData.description], + function(err) { + if (err) { + reject(err); + } else { + console.log(`✅ Создан новый LDAP пользователь ${username}. Роль: ${userData.role}, Группы: ${groups}`); + resolve({ + id: this.lastID, + login: userData.login, + name: userData.name, + email: userData.email, + role: userData.role, + auth_type: userData.auth_type, + groups: userData.groups, + description: userData.description, + last_login: new Date().toISOString() + }); + } + } + ); + } + }); + }); + } + + async authenticate(login, password) { + if (!this.db) { + throw new Error('База данных не инициализирована в AuthService'); + } + + // Сначала пробуем локальную авторизацию + try { + const localUser = await this.authenticateLocal(login, password); + if (localUser) { + return localUser; + } + } catch (error) { + console.error('Ошибка локальной аутентификации:', error.message); + } + + // Если локальная не сработала, пробуем LDAP + try { + const ldapUser = await this.authenticateLDAP(login, password); + if (ldapUser) { + return ldapUser; + } + } catch (error) { + console.error('Ошибка LDAP аутентификации:', error.message); + } + + return null; + } + + getUserById(id) { + if (!this.db) { + throw new Error('База данных не инициализирована в AuthService'); + } + + return new Promise((resolve, reject) => { + this.db.get("SELECT id, login, name, email, role, auth_type, groups, description, last_login FROM users WHERE id = ?", [id], (err, user) => { + if (err) { + reject(err); + } else { + resolve(user); + } + }); + }); + } + + // Метод для проверки готовности сервиса + isReady() { + return this.db !== null; + } +} + +// Создаем singleton экземпляр +const authService = new AuthService(); + module.exports = authService; \ No newline at end of file diff --git a/database.js b/database.js index fc47a90..6a1107c 100644 --- a/database.js +++ b/database.js @@ -1,843 +1,843 @@ -const sqlite3 = require('sqlite3').verbose(); -const { Pool } = require('pg'); -const path = require('path'); -const fs = require('fs'); -require('dotenv').config(); - -// Определяем, какую базу использовать -const USE_POSTGRES = process.env.POSTGRESQL === 'yes'; -let db = null; // Основной объект базы данных -let postgresPool = null; // Пул соединений PostgreSQL -let isInitialized = false; // Флаг инициализации - -const dataDir = path.join(__dirname, 'data'); -const createDirIfNotExists = (dirPath) => { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } -}; - -createDirIfNotExists(dataDir); - -const dbPath = path.join(dataDir, 'school_crm.db'); -const uploadsDir = path.join(dataDir, 'uploads'); -const tasksDir = path.join(uploadsDir, 'tasks'); -const logsDir = path.join(dataDir, 'logs'); - -createDirIfNotExists(uploadsDir); -createDirIfNotExists(tasksDir); -createDirIfNotExists(logsDir); - -// Инициализация базы данных -async function initializeDatabase() { - console.log(`🔧 Используется ${USE_POSTGRES ? 'PostgreSQL' : 'SQLite'}`); - - if (USE_POSTGRES) { - // Используем PostgreSQL - try { - postgresPool = new Pool({ - host: process.env.DB_HOST, - port: process.env.DB_PORT || 5432, - database: process.env.DB_NAME || 'minicrm', - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - max: 10, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, - }); - - // Тестируем подключение - const client = await postgresPool.connect(); - await client.query('SELECT 1'); - client.release(); - - console.log('✅ Подключение к PostgreSQL установлено'); - - // Создаем адаптер для PostgreSQL - db = createPostgresAdapter(postgresPool); - - // Проверяем и создаем таблицы - await createPostgresTables(); - - isInitialized = true; - - } catch (error) { - console.error('❌ Ошибка подключения к PostgreSQL:', error.message); - console.log('🔄 Пытаемся использовать SQLite как запасной вариант...'); - await initializeSQLite(); - } - } else { - // Используем SQLite - await initializeSQLite(); - } - - return db; -} - -function initializeSQLite() { - return new Promise((resolve, reject) => { - db = new sqlite3.Database(dbPath, (err) => { - if (err) { - console.error('❌ Ошибка подключения к SQLite:', err.message); - reject(err); - return; - } else { - console.log('✅ Подключение к SQLite установлено'); - console.log('📁 База данных расположена:', dbPath); - createSQLiteTables(); - isInitialized = true; - resolve(db); - } - }); - }); -} - -function createSQLiteTables() { - // SQLite таблицы - db.run(`CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - login TEXT UNIQUE NOT NULL, - password TEXT, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - role TEXT DEFAULT 'teacher', - auth_type TEXT DEFAULT 'local', - groups TEXT, - description TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_login DATETIME, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - - db.run(`CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT, - status TEXT DEFAULT 'active', - created_by INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - deleted_at DATETIME, - deleted_by INTEGER, - original_task_id INTEGER, - start_date DATETIME, - due_date DATETIME, - rework_comment TEXT, - closed_at DATETIME, - closed_by INTEGER, - FOREIGN KEY (created_by) REFERENCES users (id), - FOREIGN KEY (deleted_by) REFERENCES users (id), - FOREIGN KEY (original_task_id) REFERENCES tasks (id), - FOREIGN KEY (closed_by) REFERENCES users (id) - )`); - - db.run(`CREATE TABLE IF NOT EXISTS task_assignments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - status TEXT DEFAULT 'assigned', - start_date DATETIME, - due_date DATETIME, - rework_comment TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES tasks (id), - FOREIGN KEY (user_id) REFERENCES users (id) - )`); - - db.run(`CREATE TABLE IF NOT EXISTS task_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - file_path TEXT NOT NULL, - file_size INTEGER NOT NULL, - uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES tasks (id), - FOREIGN KEY (user_id) REFERENCES users (id) - )`); - - db.run(`CREATE TABLE IF NOT EXISTS activity_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - action TEXT NOT NULL, - details TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES tasks (id), - FOREIGN KEY (user_id) REFERENCES users (id) - )`); - - db.run(`CREATE TABLE IF NOT EXISTS notification_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - notification_key TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - - console.log('✅ База данных SQLite инициализирована'); - - // Добавляем таблицу для пользовательских настроек - db.run(`CREATE TABLE IF NOT EXISTS user_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER UNIQUE NOT NULL, - email_notifications BOOLEAN DEFAULT true, - notification_email TEXT, - telegram_notifications BOOLEAN DEFAULT false, - telegram_chat_id TEXT, - vk_notifications BOOLEAN DEFAULT false, - vk_user_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) - )`); - - console.log('✅ Таблица для пользовательских настроек инициализирована'); - - // Запускаем проверку и обновление структуры таблиц - setTimeout(() => { - checkAndUpdateTableStructure(); - }, 2000); -} - -// Функция для проверки и обновления структуры таблиц -function checkAndUpdateTableStructure() { - console.log('🔍 Проверка структуры таблиц...'); - - // Определяем ожидаемую структуру таблиц - const tableSchemas = { - users: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'login', type: 'TEXT UNIQUE NOT NULL' }, - { name: 'password', type: 'TEXT' }, - { name: 'name', type: 'TEXT NOT NULL' }, - { name: 'email', type: 'TEXT UNIQUE NOT NULL' }, - { name: 'role', type: 'TEXT DEFAULT "teacher"' }, - { name: 'auth_type', type: 'TEXT DEFAULT "local"' }, - { name: 'groups', type: 'TEXT' }, - { name: 'description', type: 'TEXT' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, - { name: 'last_login', type: 'DATETIME' }, - { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ], - tasks: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'title', type: 'TEXT NOT NULL' }, - { name: 'description', type: 'TEXT' }, - { name: 'status', type: 'TEXT DEFAULT "active"' }, - { name: 'created_by', type: 'INTEGER NOT NULL' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, - { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, - { name: 'deleted_at', type: 'DATETIME' }, - { name: 'deleted_by', type: 'INTEGER' }, - { name: 'original_task_id', type: 'INTEGER' }, - { name: 'start_date', type: 'DATETIME' }, - { name: 'due_date', type: 'DATETIME' }, - { name: 'rework_comment', type: 'TEXT' }, - { name: 'closed_at', type: 'DATETIME' }, - { name: 'closed_by', type: 'INTEGER' } - ], - task_assignments: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'task_id', type: 'INTEGER NOT NULL' }, - { name: 'user_id', type: 'INTEGER NOT NULL' }, - { name: 'status', type: 'TEXT DEFAULT "assigned"' }, - { name: 'start_date', type: 'DATETIME' }, - { name: 'due_date', type: 'DATETIME' }, - { name: 'rework_comment', type: 'TEXT' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, - { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ], - task_files: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'task_id', type: 'INTEGER NOT NULL' }, - { name: 'user_id', type: 'INTEGER NOT NULL' }, - { name: 'filename', type: 'TEXT NOT NULL' }, - { name: 'original_name', type: 'TEXT NOT NULL' }, - { name: 'file_path', type: 'TEXT NOT NULL' }, - { name: 'file_size', type: 'INTEGER NOT NULL' }, - { name: 'uploaded_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ], - activity_logs: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'task_id', type: 'INTEGER NOT NULL' }, - { name: 'user_id', type: 'INTEGER NOT NULL' }, - { name: 'action', type: 'TEXT NOT NULL' }, - { name: 'details', type: 'TEXT' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ], - notification_logs: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'notification_key', type: 'TEXT NOT NULL' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ], - user_settings: [ - { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, - { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL' }, - { name: 'email_notifications', type: 'BOOLEAN DEFAULT true' }, - { name: 'notification_email', type: 'TEXT' }, - { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'telegram_chat_id', type: 'TEXT' }, - { name: 'vk_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'vk_user_id', type: 'TEXT' }, - { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, - { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } - ] - }; - - // Проверяем каждую таблицу - Object.entries(tableSchemas).forEach(([tableName, columns]) => { - db.all(`PRAGMA table_info(${tableName})`, (err, existingColumns) => { - if (err) { - console.error(`❌ Ошибка проверки таблицы ${tableName}:`, err.message); - return; - } - - if (existingColumns.length === 0) { - console.log(`⚠️ Таблица ${tableName} не существует, создаем...`); - // Таблица будет создана автоматически при следующем запуске - return; - } - - // Создаем массив имен существующих колонок - const existingColumnNames = existingColumns.map(col => col.name.toLowerCase()); - - // Проверяем каждую ожидаемую колонку - columns.forEach(expectedColumn => { - const expectedName = expectedColumn.name.toLowerCase(); - - if (!existingColumnNames.includes(expectedName)) { - console.log(`🔧 Добавляем колонку ${expectedColumn.name} в таблицу ${tableName}...`); - - db.run( - `ALTER TABLE ${tableName} ADD COLUMN ${expectedColumn.name} ${expectedColumn.type}`, - (alterErr) => { - if (alterErr) { - console.error(`❌ Ошибка добавления колонки ${expectedColumn.name}:`, alterErr.message); - } else { - console.log(`✅ Колонка ${expectedColumn.name} добавлена в таблицу ${tableName}`); - } - } - ); - } - }); - }); - }); - - // Проверяем индекс для таблицы user_settings - setTimeout(() => { - db.get("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_user_settings_user_id'", (err, index) => { - if (err) { - console.error('❌ Ошибка проверки индекса:', err.message); - return; - } - - if (!index) { - console.log('🔧 Создаем индекс для таблицы user_settings...'); - db.run("CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)", (createErr) => { - if (createErr) { - console.error('❌ Ошибка создания индекса:', createErr.message); - } else { - console.log('✅ Индекс для user_settings создан'); - } - }); - } - }); - }, 1000); -} - -function createPostgresAdapter(pool) { - // Адаптер для PostgreSQL с совместимым API - return { - all: (sql, params = [], callback) => { - if (!callback && typeof params === 'function') { - callback = params; - params = []; - } - - // Адаптируем SQL для PostgreSQL - const adaptedSql = adaptSQLForPostgres(sql); - - pool.query(adaptedSql, params) - .then(result => callback(null, result.rows)) - .catch(err => { - console.error('PostgreSQL Error (all):', err.message, 'SQL:', adaptedSql); - callback(err); - }); - }, - - get: (sql, params = [], callback) => { - if (!callback && typeof params === 'function') { - callback = params; - params = []; - } - - // Адаптируем SQL для PostgreSQL - const adaptedSql = adaptSQLForPostgres(sql); - - pool.query(adaptedSql, params) - .then(result => callback(null, result.rows[0] || null)) - .catch(err => { - console.error('PostgreSQL Error (get):', err.message, 'SQL:', adaptedSql); - callback(err); - }); - }, - - run: (sql, params = [], callback) => { - if (!callback && typeof params === 'function') { - callback = params; - params = []; - } - - // Адаптируем SQL для PostgreSQL - const adaptedSql = adaptSQLForPostgres(sql); - - pool.query(adaptedSql, params) - .then(result => { - if (callback) { - const lastIdQuery = sql.toLowerCase().includes('insert into') ? - "SELECT lastval() as last_id" : "SELECT 0 as last_id"; - - if (sql.toLowerCase().includes('insert into')) { - pool.query("SELECT lastval() as last_id", []) - .then(lastIdResult => { - callback(null, { - lastID: lastIdResult.rows[0]?.last_id || null, - changes: result.rowCount || 0 - }); - }) - .catch(err => callback(err)); - } else { - callback(null, { - lastID: null, - changes: result.rowCount || 0 - }); - } - } - }) - .catch(err => { - console.error('PostgreSQL Error (run):', err.message, 'SQL:', adaptedSql); - if (callback) callback(err); - }); - }, - - // Для транзакций - эмуляция - serialize: (callback) => { - // В PostgreSQL транзакции обрабатываются по-другому - // Здесь просто выполняем колбэк - try { - callback(); - } catch (error) { - console.error('Error in serialize:', error); - } - }, - - // Закрытие соединения - close: (callback) => { - pool.end() - .then(() => { - if (callback) callback(null); - }) - .catch(err => { - if (callback) callback(err); - }); - }, - - // Дополнительные методы - exec: (sql, callback) => { - pool.query(sql) - .then(() => { - if (callback) callback(null); - }) - .catch(err => { - if (callback) callback(err); - }); - } - }; -} - -function adaptSQLForPostgres(sql) { - // Адаптируем SQL запросы для PostgreSQL - let adaptedSql = sql; - - // Заменяем SQLite-специфичные синтаксисы - adaptedSql = adaptedSql.replace(/AUTOINCREMENT/gi, 'SERIAL'); - adaptedSql = adaptedSql.replace(/DATETIME/gi, 'TIMESTAMP'); - adaptedSql = adaptedSql.replace(/INTEGER PRIMARY KEY/gi, 'SERIAL PRIMARY KEY'); - adaptedSql = adaptedSql.replace(/datetime\('now'\)/gi, 'CURRENT_TIMESTAMP'); - adaptedSql = adaptedSql.replace(/CURRENT_TIMESTAMP/gi, 'CURRENT_TIMESTAMP'); - - // Исправляем INSERT с возвратом ID - if (adaptedSql.includes('INSERT INTO') && adaptedSql.includes('RETURNING id')) { - adaptedSql = adaptedSql.replace('RETURNING id', 'RETURNING id'); - } - - return adaptedSql; -} - -async function createPostgresTables() { - if (!USE_POSTGRES) return; - - try { - const client = await postgresPool.connect(); - - console.log('🔧 Проверяем/создаем таблицы в PostgreSQL...'); - - // Создаем таблицы PostgreSQL - await client.query(` - CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - login VARCHAR(100) UNIQUE NOT NULL, - password TEXT, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - role VARCHAR(50) DEFAULT 'teacher', - auth_type VARCHAR(50) DEFAULT 'local', - groups TEXT DEFAULT '[]', - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_login TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - await client.query(` - CREATE TABLE IF NOT EXISTS tasks ( - id SERIAL PRIMARY KEY, - title VARCHAR(500) NOT NULL, - description TEXT, - status VARCHAR(50) DEFAULT 'active', - created_by INTEGER NOT NULL REFERENCES users(id), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP, - deleted_by INTEGER REFERENCES users(id), - original_task_id INTEGER REFERENCES tasks(id), - start_date TIMESTAMP, - due_date TIMESTAMP, - rework_comment TEXT, - closed_at TIMESTAMP, - closed_by INTEGER REFERENCES users(id) - ) - `); - - await client.query(` - CREATE TABLE IF NOT EXISTS task_assignments ( - id SERIAL PRIMARY KEY, - task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - status VARCHAR(50) DEFAULT 'assigned', - start_date TIMESTAMP, - due_date TIMESTAMP, - rework_comment TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - await client.query(` - CREATE TABLE IF NOT EXISTS task_files ( - id SERIAL PRIMARY KEY, - task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - filename VARCHAR(255) NOT NULL, - original_name VARCHAR(500) NOT NULL, - file_path TEXT NOT NULL, - file_size BIGINT NOT NULL, - uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - await client.query(` - CREATE TABLE IF NOT EXISTS activity_logs ( - id SERIAL PRIMARY KEY, - task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - action VARCHAR(100) NOT NULL, - details TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - await client.query(` - CREATE TABLE IF NOT EXISTS notification_logs ( - id SERIAL PRIMARY KEY, - notification_key VARCHAR(500) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Добавляем таблицу для пользовательских настроек - await client.query(` - CREATE TABLE IF NOT EXISTS user_settings ( - id SERIAL PRIMARY KEY, - user_id INTEGER UNIQUE NOT NULL REFERENCES users(id), - email_notifications BOOLEAN DEFAULT true, - notification_email TEXT, - telegram_notifications BOOLEAN DEFAULT false, - telegram_chat_id TEXT, - vk_notifications BOOLEAN DEFAULT false, - vk_user_id TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Создаем индексы - const indexes = [ - 'CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)', - 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', - 'CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)', - 'CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)', - 'CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)', - 'CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)', - 'CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)' - ]; - - for (const indexQuery of indexes) { - try { - await client.query(indexQuery); - } catch (err) { - console.warn(`⚠️ Не удалось создать индекс: ${err.message}`); - } - } - - client.release(); - console.log('✅ Таблицы PostgreSQL проверены/созданы'); - - // Проверяем структуру PostgreSQL таблиц - await checkPostgresTableStructure(); - - } catch (error) { - console.error('❌ Ошибка создания таблиц PostgreSQL:', error.message); - } -} - -// Функция для проверки структуры таблиц PostgreSQL -async function checkPostgresTableStructure() { - if (!USE_POSTGRES) return; - - try { - const client = await postgresPool.connect(); - - console.log('🔍 Проверка структуры таблиц PostgreSQL...'); - - // Определяем ожидаемую структуру таблиц PostgreSQL - const tableSchemas = { - user_settings: [ - { name: 'id', type: 'SERIAL PRIMARY KEY' }, - { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL REFERENCES users(id)' }, - { name: 'email_notifications', type: 'BOOLEAN DEFAULT true' }, - { name: 'notification_email', type: 'TEXT' }, - { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'telegram_chat_id', type: 'TEXT' }, - { name: 'vk_notifications', type: 'BOOLEAN DEFAULT false' }, - { name: 'vk_user_id', type: 'TEXT' }, - { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, - { name: 'updated_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } - ] - }; - - // Проверяем каждую таблицу - for (const [tableName, columns] of Object.entries(tableSchemas)) { - try { - // Проверяем существование таблицы - const tableExists = await client.query( - "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", - [tableName] - ); - - if (!tableExists.rows[0].exists) { - console.log(`⚠️ Таблица ${tableName} не существует в PostgreSQL`); - continue; - } - - // Получаем существующие колонки - const existingColumns = await client.query(` - SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_name = $1 - ORDER BY ordinal_position - `, [tableName]); - - const existingColumnNames = existingColumns.rows.map(col => col.column_name.toLowerCase()); - - // Проверяем каждую ожидаемую колонку - for (const expectedColumn of columns) { - const expectedName = expectedColumn.name.toLowerCase(); - - if (!existingColumnNames.includes(expectedName)) { - console.log(`🔧 Добавляем колонку ${expectedColumn.name} в таблицу PostgreSQL ${tableName}...`); - - try { - await client.query( - `ALTER TABLE ${tableName} ADD COLUMN ${expectedColumn.name} ${expectedColumn.type}` - ); - console.log(`✅ Колонка ${expectedColumn.name} добавлена в таблицу PostgreSQL ${tableName}`); - } catch (alterErr) { - console.error(`❌ Ошибка добавления колонки ${expectedColumn.name}:`, alterErr.message); - } - } - } - } catch (error) { - console.error(`❌ Ошибка проверки таблицы PostgreSQL ${tableName}:`, error.message); - } - } - - client.release(); - console.log('✅ Проверка структуры таблиц PostgreSQL завершена'); - - } catch (error) { - console.error('❌ Ошибка проверки структуры таблиц PostgreSQL:', error.message); - } -} - -function createTaskFolder(taskId) { - const taskFolder = path.join(tasksDir, taskId.toString()); - createDirIfNotExists(taskFolder); - return taskFolder; -} - -function createUserTaskFolder(taskId, userLogin) { - const taskFolder = path.join(tasksDir, taskId.toString()); - const userFolder = path.join(taskFolder, userLogin); - createDirIfNotExists(userFolder); - return userFolder; -} - -function saveTaskMetadata(taskId, title, description, createdBy, originalTaskId = null, startDate = null, dueDate = null) { - const taskFolder = createTaskFolder(taskId); - const metadata = { - id: taskId, - title: title, - description: description, - status: 'active', - created_by: createdBy, - original_task_id: originalTaskId, - start_date: startDate, - due_date: dueDate, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - files: [] - }; - - const metadataPath = path.join(taskFolder, 'task.json'); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); -} - -function updateTaskMetadata(taskId, updates) { - const metadataPath = path.join(tasksDir, taskId.toString(), 'task.json'); - if (fs.existsSync(metadataPath)) { - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - const updatedMetadata = { ...metadata, ...updates, updated_at: new Date().toISOString() }; - fs.writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2)); - } -} - -function logActivity(taskId, userId, action, details = '') { - db.run( - "INSERT INTO activity_logs (task_id, user_id, action, details) VALUES (?, ?, ?, ?)", - [taskId, userId, action, details] - ); - - const logEntry = `${new Date().toISOString()} - User ${userId}: ${action} - Task ${taskId} - ${details}\n`; - fs.appendFileSync(path.join(logsDir, 'activity.log'), logEntry); -} - -function checkTaskAccess(userId, taskId, callback) { - db.get("SELECT role FROM users WHERE id = ?", [userId], (err, user) => { - if (err) { - callback(err, false); - return; - } - - if (user && user.role === 'admin') { - callback(null, true); - return; - } - - db.get("SELECT status, created_by, closed_at FROM tasks WHERE id = ?", [taskId], (err, task) => { - if (err || !task) { - callback(err, false); - return; - } - - if (task.closed_at && task.created_by !== userId && user.role !== 'admin') { - callback(null, false); - return; - } - - const query = ` - SELECT 1 FROM tasks t - WHERE t.id = ? AND ( - t.created_by = ? - OR EXISTS (SELECT 1 FROM task_assignments WHERE task_id = t.id AND user_id = ?) - ) - `; - - db.get(query, [taskId, userId, userId], (err, row) => { - callback(err, !!row); - }); - }); - }); -} - -function checkOverdueTasks() { - const now = new Date().toISOString(); - - const query = ` - SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date - FROM task_assignments ta - JOIN tasks t ON ta.task_id = t.id - WHERE ta.due_date IS NOT NULL - AND ta.due_date < ? - AND ta.status NOT IN ('completed', 'overdue') - AND t.status = 'active' - AND t.closed_at IS NULL - `; - - db.all(query, [now], (err, assignments) => { - if (err) { - console.error('Ошибка при проверке просроченных задач:', err); - return; - } - - assignments.forEach(assignment => { - db.run( - "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", - [assignment.id] - ); - logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); - }); - }); -} - -setInterval(checkOverdueTasks, 60000); - -module.exports = { - initializeDatabase, // Экспортируем функцию инициализации - getDb: () => { - if (!isInitialized) { - throw new Error('База данных не инициализирована'); - } - return db; - }, - isInitialized: () => isInitialized, - logActivity, - createTaskFolder, - createUserTaskFolder, - saveTaskMetadata, - updateTaskMetadata, - checkTaskAccess, - USE_POSTGRES, - getDatabaseType: () => USE_POSTGRES ? 'PostgreSQL' : 'SQLite', - checkAndUpdateTableStructure // Экспортируем для ручного запуска -}; - -// Запускаем инициализацию при экспорте (но она завершится позже) -initializeDatabase().catch(err => { - console.error('❌ Ошибка инициализации базы данных:', err.message); +const sqlite3 = require('sqlite3').verbose(); +const { Pool } = require('pg'); +const path = require('path'); +const fs = require('fs'); +require('dotenv').config(); + +// Определяем, какую базу использовать +const USE_POSTGRES = process.env.POSTGRESQL === 'yes'; +let db = null; // Основной объект базы данных +let postgresPool = null; // Пул соединений PostgreSQL +let isInitialized = false; // Флаг инициализации + +const dataDir = path.join(__dirname, 'data'); +const createDirIfNotExists = (dirPath) => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +createDirIfNotExists(dataDir); + +const dbPath = path.join(dataDir, 'school_crm.db'); +const uploadsDir = path.join(dataDir, 'uploads'); +const tasksDir = path.join(uploadsDir, 'tasks'); +const logsDir = path.join(dataDir, 'logs'); + +createDirIfNotExists(uploadsDir); +createDirIfNotExists(tasksDir); +createDirIfNotExists(logsDir); + +// Инициализация базы данных +async function initializeDatabase() { + console.log(`🔧 Используется ${USE_POSTGRES ? 'PostgreSQL' : 'SQLite'}`); + + if (USE_POSTGRES) { + // Используем PostgreSQL + try { + postgresPool = new Pool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'minicrm', + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + + // Тестируем подключение + const client = await postgresPool.connect(); + await client.query('SELECT 1'); + client.release(); + + console.log('✅ Подключение к PostgreSQL установлено'); + + // Создаем адаптер для PostgreSQL + db = createPostgresAdapter(postgresPool); + + // Проверяем и создаем таблицы + await createPostgresTables(); + + isInitialized = true; + + } catch (error) { + console.error('❌ Ошибка подключения к PostgreSQL:', error.message); + console.log('🔄 Пытаемся использовать SQLite как запасной вариант...'); + await initializeSQLite(); + } + } else { + // Используем SQLite + await initializeSQLite(); + } + + return db; +} + +function initializeSQLite() { + return new Promise((resolve, reject) => { + db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('❌ Ошибка подключения к SQLite:', err.message); + reject(err); + return; + } else { + console.log('✅ Подключение к SQLite установлено'); + console.log('📁 База данных расположена:', dbPath); + createSQLiteTables(); + isInitialized = true; + resolve(db); + } + }); + }); +} + +function createSQLiteTables() { + // SQLite таблицы + db.run(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + role TEXT DEFAULT 'teacher', + auth_type TEXT DEFAULT 'local', + groups TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + db.run(`CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'active', + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + deleted_by INTEGER, + original_task_id INTEGER, + start_date DATETIME, + due_date DATETIME, + rework_comment TEXT, + closed_at DATETIME, + closed_by INTEGER, + FOREIGN KEY (created_by) REFERENCES users (id), + FOREIGN KEY (deleted_by) REFERENCES users (id), + FOREIGN KEY (original_task_id) REFERENCES tasks (id), + FOREIGN KEY (closed_by) REFERENCES users (id) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS task_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status TEXT DEFAULT 'assigned', + start_date DATETIME, + due_date DATETIME, + rework_comment TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS task_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS activity_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS notification_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + notification_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + console.log('✅ База данных SQLite инициализирована'); + + // Добавляем таблицу для пользовательских настроек + db.run(`CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + email_notifications BOOLEAN DEFAULT true, + notification_email TEXT, + telegram_notifications BOOLEAN DEFAULT false, + telegram_chat_id TEXT, + vk_notifications BOOLEAN DEFAULT false, + vk_user_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + console.log('✅ Таблица для пользовательских настроек инициализирована'); + + // Запускаем проверку и обновление структуры таблиц + setTimeout(() => { + checkAndUpdateTableStructure(); + }, 2000); +} + +// Функция для проверки и обновления структуры таблиц +function checkAndUpdateTableStructure() { + console.log('🔍 Проверка структуры таблиц...'); + + // Определяем ожидаемую структуру таблиц + const tableSchemas = { + users: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'login', type: 'TEXT UNIQUE NOT NULL' }, + { name: 'password', type: 'TEXT' }, + { name: 'name', type: 'TEXT NOT NULL' }, + { name: 'email', type: 'TEXT UNIQUE NOT NULL' }, + { name: 'role', type: 'TEXT DEFAULT "teacher"' }, + { name: 'auth_type', type: 'TEXT DEFAULT "local"' }, + { name: 'groups', type: 'TEXT' }, + { name: 'description', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'last_login', type: 'DATETIME' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + tasks: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'title', type: 'TEXT NOT NULL' }, + { name: 'description', type: 'TEXT' }, + { name: 'status', type: 'TEXT DEFAULT "active"' }, + { name: 'created_by', type: 'INTEGER NOT NULL' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'deleted_at', type: 'DATETIME' }, + { name: 'deleted_by', type: 'INTEGER' }, + { name: 'original_task_id', type: 'INTEGER' }, + { name: 'start_date', type: 'DATETIME' }, + { name: 'due_date', type: 'DATETIME' }, + { name: 'rework_comment', type: 'TEXT' }, + { name: 'closed_at', type: 'DATETIME' }, + { name: 'closed_by', type: 'INTEGER' } + ], + task_assignments: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'status', type: 'TEXT DEFAULT "assigned"' }, + { name: 'start_date', type: 'DATETIME' }, + { name: 'due_date', type: 'DATETIME' }, + { name: 'rework_comment', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + task_files: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'filename', type: 'TEXT NOT NULL' }, + { name: 'original_name', type: 'TEXT NOT NULL' }, + { name: 'file_path', type: 'TEXT NOT NULL' }, + { name: 'file_size', type: 'INTEGER NOT NULL' }, + { name: 'uploaded_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + activity_logs: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'action', type: 'TEXT NOT NULL' }, + { name: 'details', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + notification_logs: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'notification_key', type: 'TEXT NOT NULL' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + user_settings: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL' }, + { name: 'email_notifications', type: 'BOOLEAN DEFAULT true' }, + { name: 'notification_email', type: 'TEXT' }, + { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT false' }, + { name: 'telegram_chat_id', type: 'TEXT' }, + { name: 'vk_notifications', type: 'BOOLEAN DEFAULT false' }, + { name: 'vk_user_id', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ] + }; + + // Проверяем каждую таблицу + Object.entries(tableSchemas).forEach(([tableName, columns]) => { + db.all(`PRAGMA table_info(${tableName})`, (err, existingColumns) => { + if (err) { + console.error(`❌ Ошибка проверки таблицы ${tableName}:`, err.message); + return; + } + + if (existingColumns.length === 0) { + console.log(`⚠️ Таблица ${tableName} не существует, создаем...`); + // Таблица будет создана автоматически при следующем запуске + return; + } + + // Создаем массив имен существующих колонок + const existingColumnNames = existingColumns.map(col => col.name.toLowerCase()); + + // Проверяем каждую ожидаемую колонку + columns.forEach(expectedColumn => { + const expectedName = expectedColumn.name.toLowerCase(); + + if (!existingColumnNames.includes(expectedName)) { + console.log(`🔧 Добавляем колонку ${expectedColumn.name} в таблицу ${tableName}...`); + + db.run( + `ALTER TABLE ${tableName} ADD COLUMN ${expectedColumn.name} ${expectedColumn.type}`, + (alterErr) => { + if (alterErr) { + console.error(`❌ Ошибка добавления колонки ${expectedColumn.name}:`, alterErr.message); + } else { + console.log(`✅ Колонка ${expectedColumn.name} добавлена в таблицу ${tableName}`); + } + } + ); + } + }); + }); + }); + + // Проверяем индекс для таблицы user_settings + setTimeout(() => { + db.get("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_user_settings_user_id'", (err, index) => { + if (err) { + console.error('❌ Ошибка проверки индекса:', err.message); + return; + } + + if (!index) { + console.log('🔧 Создаем индекс для таблицы user_settings...'); + db.run("CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)", (createErr) => { + if (createErr) { + console.error('❌ Ошибка создания индекса:', createErr.message); + } else { + console.log('✅ Индекс для user_settings создан'); + } + }); + } + }); + }, 1000); +} + +function createPostgresAdapter(pool) { + // Адаптер для PostgreSQL с совместимым API + return { + all: (sql, params = [], callback) => { + if (!callback && typeof params === 'function') { + callback = params; + params = []; + } + + // Адаптируем SQL для PostgreSQL + const adaptedSql = adaptSQLForPostgres(sql); + + pool.query(adaptedSql, params) + .then(result => callback(null, result.rows)) + .catch(err => { + console.error('PostgreSQL Error (all):', err.message, 'SQL:', adaptedSql); + callback(err); + }); + }, + + get: (sql, params = [], callback) => { + if (!callback && typeof params === 'function') { + callback = params; + params = []; + } + + // Адаптируем SQL для PostgreSQL + const adaptedSql = adaptSQLForPostgres(sql); + + pool.query(adaptedSql, params) + .then(result => callback(null, result.rows[0] || null)) + .catch(err => { + console.error('PostgreSQL Error (get):', err.message, 'SQL:', adaptedSql); + callback(err); + }); + }, + + run: (sql, params = [], callback) => { + if (!callback && typeof params === 'function') { + callback = params; + params = []; + } + + // Адаптируем SQL для PostgreSQL + const adaptedSql = adaptSQLForPostgres(sql); + + pool.query(adaptedSql, params) + .then(result => { + if (callback) { + const lastIdQuery = sql.toLowerCase().includes('insert into') ? + "SELECT lastval() as last_id" : "SELECT 0 as last_id"; + + if (sql.toLowerCase().includes('insert into')) { + pool.query("SELECT lastval() as last_id", []) + .then(lastIdResult => { + callback(null, { + lastID: lastIdResult.rows[0]?.last_id || null, + changes: result.rowCount || 0 + }); + }) + .catch(err => callback(err)); + } else { + callback(null, { + lastID: null, + changes: result.rowCount || 0 + }); + } + } + }) + .catch(err => { + console.error('PostgreSQL Error (run):', err.message, 'SQL:', adaptedSql); + if (callback) callback(err); + }); + }, + + // Для транзакций - эмуляция + serialize: (callback) => { + // В PostgreSQL транзакции обрабатываются по-другому + // Здесь просто выполняем колбэк + try { + callback(); + } catch (error) { + console.error('Error in serialize:', error); + } + }, + + // Закрытие соединения + close: (callback) => { + pool.end() + .then(() => { + if (callback) callback(null); + }) + .catch(err => { + if (callback) callback(err); + }); + }, + + // Дополнительные методы + exec: (sql, callback) => { + pool.query(sql) + .then(() => { + if (callback) callback(null); + }) + .catch(err => { + if (callback) callback(err); + }); + } + }; +} + +function adaptSQLForPostgres(sql) { + // Адаптируем SQL запросы для PostgreSQL + let adaptedSql = sql; + + // Заменяем SQLite-специфичные синтаксисы + adaptedSql = adaptedSql.replace(/AUTOINCREMENT/gi, 'SERIAL'); + adaptedSql = adaptedSql.replace(/DATETIME/gi, 'TIMESTAMP'); + adaptedSql = adaptedSql.replace(/INTEGER PRIMARY KEY/gi, 'SERIAL PRIMARY KEY'); + adaptedSql = adaptedSql.replace(/datetime\('now'\)/gi, 'CURRENT_TIMESTAMP'); + adaptedSql = adaptedSql.replace(/CURRENT_TIMESTAMP/gi, 'CURRENT_TIMESTAMP'); + + // Исправляем INSERT с возвратом ID + if (adaptedSql.includes('INSERT INTO') && adaptedSql.includes('RETURNING id')) { + adaptedSql = adaptedSql.replace('RETURNING id', 'RETURNING id'); + } + + return adaptedSql; +} + +async function createPostgresTables() { + if (!USE_POSTGRES) return; + + try { + const client = await postgresPool.connect(); + + console.log('🔧 Проверяем/создаем таблицы в PostgreSQL...'); + + // Создаем таблицы PostgreSQL + await client.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + login VARCHAR(100) UNIQUE NOT NULL, + password TEXT, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + role VARCHAR(50) DEFAULT 'teacher', + auth_type VARCHAR(50) DEFAULT 'local', + groups TEXT DEFAULT '[]', + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(50) DEFAULT 'active', + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + deleted_by INTEGER REFERENCES users(id), + original_task_id INTEGER REFERENCES tasks(id), + start_date TIMESTAMP, + due_date TIMESTAMP, + rework_comment TEXT, + closed_at TIMESTAMP, + closed_by INTEGER REFERENCES users(id) + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS task_assignments ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + status VARCHAR(50) DEFAULT 'assigned', + start_date TIMESTAMP, + due_date TIMESTAMP, + rework_comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS task_files ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(500) NOT NULL, + file_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS activity_logs ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + action VARCHAR(100) NOT NULL, + details TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS notification_logs ( + id SERIAL PRIMARY KEY, + notification_key VARCHAR(500) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Добавляем таблицу для пользовательских настроек + await client.query(` + CREATE TABLE IF NOT EXISTS user_settings ( + id SERIAL PRIMARY KEY, + user_id INTEGER UNIQUE NOT NULL REFERENCES users(id), + email_notifications BOOLEAN DEFAULT true, + notification_email TEXT, + telegram_notifications BOOLEAN DEFAULT false, + telegram_chat_id TEXT, + vk_notifications BOOLEAN DEFAULT false, + vk_user_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Создаем индексы + const indexes = [ + 'CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)', + 'CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)', + 'CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)' + ]; + + for (const indexQuery of indexes) { + try { + await client.query(indexQuery); + } catch (err) { + console.warn(`⚠️ Не удалось создать индекс: ${err.message}`); + } + } + + client.release(); + console.log('✅ Таблицы PostgreSQL проверены/созданы'); + + // Проверяем структуру PostgreSQL таблиц + await checkPostgresTableStructure(); + + } catch (error) { + console.error('❌ Ошибка создания таблиц PostgreSQL:', error.message); + } +} + +// Функция для проверки структуры таблиц PostgreSQL +async function checkPostgresTableStructure() { + if (!USE_POSTGRES) return; + + try { + const client = await postgresPool.connect(); + + console.log('🔍 Проверка структуры таблиц PostgreSQL...'); + + // Определяем ожидаемую структуру таблиц PostgreSQL + const tableSchemas = { + user_settings: [ + { name: 'id', type: 'SERIAL PRIMARY KEY' }, + { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL REFERENCES users(id)' }, + { name: 'email_notifications', type: 'BOOLEAN DEFAULT true' }, + { name: 'notification_email', type: 'TEXT' }, + { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT false' }, + { name: 'telegram_chat_id', type: 'TEXT' }, + { name: 'vk_notifications', type: 'BOOLEAN DEFAULT false' }, + { name: 'vk_user_id', type: 'TEXT' }, + { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } + ] + }; + + // Проверяем каждую таблицу + for (const [tableName, columns] of Object.entries(tableSchemas)) { + try { + // Проверяем существование таблицы + const tableExists = await client.query( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", + [tableName] + ); + + if (!tableExists.rows[0].exists) { + console.log(`⚠️ Таблица ${tableName} не существует в PostgreSQL`); + continue; + } + + // Получаем существующие колонки + const existingColumns = await client.query(` + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `, [tableName]); + + const existingColumnNames = existingColumns.rows.map(col => col.column_name.toLowerCase()); + + // Проверяем каждую ожидаемую колонку + for (const expectedColumn of columns) { + const expectedName = expectedColumn.name.toLowerCase(); + + if (!existingColumnNames.includes(expectedName)) { + console.log(`🔧 Добавляем колонку ${expectedColumn.name} в таблицу PostgreSQL ${tableName}...`); + + try { + await client.query( + `ALTER TABLE ${tableName} ADD COLUMN ${expectedColumn.name} ${expectedColumn.type}` + ); + console.log(`✅ Колонка ${expectedColumn.name} добавлена в таблицу PostgreSQL ${tableName}`); + } catch (alterErr) { + console.error(`❌ Ошибка добавления колонки ${expectedColumn.name}:`, alterErr.message); + } + } + } + } catch (error) { + console.error(`❌ Ошибка проверки таблицы PostgreSQL ${tableName}:`, error.message); + } + } + + client.release(); + console.log('✅ Проверка структуры таблиц PostgreSQL завершена'); + + } catch (error) { + console.error('❌ Ошибка проверки структуры таблиц PostgreSQL:', error.message); + } +} + +function createTaskFolder(taskId) { + const taskFolder = path.join(tasksDir, taskId.toString()); + createDirIfNotExists(taskFolder); + return taskFolder; +} + +function createUserTaskFolder(taskId, userLogin) { + const taskFolder = path.join(tasksDir, taskId.toString()); + const userFolder = path.join(taskFolder, userLogin); + createDirIfNotExists(userFolder); + return userFolder; +} + +function saveTaskMetadata(taskId, title, description, createdBy, originalTaskId = null, startDate = null, dueDate = null) { + const taskFolder = createTaskFolder(taskId); + const metadata = { + id: taskId, + title: title, + description: description, + status: 'active', + created_by: createdBy, + original_task_id: originalTaskId, + start_date: startDate, + due_date: dueDate, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + files: [] + }; + + const metadataPath = path.join(taskFolder, 'task.json'); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +function updateTaskMetadata(taskId, updates) { + const metadataPath = path.join(tasksDir, taskId.toString(), 'task.json'); + if (fs.existsSync(metadataPath)) { + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + const updatedMetadata = { ...metadata, ...updates, updated_at: new Date().toISOString() }; + fs.writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2)); + } +} + +function logActivity(taskId, userId, action, details = '') { + db.run( + "INSERT INTO activity_logs (task_id, user_id, action, details) VALUES (?, ?, ?, ?)", + [taskId, userId, action, details] + ); + + const logEntry = `${new Date().toISOString()} - User ${userId}: ${action} - Task ${taskId} - ${details}\n`; + fs.appendFileSync(path.join(logsDir, 'activity.log'), logEntry); +} + +function checkTaskAccess(userId, taskId, callback) { + db.get("SELECT role FROM users WHERE id = ?", [userId], (err, user) => { + if (err) { + callback(err, false); + return; + } + + if (user && user.role === 'admin') { + callback(null, true); + return; + } + + db.get("SELECT status, created_by, closed_at FROM tasks WHERE id = ?", [taskId], (err, task) => { + if (err || !task) { + callback(err, false); + return; + } + + if (task.closed_at && task.created_by !== userId && user.role !== 'admin') { + callback(null, false); + return; + } + + const query = ` + SELECT 1 FROM tasks t + WHERE t.id = ? AND ( + t.created_by = ? + OR EXISTS (SELECT 1 FROM task_assignments WHERE task_id = t.id AND user_id = ?) + ) + `; + + db.get(query, [taskId, userId, userId], (err, row) => { + callback(err, !!row); + }); + }); + }); +} + +function checkOverdueTasks() { + const now = new Date().toISOString(); + + const query = ` + SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date + FROM task_assignments ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.due_date IS NOT NULL + AND ta.due_date < ? + AND ta.status NOT IN ('completed', 'overdue') + AND t.status = 'active' + AND t.closed_at IS NULL + `; + + db.all(query, [now], (err, assignments) => { + if (err) { + console.error('Ошибка при проверке просроченных задач:', err); + return; + } + + assignments.forEach(assignment => { + db.run( + "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", + [assignment.id] + ); + logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); + }); + }); +} + +setInterval(checkOverdueTasks, 60000); + +module.exports = { + initializeDatabase, // Экспортируем функцию инициализации + getDb: () => { + if (!isInitialized) { + throw new Error('База данных не инициализирована'); + } + return db; + }, + isInitialized: () => isInitialized, + logActivity, + createTaskFolder, + createUserTaskFolder, + saveTaskMetadata, + updateTaskMetadata, + checkTaskAccess, + USE_POSTGRES, + getDatabaseType: () => USE_POSTGRES ? 'PostgreSQL' : 'SQLite', + checkAndUpdateTableStructure // Экспортируем для ручного запуска +}; + +// Запускаем инициализацию при экспорте (но она завершится позже) +initializeDatabase().catch(err => { + console.error('❌ Ошибка инициализации базы данных:', err.message); }); \ No newline at end of file diff --git a/package.json b/package.json index 0b22f85..29101a4 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,25 @@ -{ - "name": "school-crm", - "version": "1.2.0", - "description": "CRM система для школы с управлением задачами и админ-панелью", - "main": "server.js", - "scripts": { - "start": "node server.js", - "dev": "nodemon server.js" - }, - "dependencies": { - "bcryptjs": "~2.4.3", - "dotenv": "~16.3.1", - "express": "^4.21.2", - "express-session": "^1.18.2", - "form-data": "^4.0.0", - "multer": "^2.0.2", - "node-fetch": "~2.6.7", - "nodemailer": "^6.9.13", - "pg": "^8.11.3", - "sqlite3": "~5.1.6" - }, - "devDependencies": { - "nodemon": "~3.0.1" - } +{ + "name": "school-crm", + "version": "1.2.0", + "description": "CRM система для школы с управлением задачами и админ-панелью", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "~2.4.3", + "dotenv": "~16.3.1", + "express": "^4.21.2", + "express-session": "^1.18.2", + "form-data": "^4.0.0", + "multer": "^2.0.2", + "node-fetch": "~2.6.7", + "nodemailer": "^6.9.13", + "pg": "^8.11.3", + "sqlite3": "~5.1.6" + }, + "devDependencies": { + "nodemon": "~3.0.1" + } } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 0ef7809..9ed786a 100644 --- a/public/index.html +++ b/public/index.html @@ -1,369 +1,369 @@ - - - - - - School CRM - Управление задачами - - - - - - -
-
-
-

School CRM - Управление задачами

- -
- -
- -
-
-

Все задачи

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

Создать новую задачу

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

Лог активности

-
-
- -
-

Личный кабинет

- -
-

Настройки уведомлений

-
-
- -
- -
- -
- - -
- Если не указано, будет использован email из профиля -
- -
- -
-
- -
-
-
-
- -
-
-
-
-
- - - - - - - - - - -
-
-

Канбан-доска

-

Перетаскивайте задачи между колонками для изменения статуса

-
-
- - -
-
-
- -
-
Загрузка Канбан-доски...
-
-
- - - - - - - - - - + + + + + + School CRM - Управление задачами + + + + + + +
+
+
+

School CRM - Управление задачами

+ +
+ +
+ +
+
+

Все задачи

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

Создать новую задачу

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

Лог активности

+
+
+ +
+

Личный кабинет

+ +
+

Настройки уведомлений

+
+
+ +
+ +
+ +
+ + +
+ Если не указано, будет использован email из профиля +
+ +
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+ + + + + + + + + + +
+
+

Канбан-доска

+

Перетаскивайте задачи между колонками для изменения статуса

+
+
+ + +
+
+
+ +
+
Загрузка Канбан-доски...
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/style.css b/public/style.css index b7422e4..f020a05 100644 --- a/public/style.css +++ b/public/style.css @@ -1,2279 +1,2279 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - line-height: 1.6; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #333; - min-height: 100vh; -} - -.container { - max-width: 99%; - margin: 0 auto; - padding: 20px; - display: none; -} - -header { - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: 15px; - padding: 1.5rem; - margin-bottom: 25px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); -} - -header h1 { - margin-bottom: 1.2rem; - text-align: center; - color: #2c3e50; - font-size: 2.2rem; - font-weight: 700; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.user-info { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1.2rem; - padding: 12px 20px; - background: linear-gradient(135deg, #667eea, #764ba2); - border-radius: 10px; - color: white; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); -} - -.user-info span { - font-weight: 600; - font-size: 1.1rem; -} - -.user-info button { - background: rgba(255, 255, 255, 0.2); - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.user-info button:hover { - background: rgba(255, 255, 255, 0.3); - transform: translateY(-2px); -} - -nav { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -nav button { - background: linear-gradient(135deg, #3498db, #2980b9); - color: white; - border: none; - padding: 12px 24px; - border-radius: 10px; - cursor: pointer; - flex: 1; - min-width: 140px; - font-weight: 600; - font-size: 1rem; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); -} - -nav button:hover { - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); -} - -.section { - display: none; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - padding: 25px; - border-radius: 15px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - margin-bottom: 20px; -} - -.section.active { - display: block; - animation: fadeIn 0.5s ease-in; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -.form-group { - margin-bottom: 20px; -} - -label { - display: block; - margin-bottom: 8px; - font-weight: 600; - color: #2c3e50; - font-size: 1rem; -} - -input[type="text"], -input[type="password"], -input[type="datetime-local"], -textarea, -select { - width: 100%; - padding: 12px 16px; - border: 2px solid #e9ecef; - border-radius: 10px; - font-size: 1rem; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.9); -} - -input[type="text"]:focus, -input[type="password"]:focus, -input[type="datetime-local"]:focus, -textarea:focus, -select:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); - transform: translateY(-2px); -} - -textarea { - resize: vertical; - min-height: 120px; - font-family: inherit; -} - -button { - background: linear-gradient(135deg, #27ae60, #219a52); - color: white; - padding: 12px 30px; - border: none; - border-radius: 10px; - cursor: pointer; - font-size: 1rem; - font-weight: 600; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(39, 174, 96, 0.3); -} - -button:hover { - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(39, 174, 96, 0.4); -} - -button.delete-btn { - background: linear-gradient(135deg, #e74c3c, #c0392b); - box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3); -} - -button.delete-btn:hover { - box-shadow: 0 6px 20px rgba(231, 76, 60, 0.4); -} - -button.edit-btn { - background: linear-gradient(135deg, #f39c12, #e67e22); - box-shadow: 0 4px 15px rgba(243, 156, 18, 0.3); -} - -button.edit-btn:hover { - box-shadow: 0 6px 20px rgba(243, 156, 18, 0.4); -} - -button.copy-btn { - background: linear-gradient(135deg, #3498db, #2980b9); - box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); -} - -button.copy-btn:hover { - box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); -} - -button.restore-btn { - background: linear-gradient(135deg, #9b59b6, #8e44ad); - box-shadow: 0 4px 15px rgba(155, 89, 182, 0.3); -} - -button.restore-btn:hover { - box-shadow: 0 6px 20px rgba(155, 89, 182, 0.4); -} - -button.edit-date-btn { - background: linear-gradient(135deg, #17a2b8, #138496); - padding: 6px 12px; - font-size: 0.85rem; - box-shadow: 0 2px 8px rgba(23, 162, 184, 0.3); -} - -button.edit-date-btn:hover { - box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4); -} - -#tasks-controls { - margin-bottom: 20px; - padding: 15px 20px; - background: linear-gradient(135deg, #ecf0f1, #bdc3c7); - border-radius: 10px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); -} - -#tasks-controls label { - display: flex; - align-items: center; - gap: 10px; - margin: 0; - font-weight: normal; - color: #2c3e50; - cursor: pointer; -} - -#tasks-controls input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; -} - -.task-card { - border: none; - border-radius: 15px; - padding: 0; - margin-bottom: 20px; - background: rgba(255, 255, 255, 0.9); - position: relative; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); - border-left: 5px solid #3498db; -} - -.task-card:hover { - transform: translateY(-5px); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); -} - -.task-card.deleted { - background: linear-gradient(135deg, #f8d7da, #f5c6cb); - border-left-color: #e74c3c; - opacity: 0.8; -} - -.task-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 0; - gap: 20px; -} - -.task-title { - font-size: 1.2rem; - font-weight: 700; - color: #2c3e50; - flex: 1; - line-height: 1.4; - cursor: pointer; - padding: 15px; - border-radius: 10px; - background: linear-gradient(135deg, rgba(52, 152, 219, 0.1), rgba(52, 152, 219, 0.05)); - transition: all 0.3s ease; -} - -.task-title:hover { - background: linear-gradient(135deg, rgba(52, 152, 219, 0.15), rgba(52, 152, 219, 0.1)); - transform: translateY(-2px); -} - -.task-status { - padding: 8px 16px; - border-radius: 25px; - font-size: 0.9rem; - font-weight: 600; - white-space: nowrap; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.status-purple { - background: linear-gradient(135deg, #9b59b6, #8e44ad); - color: white; -} -.status-red { - background: linear-gradient(135deg, #e74c3c, #c0392b); - color: white; -} -.status-orange { - background: linear-gradient(135deg, #f39c12, #e67e22); - color: white; -} -.status-green { - background: linear-gradient(135deg, #27ae60, #219a52); - color: white; -} -.status-gray { - background: linear-gradient(135deg, #95a5a6, #7f8c8d); - color: white; -} -.status-darkred { - background: linear-gradient(135deg, #8b0000, #660000); - color: white; -} - -.deleted-badge { - background: #e74c3c; - color: white; - padding: 4px 12px; - border-radius: 20px; - font-size: 0.8rem; - margin-left: 10px; - font-weight: 600; -} - -.copy-badge { - background: #3498db; - color: white; - padding: 4px 12px; - border-radius: 20px; - font-size: 0.8rem; - margin-left: 10px; - font-weight: 600; -} - -.role-badge { - font-size: 0.8rem; - padding: 4px 10px; - border-radius: 15px; - margin-left: 8px; - font-weight: 600; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); -} - -.role-admin { - background: linear-gradient(135deg, #e74c3c, #c0392b); - color: white; -} -.role-creator { - background: linear-gradient(135deg, #27ae60, #219a52); - color: white; -} -.role-executor { - background: linear-gradient(135deg, #3498db, #2980b9); - color: white; -} - -.task-original { - margin: 10px 0; - padding: 10px 15px; - background: linear-gradient(135deg, #e8f4fd, #d4e6f1); - border-radius: 8px; - border-left: 4px solid #3498db; -} - -.task-original small { - color: #2c3e50; - font-style: italic; - font-size: 0.9rem; -} - -.task-description { - margin: 15px 0; - padding: 15px; - background: #f8f9fa; - border-radius: 8px; - line-height: 1.6; - color: #495057; -} - -.task-dates-files { - margin: 15px 0; - padding: 15px; - background: linear-gradient(135deg, #fff3cd, #ffeaa7); - border-radius: 10px; - border-left: 4px solid #f39c12; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 10px; -} - -.task-dates-files .task-dates { - flex: 1; - min-width: 250px; - font-size: 0.95rem; - color: #856404; -} - -.task-dates-files .file-list { - flex: 1; - min-width: 250px; - font-size: 0.95rem; - color: #856404; -} - -.task-dates-files .file-list strong { - display: block; - margin-bottom: 5px; -} - -.task-dates-files .files-placeholder { - color: #6c757d; - font-style: italic; -} - -.task-dates-files strong { - color: #e67e22; -} - -.task-assignments { - margin: 20px 0; -} - -.assignment { - display: flex; - align-items: center; - margin: 10px 0; - padding: 12px 15px; - border-radius: 10px; - background: rgba(248, 249, 250, 0.8); - transition: all 0.3s ease; - border: 1px solid #e9ecef; -} - -.assignment:hover { - background: rgba(233, 236, 239, 0.8); - transform: translateX(5px); -} - -.assignment.overdue { - background: linear-gradient(135deg, #f8d7da, #f5c6cb); - border: 1px solid #e74c3c; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); } - 70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); } - 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } -} - -.assignment-status { - width: 16px; - height: 16px; - border-radius: 50%; - margin-right: 15px; - flex-shrink: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.status-assigned { - background: linear-gradient(135deg, #e74c3c, #c0392b); -} -.status-in_progress { - background: linear-gradient(135deg, #f39c12, #e67e22); -} -.status-completed { - background: linear-gradient(135deg, #27ae60, #219a52); -} -.status-overdue { - background: linear-gradient(135deg, #8b0000, #660000); -} - -.assignment-dates { - margin-top: 8px; - font-size: 0.85rem; - color: #6c757d; -} - -.assignment-dates small { - display: block; - margin: 3px 0; -} - -.action-buttons { - margin-top: 12px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.action-buttons button { - margin-right: 0; - padding: 8px 16px; - font-size: 0.9rem; - border-radius: 8px; -} - -.task-actions { - position: absolute; - bottom: 20px; - right: 20px; - display: flex; - gap: 8px; -} - -.task-actions button { - padding: 8px 12px; - font-size: 1rem; - border-radius: 8px; - min-width: auto; -} - -.file-list { - margin-top: 15px; - padding: 15px; - background: #f8f9fa; - border-radius: 10px; -} - -.file-item { - display: flex; - align-items: center; - padding: 10px; - background: white; - margin-bottom: 8px; - border-radius: 8px; - border: 1px solid #e9ecef; - transition: all 0.3s ease; -} - -.file-item:hover { - transform: translateX(5px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.file-item a { - color: #3498db; - text-decoration: none; - margin-right: 15px; - font-weight: 500; - transition: color 0.3s ease; -} - -.file-item a:hover { - color: #2980b9; - text-decoration: underline; -} - -.file-item small { - color: #6c757d; - margin-left: auto; - font-size: 0.85rem; -} - -.task-meta { - margin-top: 15px; - padding-top: 15px; - border-top: 2px solid #e9ecef; -} - -.task-meta small { - color: #6c757d; - font-size: 0.9rem; -} - -.log-entry { - padding: 15px; - border-bottom: 1px solid #e9ecef; - font-size: 0.95rem; - transition: all 0.3s ease; -} - -.log-entry:hover { - background: #f8f9fa; - border-radius: 8px; -} - -.log-time { - color: #6c757d; - font-size: 0.85rem; - margin-bottom: 5px; -} - -.modal { - display: none; - position: fixed; - z-index: 1000; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(5px); - animation: fadeIn 0.3s ease; -} - -.modal-content { - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(20px); - margin: 8% auto; - padding: 30px; - border-radius: 20px; - width: 90%; - max-width: 600px; - max-height: 85vh; - overflow-y: auto; - position: relative; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.3); - animation: slideIn 0.3s ease; -} - -@keyframes slideIn { - from { transform: translateY(-50px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -.close { - color: #6c757d; - float: right; - font-size: 28px; - font-weight: bold; - cursor: pointer; - position: absolute; - right: 20px; - top: 15px; - transition: all 0.3s ease; -} - -.close:hover { - color: #e74c3c; - transform: rotate(90deg); -} - -.checkbox-group { - max-height: 250px; - overflow-y: auto; - border: 2px solid #e9ecef; - padding: 15px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.8); -} - -.checkbox-item { - margin: 8px 0; - padding: 8px 12px; - border-radius: 8px; - transition: all 0.3s ease; -} - -.checkbox-item:hover { - background: rgba(52, 152, 219, 0.1); - transform: translateX(5px); -} - -.checkbox-item label { - display: flex; - align-items: center; - gap: 12px; - margin: 0; - font-weight: normal; - cursor: pointer; - color: #495057; -} - -.checkbox-item input[type="checkbox"] { - width: 18px; - height: 18px; - cursor: pointer; -} - -#login-modal .modal-content { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.85)); - text-align: center; - max-width: 500px; -} - -#login-form .form-group { - display: flex; - flex-direction: column; - align-items: center; -} - -#login-form input[type="text"], -#login-form input[type="password"] { - width: 100%; - max-width: 300px; - margin: 0 auto; - padding: 12px 16px; - border: 2px solid #e9ecef; - border-radius: 10px; - font-size: 1rem; - transition: all 0.3s ease; -} - -#login-form input[type="text"]:focus, -#login-form input[type="password"]:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); - transform: translateY(-2px); -} - -.test-users { - margin-top: 25px; - padding: 20px; - background: linear-gradient(135deg, #f8f9fa, #e9ecef); - border-radius: 15px; - border-left: 5px solid #3498db; -} - -.test-users h3 { - margin-bottom: 15px; - color: #2c3e50; - font-size: 1.2rem; -} - -.test-users ul { - list-style: none; - padding: 0; -} - -.test-users li { - padding: 8px 0; - border-bottom: 1px solid #dee2e6; - color: #495057; -} - -.test-users li:last-child { - border-bottom: none; -} - -.test-users strong { - color: #2c3e50; -} - -.loading { - text-align: center; - padding: 40px 20px; - color: #6c757d; - font-size: 1.1rem; -} - -.error { - background: linear-gradient(135deg, #f8d7da, #f5c6cb); - color: #721c24; - padding: 15px 20px; - border-radius: 10px; - margin: 15px 0; - border: 1px solid #f5c6cb; - border-left: 5px solid #e74c3c; -} - -@media (max-width: 768px) { - .container { - padding: 10px; - } - - .task-header { - flex-direction: column; - align-items: flex-start; - gap: 15px; - } - - .task-actions { - position: static; - margin-top: 15px; - justify-content: center; - width: 100%; - } - - nav { - flex-direction: column; - } - - .modal-content { - width: 95%; - margin: 5% auto; - padding: 20px; - } - - header h1 { - font-size: 1.8rem; - } - - .user-info { - flex-direction: column; - gap: 10px; - text-align: center; - } - - .assignment { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .action-buttons { - width: 100%; - justify-content: flex-start; - } - - #login-form input[type="text"], - #login-form input[type="password"] { - max-width: 100%; - } -} - -@media (max-width: 480px) { - .section { - padding: 15px; - } - - .task-card { - padding: 15px; - } - - .task-title { - font-size: 1.2rem; - } - - .task-status { - font-size: 0.8rem; - padding: 6px 12px; - } - - .task-actions { - gap: 5px; - } - - .task-actions button { - padding: 8px 12px; - font-size: 0.8rem; - } - - nav button { - padding: 10px 15px; - font-size: 0.9rem; - } -} - -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 10px; -} - -::-webkit-scrollbar-thumb { - background: linear-gradient(135deg, #3498db, #2980b9); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: linear-gradient(135deg, #2980b9, #1f618d); -} - -button:focus, -input:focus, -textarea:focus, -select:focus { - outline: 2px solid #3498db; - outline-offset: 2px; -} - -@media print { - .task-actions, - nav, - .user-info button { - display: none !important; - } - - .task-card { - break-inside: avoid; - box-shadow: none; - border: 1px solid #ddd; - } -} - -.filters { - display: flex; - gap: 20px; - margin-bottom: 15px; - flex-wrap: wrap; -} - -.filter-group { - display: flex; - flex-direction: column; - gap: 5px; -} - -.filter-group label { - font-weight: 600; - color: #2c3e50; - margin-bottom: 0; -} - -.filter-group input, -.filter-group select { - padding: 8px 12px; - border: 2px solid #e9ecef; - border-radius: 8px; - font-size: 0.9rem; -} - -.user-search { - margin-bottom: 10px; -} - -.user-search input { - width: 100%; - padding: 8px 12px; - border: 2px solid #e9ecef; - border-radius: 8px; - font-size: 0.9rem; -} - -.status-yellow { - background: linear-gradient(135deg, #ffc107, #e0a800); - color: white; -} - -.task-card.closed { - background: linear-gradient(135deg, #e9ecef, #dee2e6); - border-left-color: #6c757d; - opacity: 0.8; -} - -.closed-badge { - background: #6c757d; - color: white; - padding: 4px 12px; - border-radius: 20px; - font-size: 0.8rem; - margin-left: 10px; - font-weight: 600; -} - -.rework-comment { - margin: 10px 0; - padding: 12px 15px; - background: linear-gradient(135deg, #fff3cd, #ffeaa7); - border-radius: 8px; - border-left: 4px solid #ffc107; - color: #856404; -} - -.assignment-rework-comment { - margin-top: 8px; - padding: 8px; - background: #fff3cd; - border-radius: 6px; - border-left: 3px solid #ffc107; -} - -.assignment.rework { - background: linear-gradient(135deg, #fff3cd, #ffeaa7); - border: 1px solid #ffc107; -} - -button.rework-btn { - background: linear-gradient(135deg, #ffc107, #e0a800); - box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3); -} - -button.rework-btn:hover { - box-shadow: 0 6px 20px rgba(255, 193, 7, 0.4); -} - -button.close-btn { - background: linear-gradient(135deg, #6c757d, #5a6268); - box-shadow: 0 4px 15px rgba(108, 117, 125, 0.3); -} - -button.close-btn:hover { - box-shadow: 0 6px 20px rgba(108, 117, 125, 0.4); -} - -button.reopen-btn { - background: linear-gradient(135deg, #20c997, #1ea085); - box-shadow: 0 4px 15px rgba(32, 201, 151, 0.3); -} - -button.reopen-btn:hover { - box-shadow: 0 6px 20px rgba(32, 201, 151, 0.4); -} - -.show-deleted-label { - display: flex; - align-items: center; - gap: 8px; - font-weight: normal; - color: #2c3e50; - cursor: pointer; -} - -.show-deleted-label input { - margin: 0; -} - -.show-deleted-label[style*="display: none"] { - display: none !important; -} - -.deadline-badge { - padding: 4px 12px; - border-radius: 20px; - font-size: 0.8rem; - margin-left: 10px; - font-weight: 600; -} - -.deadline-48h { - background: linear-gradient(135deg, #ffc107, #e0a800); - color: #856404; -} - -.deadline-24h { - background: linear-gradient(135deg, #fd7e14, #e8590c); - color: white; -} - -.deadline-indicator { - padding: 2px 8px; - border-radius: 12px; - font-size: 0.7rem; - margin-left: 8px; - font-weight: 600; -} - -.task-title .deadline-badge { - vertical-align: middle; -} - -.filter-group select { - min-width: 150px; -} - -.filters { - gap: 15px; - margin-bottom: 15px; -} - -@media (max-width: 768px) { - .filters { - flex-direction: column; - gap: 10px; - } - - .filter-group select { - width: 100%; - } -} - -.admin-container { - max-width: 1400px; - margin: 0 auto; - padding: 20px; - display: none; -} - -.admin-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 30px; - padding-bottom: 20px; - border-bottom: 2px solid #e9ecef; -} - -.admin-tabs { - display: flex; - gap: 10px; - margin-bottom: 20px; - border-bottom: 2px solid #e9ecef; - padding-bottom: 10px; -} - -.admin-tab { - padding: 10px 20px; - background: #f8f9fa; - border: none; - border-radius: 8px 8px 0 0; - cursor: pointer; - font-weight: 600; - color: #495057; - transition: all 0.3s ease; -} - -.admin-tab:hover { - background: #e9ecef; -} - -.admin-tab.active { - background: #3498db; - color: white; -} - -.admin-section { - display: none; - padding: 20px; - background: white; - border-radius: 10px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); -} - -.admin-section.active { - display: block; - animation: fadeIn 0.3s ease; -} - -.users-table { - width: 100%; - border-collapse: collapse; - margin-top: 20px; -} - -.users-table th, -.users-table td { - padding: 12px; - text-align: left; - border-bottom: 1px solid #e9ecef; -} - -.users-table th { - background: #f8f9fa; - font-weight: 600; - color: #495057; -} - -.users-table tr:hover { - background: #f8f9fa; -} - -.user-actions { - display: flex; - gap: 8px; -} - -.user-actions button { - padding: 6px 12px; - font-size: 0.85rem; - border-radius: 6px; -} - -.ldap-badge { - background: #3498db; - color: white; - padding: 3px 8px; - border-radius: 12px; - font-size: 0.75rem; - margin-left: 5px; -} - -.admin-badge { - background: #e74c3c; - color: white; - padding: 3px 8px; - border-radius: 12px; - font-size: 0.75rem; - margin-left: 5px; -} - -.form-row { - display: flex; - gap: 20px; - margin-bottom: 20px; -} - -.form-row .form-group { - flex: 1; -} - -.password-fields { - background: #f8f9fa; - padding: 15px; - border-radius: 8px; - margin-bottom: 20px; - border-left: 4px solid #3498db; -} - -.modal-lg { - max-width: 800px; -} - -.search-container { - display: flex; - gap: 10px; - margin-bottom: 20px; -} - -.search-container input { - flex: 1; -} - -.stats-cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; - margin-bottom: 30px; -} - -.stat-card { - background: white; - padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - border-left: 4px solid #3498db; -} - -.stat-card h3 { - margin: 0 0 10px 0; - color: #495057; - font-size: 1.1rem; -} - -.stat-value { - font-size: 2rem; - font-weight: 700; - color: #2c3e50; - margin-bottom: 10px; -} - -.stat-desc { - color: #6c757d; - font-size: 0.9rem; -} - -.stat-card.admin-stat { - border-left-color: #e74c3c; -} - -.stat-card.ldap-stat { - border-left-color: #2ecc71; -} - -.stat-card.local-stat { - border-left-color: #f39c12; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.error { - background: linear-gradient(135deg, #f8d7da, #f5c6cb); - color: #721c24; - padding: 15px 20px; - border-radius: 10px; - margin: 15px 0; - border: 1px solid #f5c6cb; - border-left: 5px solid #e74c3c; - text-align: center; -} - -.task-number { - background: #3498db; - color: white; - padding: 3px 8px; - border-radius: 12px; - font-size: 0.8rem; - margin-right: 10px; - font-weight: bold; -} - -.task-header .task-title .expand-icon { - font-size: 0.8rem; - color: #6c757d; - margin-left: auto; - padding-left: 15px; -} - -.task-content { - padding: 20px; - border-top: 1px solid #e9ecef; - margin-top: 10px; - animation: fadeIn 0.3s ease; - overflow: hidden; - transition: max-height 0.3s ease; - max-height: 0; -} - -.task-content.expanded { - max-height: 2000px; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } -} - -.tasks-no-date-btn { - background: linear-gradient(135deg, #17a2b8, #138496); - box-shadow: 0 4px 15px rgba(23, 162, 184, 0.3); -} - -.tasks-no-date-btn.active { - background: linear-gradient(135deg, #138496, #117a8b); - box-shadow: 0 6px 20px rgba(23, 162, 184, 0.4); -} - -.no-date-badge { - background: linear-gradient(135deg, #6c757d, #5a6268); - color: white; - padding: 3px 8px; - border-radius: 12px; - font-size: 0.75rem; - margin-left: 10px; - font-weight: 600; -} - -@media (max-width: 768px) { - .task-dates-files { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - - .task-dates-files .task-dates, - .task-dates-files .file-list { - min-width: 100%; - } -} -/* Стили для иконок файлов */ -.file-icons-container { - display: flex; - flex-wrap: wrap; - gap: 15px; - margin-top: 10px; -} - -.file-icon-container { - display: flex; - flex-direction: column; - align-items: center; - width: 80px; - cursor: pointer; - transition: transform 0.2s ease; - position: relative; -} - -.file-icon-container:hover { - transform: translateY(-5px); -} - -.file-icon { - width: 60px; - height: 60px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - transition: all 0.3s ease; - position: relative; - overflow: hidden; -} - -.file-icon:hover { - transform: scale(1.05); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); -} - -.file-icon::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 20px; - background: rgba(255, 255, 255, 0.2); -} - -.file-extension { - color: white; - font-size: 0.8rem; - font-weight: bold; - text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - z-index: 1; -} - -.file-name { - margin-top: 5px; - font-size: 0.75rem; - color: #495057; - text-align: center; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.2s ease; -} - -.file-download-link { - text-decoration: none; - color: inherit; - width: 100%; -} - -.file-download-link:hover .file-name { - color: #3498db; - text-decoration: underline; -} - -/* Тултип при наведении */ -.file-icon-container::before { - content: attr(title); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 5px 10px; - border-radius: 4px; - font-size: 0.8rem; - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; - z-index: 100; - pointer-events: none; -} - -.file-icon-container:hover::before { - opacity: 1; - visibility: visible; -} - -/* Адаптивность */ -@media (max-width: 768px) { - .file-icons-container { - gap: 10px; - justify-content: center; - } - - .file-icon-container { - width: 70px; - } - - .file-icon { - width: 50px; - height: 50px; - } - - .file-extension { - font-size: 0.7rem; - } - - .file-name { - font-size: 0.7rem; - } -} - -@media (max-width: 480px) { - .file-icons-container { - gap: 8px; - } - - .file-icon-container { - width: 60px; - } - - .file-icon { - width: 45px; - height: 45px; - } - - .file-extension { - font-size: 0.65rem; - } - - .file-name { - font-size: 0.65rem; - } -} -.no-files { - color: #6c757d; - font-style: italic; - font-size: 0.9rem; -} - -/* Сделаем файлы видимыми всегда */ -.task-dates-files .file-list { - flex: 1; - min-width: 250px; - font-size: 0.95rem; - color: #856404; -} - -.task-dates-files .file-list strong { - display: block; - margin-bottom: 10px; -} - -/* Уменьшим отступы для компактности */ -.task-dates-files { - margin: 10px 0; - padding: 12px; - background: linear-gradient(135deg, #fff3cd, #ffeaa7); - border-radius: 10px; - border-left: 4px solid #f39c12; - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: 15px; -} -/* Стили для иконок файлов */ -.file-icons-container { - display: flex; - flex-wrap: wrap; - gap: 15px; - margin-top: 10px; -} - -.file-icon-container { - display: flex; - flex-direction: column; - align-items: center; - width: 80px; - cursor: pointer; - transition: transform 0.2s ease; - position: relative; -} - -.file-icon-container:hover { - transform: translateY(-5px); -} - -.file-icon { - width: 60px; - height: 60px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - transition: all 0.3s ease; - position: relative; - overflow: hidden; -} - -.file-icon:hover { - transform: scale(1.05); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); -} - -.file-icon::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 20px; - background: rgba(255, 255, 255, 0.2); -} - -.file-extension { - color: white; - font-size: 0.8rem; - font-weight: bold; - text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - z-index: 1; - padding: 0 5px; - text-align: center; - line-height: 1.2; - max-width: 100%; - word-break: break-all; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -/* Для коротких текстов (1-2 символа) */ -.file-extension.short { - font-size: 1rem; -} - -/* Для средних текстов (3-4 символа) */ -.file-extension.medium { - font-size: 0.9rem; -} - -/* Для длинных текстов (5+ символов) */ -.file-extension.long { - font-size: 0.7rem; -} - -.file-name { - margin-top: 5px; - font-size: 0.75rem; - color: #495057; - text-align: center; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.2s ease; -} - -.file-download-link { - text-decoration: none; - color: inherit; - width: 100%; -} - -.file-download-link:hover .file-name { - color: #3498db; - text-decoration: underline; -} - -/* Тултип при наведении */ -.file-icon-container::before { - content: attr(title); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 5px 10px; - border-radius: 4px; - font-size: 0.8rem; - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; - z-index: 100; - pointer-events: none; -} - -.file-icon-container:hover::before { - opacity: 1; - visibility: visible; -} - -/* Адаптивность */ -@media (max-width: 768px) { - .file-icons-container { - gap: 10px; - justify-content: center; - } - - .file-icon-container { - width: 70px; - } - - .file-icon { - width: 50px; - height: 50px; - } - - .file-extension { - font-size: 0.7rem; - } - - .file-extension.short { - font-size: 0.9rem; - } - - .file-extension.medium { - font-size: 0.8rem; - } - - .file-extension.long { - font-size: 0.6rem; - } - - .file-name { - font-size: 0.7rem; - } -} - -@media (max-width: 480px) { - .file-icons-container { - gap: 8px; - } - - .file-icon-container { - width: 60px; - } - - .file-icon { - width: 45px; - height: 45px; - } - - .file-extension { - font-size: 0.65rem; - } - - .file-extension.short { - font-size: 0.8rem; - } - - .file-extension.medium { - font-size: 0.7rem; - } - - .file-extension.long { - font-size: 0.55rem; - } - - .file-name { - font-size: 0.65rem; - } -} -/* Добавьте эти стили в style.css */ - -.assignments-container { - margin-top: 10px; - border: 1px solid #e1e5e9; - border-radius: 8px; - padding: 10px; - background: #f8fafc; -} - -.assignments-filter { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px solid #e1e5e9; -} - -.assignment-filter-input { - flex: 1; - padding: 6px 10px; - border: 1px solid #d1d5db; - border-radius: 4px; - font-size: 14px; - margin-right: 10px; -} - -.assignment-filter-input:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); -} - -.filter-count { - font-size: 12px; - color: #6c757d; - background: #e9ecef; - padding: 2px 8px; - border-radius: 12px; - white-space: nowrap; -} - -.assignments-scroll-container { - max-height: 300px; - overflow-y: auto; - padding-right: 5px; -} - -/* Стили для скроллбара */ -.assignments-scroll-container::-webkit-scrollbar { - width: 8px; -} - -.assignments-scroll-container::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; -} - -.assignments-scroll-container::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 4px; -} - -.assignments-scroll-container::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; -} - -/* Стили для отдельных исполнителей */ -.assignment { - display: flex; - align-items: flex-start; - padding: 8px; - margin-bottom: 8px; - background: white; - border: 1px solid #e9ecef; - border-radius: 6px; - transition: all 0.2s; -} - -.assignment:hover { - background: #f8f9fa; - border-color: #dee2e6; -} - -.assignment:last-child { - margin-bottom: 0; -} -/* Добавить в стили */ -.kanban-section { - padding: 20px; -} - -.kanban-board { - background: #f5f5f5; - border-radius: 10px; - padding: 20px; - min-height: 70vh; -} - -.kanban-controls { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding: 15px; - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); -} - -.kanban-period { - display: flex; - align-items: center; - gap: 10px; -} - -.kanban-period label { - font-weight: bold; -} - -.kanban-period select { - padding: 8px 15px; - border: 2px solid #3498db; - border-radius: 5px; - background: white; - font-size: 14px; - cursor: pointer; -} - -.kanban-stats { - display: flex; - align-items: center; - gap: 15px; -} - -.kanban-stats span { - font-weight: bold; - color: #2c3e50; -} - -.refresh-btn { - padding: 8px 15px; - background: #3498db; - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 14px; -} - -.refresh-btn:hover { - background: #2980b9; -} - -.kanban-columns { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; - margin-top: 20px; -} - -.kanban-column { - background: white; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); - min-height: 500px; -} - -.kanban-column-header { - padding: 15px; - color: white; - display: flex; - justify-content: space-between; - align-items: center; -} - -.kanban-column-header h3 { - margin: 0; - font-size: 16px; -} - -.kanban-count { - background: rgba(255,255,255,0.3); - padding: 3px 10px; - border-radius: 20px; - font-weight: bold; -} - -.kanban-column-body { - padding: 15px; - min-height: 450px; - max-height: 600px; - overflow-y: auto; -} - -.kanban-empty { - text-align: center; - padding: 40px 20px; - color: #95a5a6; - font-style: italic; -} - -.kanban-card { - background: white; - border: 1px solid #e0e0e0; - border-radius: 6px; - padding: 15px; - margin-bottom: 15px; - cursor: move; - transition: all 0.3s ease; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); -} - -.kanban-card:hover { - box-shadow: 0 4px 8px rgba(0,0,0,0.1); - transform: translateY(-2px); - border-color: #3498db; -} - -.kanban-card.dragging { - opacity: 0.5; - transform: rotate(5deg); -} - -.kanban-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.kanban-task-id { - font-size: 12px; - color: #7f8c8d; - font-weight: bold; -} - -.kanban-task-actions { - display: flex; - gap: 5px; -} - -.kanban-task-actions button { - background: none; - border: none; - cursor: pointer; - font-size: 14px; - opacity: 0.7; -} - -.kanban-task-actions button:hover { - opacity: 1; -} - -.kanban-task-title { - font-weight: bold; - margin-bottom: 10px; - cursor: pointer; - color: #2c3e50; -} - -.kanban-task-title:hover { - color: #3498db; -} - -.kanban-task-info { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - font-size: 12px; -} - -.kanban-deadline { - display: flex; - align-items: center; - gap: 5px; -} - -.kanban-date { - color: #e74c3c; - font-weight: bold; -} - -.kanban-no-date { - color: #95a5a6; - font-style: italic; -} - -.kanban-assignees { - display: flex; - align-items: center; - gap: 5px; -} - -.kanban-assignee { - width: 24px; - height: 24px; - border-radius: 50%; - background: #3498db; - color: white; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: bold; -} - -.kanban-no-assignee { - opacity: 0.5; -} - -.kanban-more-assignees { - font-size: 10px; - color: #7f8c8d; - margin-left: 5px; -} - -.kanban-task-footer { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; - color: #7f8c8d; - border-top: 1px solid #f0f0f0; - padding-top: 8px; - margin-top: 8px; -} - -.kanban-creator { - display: flex; - align-items: center; - gap: 5px; -} - -.kanban-files { - display: flex; - align-items: center; - gap: 5px; -} - -/* Адаптивность */ -@media (max-width: 1200px) { - .kanban-columns { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 768px) { - .kanban-columns { - grid-template-columns: 1fr; - } - - .kanban-controls { - flex-direction: column; - gap: 15px; - align-items: stretch; - } -} -.error-message { - background: #ffebee; - border: 1px solid #f44336; - border-radius: 8px; - padding: 20px; - text-align: center; - color: #c62828; - margin: 20px; -} - -.retry-btn { - background: #4CAF50; - color: white; - border: none; - padding: 10px 20px; - border-radius: 4px; - cursor: pointer; - margin-top: 10px; - font-size: 14px; -} - -.retry-btn:hover { - background: #45a049; -} -.kanban-filters { - display: flex; - flex-wrap: wrap; - gap: 20px; - align-items: center; - margin-bottom: 20px; - padding: 15px; - background: #f8f9fa; - border-radius: 8px; - border: 1px solid #e9ecef; -} - -.kanban-period, .kanban-filter-type { - display: flex; - flex-direction: column; - gap: 5px; -} - -.kanban-period label, .kanban-filter-type label { - font-size: 12px; - color: #6c757d; - font-weight: 500; -} - -.kanban-period select, .kanban-filter-type select { - padding: 8px 12px; - border: 1px solid #dee2e6; - border-radius: 4px; - background: white; - font-size: 14px; - min-width: 150px; -} - -.kanban-stats { - display: flex; - align-items: center; - gap: 15px; - margin-left: auto; -} - -.filter-title { - font-weight: 600; - color: #495057; - background: #e9ecef; - padding: 6px 12px; - border-radius: 20px; - font-size: 14px; -} - -.task-count { - color: #6c757d; - font-size: 14px; -} - -.refresh-btn { - background: #6c757d; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - display: flex; - align-items: center; - gap: 5px; -} - -.refresh-btn:hover { - background: #5a6268; -} - -.kanban-task-role { - font-size: 16px; - cursor: help; - margin: 0 5px; -} -.kanban-column[data-status="overdue"] .kanban-column-body, -.kanban-column[data-status="assigned"] .kanban-column-body { - opacity: 0.6; - cursor: not-allowed !important; -} - -.kanban-column[data-status="overdue"] .kanban-card, -.kanban-column[data-status="assigned"] .kanban-card { - cursor: not-allowed !important; -} - -.kanban-column[data-status="overdue"] .kanban-card:hover, -.kanban-column[data-status="assigned"] .kanban-card:hover { - transform: none !important; - box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important; -} -/* Добавим в существующий style.css */ - -.profile-card { - background: white; - border-radius: 10px; - padding: 20px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - margin-bottom: 20px; -} - -.profile-card p { - margin: 10px 0; - padding: 5px 0; - border-bottom: 1px solid #eee; -} - -.profile-card p:last-child { - border-bottom: none; -} - -.notification-settings { - background: white; - border-radius: 10px; - padding: 20px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); -} - -.checkbox-label { - display: flex; - align-items: center; - margin: 10px 0; - cursor: pointer; -} - -.checkbox-label input[type="checkbox"] { - margin-right: 10px; - width: 18px; - height: 18px; -} - -.checkbox-label span { - font-weight: 500; -} - -#notification-email { - width: 100%; - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - margin-top: 5px; -} - -#notification-email:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); -} - -small { - color: #666; - font-size: 12px; - display: block; - margin-top: 5px; +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #333; + min-height: 100vh; +} + +.container { + max-width: 99%; + margin: 0 auto; + padding: 20px; + display: none; +} + +header { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 1.5rem; + margin-bottom: 25px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +header h1 { + margin-bottom: 1.2rem; + text-align: center; + color: #2c3e50; + font-size: 2.2rem; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.user-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.2rem; + padding: 12px 20px; + background: linear-gradient(135deg, #667eea, #764ba2); + border-radius: 10px; + color: white; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.user-info span { + font-weight: 600; + font-size: 1.1rem; +} + +.user-info button { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.user-info button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +nav { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +nav button { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; + border: none; + padding: 12px 24px; + border-radius: 10px; + cursor: pointer; + flex: 1; + min-width: 140px; + font-weight: 600; + font-size: 1rem; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); +} + +nav button:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); +} + +.section { + display: none; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + padding: 25px; + border-radius: 15px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + margin-bottom: 20px; +} + +.section.active { + display: block; + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #2c3e50; + font-size: 1rem; +} + +input[type="text"], +input[type="password"], +input[type="datetime-local"], +textarea, +select { + width: 100%; + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 10px; + font-size: 1rem; + transition: all 0.3s ease; + background: rgba(255, 255, 255, 0.9); +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="datetime-local"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); + transform: translateY(-2px); +} + +textarea { + resize: vertical; + min-height: 120px; + font-family: inherit; +} + +button { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; + padding: 12px 30px; + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(39, 174, 96, 0.3); +} + +button:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(39, 174, 96, 0.4); +} + +button.delete-btn { + background: linear-gradient(135deg, #e74c3c, #c0392b); + box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3); +} + +button.delete-btn:hover { + box-shadow: 0 6px 20px rgba(231, 76, 60, 0.4); +} + +button.edit-btn { + background: linear-gradient(135deg, #f39c12, #e67e22); + box-shadow: 0 4px 15px rgba(243, 156, 18, 0.3); +} + +button.edit-btn:hover { + box-shadow: 0 6px 20px rgba(243, 156, 18, 0.4); +} + +button.copy-btn { + background: linear-gradient(135deg, #3498db, #2980b9); + box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); +} + +button.copy-btn:hover { + box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); +} + +button.restore-btn { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + box-shadow: 0 4px 15px rgba(155, 89, 182, 0.3); +} + +button.restore-btn:hover { + box-shadow: 0 6px 20px rgba(155, 89, 182, 0.4); +} + +button.edit-date-btn { + background: linear-gradient(135deg, #17a2b8, #138496); + padding: 6px 12px; + font-size: 0.85rem; + box-shadow: 0 2px 8px rgba(23, 162, 184, 0.3); +} + +button.edit-date-btn:hover { + box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4); +} + +#tasks-controls { + margin-bottom: 20px; + padding: 15px 20px; + background: linear-gradient(135deg, #ecf0f1, #bdc3c7); + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +#tasks-controls label { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-weight: normal; + color: #2c3e50; + cursor: pointer; +} + +#tasks-controls input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.task-card { + border: none; + border-radius: 15px; + padding: 0; + margin-bottom: 20px; + background: rgba(255, 255, 255, 0.9); + position: relative; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); + border-left: 5px solid #3498db; +} + +.task-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.task-card.deleted { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + border-left-color: #e74c3c; + opacity: 0.8; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0; + gap: 20px; +} + +.task-title { + font-size: 1.2rem; + font-weight: 700; + color: #2c3e50; + flex: 1; + line-height: 1.4; + cursor: pointer; + padding: 15px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(52, 152, 219, 0.1), rgba(52, 152, 219, 0.05)); + transition: all 0.3s ease; +} + +.task-title:hover { + background: linear-gradient(135deg, rgba(52, 152, 219, 0.15), rgba(52, 152, 219, 0.1)); + transform: translateY(-2px); +} + +.task-status { + padding: 8px 16px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.status-purple { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + color: white; +} +.status-red { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; +} +.status-orange { + background: linear-gradient(135deg, #f39c12, #e67e22); + color: white; +} +.status-green { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; +} +.status-gray { + background: linear-gradient(135deg, #95a5a6, #7f8c8d); + color: white; +} +.status-darkred { + background: linear-gradient(135deg, #8b0000, #660000); + color: white; +} + +.deleted-badge { + background: #e74c3c; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.copy-badge { + background: #3498db; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.role-badge { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 15px; + margin-left: 8px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.role-admin { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; +} +.role-creator { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; +} +.role-executor { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; +} + +.task-original { + margin: 10px 0; + padding: 10px 15px; + background: linear-gradient(135deg, #e8f4fd, #d4e6f1); + border-radius: 8px; + border-left: 4px solid #3498db; +} + +.task-original small { + color: #2c3e50; + font-style: italic; + font-size: 0.9rem; +} + +.task-description { + margin: 15px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + line-height: 1.6; + color: #495057; +} + +.task-dates-files { + margin: 15px 0; + padding: 15px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 10px; + border-left: 4px solid #f39c12; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.task-dates-files .task-dates { + flex: 1; + min-width: 250px; + font-size: 0.95rem; + color: #856404; +} + +.task-dates-files .file-list { + flex: 1; + min-width: 250px; + font-size: 0.95rem; + color: #856404; +} + +.task-dates-files .file-list strong { + display: block; + margin-bottom: 5px; +} + +.task-dates-files .files-placeholder { + color: #6c757d; + font-style: italic; +} + +.task-dates-files strong { + color: #e67e22; +} + +.task-assignments { + margin: 20px 0; +} + +.assignment { + display: flex; + align-items: center; + margin: 10px 0; + padding: 12px 15px; + border-radius: 10px; + background: rgba(248, 249, 250, 0.8); + transition: all 0.3s ease; + border: 1px solid #e9ecef; +} + +.assignment:hover { + background: rgba(233, 236, 239, 0.8); + transform: translateX(5px); +} + +.assignment.overdue { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + border: 1px solid #e74c3c; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); } + 70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); } + 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } +} + +.assignment-status { + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 15px; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.status-assigned { + background: linear-gradient(135deg, #e74c3c, #c0392b); +} +.status-in_progress { + background: linear-gradient(135deg, #f39c12, #e67e22); +} +.status-completed { + background: linear-gradient(135deg, #27ae60, #219a52); +} +.status-overdue { + background: linear-gradient(135deg, #8b0000, #660000); +} + +.assignment-dates { + margin-top: 8px; + font-size: 0.85rem; + color: #6c757d; +} + +.assignment-dates small { + display: block; + margin: 3px 0; +} + +.action-buttons { + margin-top: 12px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.action-buttons button { + margin-right: 0; + padding: 8px 16px; + font-size: 0.9rem; + border-radius: 8px; +} + +.task-actions { + position: absolute; + bottom: 20px; + right: 20px; + display: flex; + gap: 8px; +} + +.task-actions button { + padding: 8px 12px; + font-size: 1rem; + border-radius: 8px; + min-width: auto; +} + +.file-list { + margin-top: 15px; + padding: 15px; + background: #f8f9fa; + border-radius: 10px; +} + +.file-item { + display: flex; + align-items: center; + padding: 10px; + background: white; + margin-bottom: 8px; + border-radius: 8px; + border: 1px solid #e9ecef; + transition: all 0.3s ease; +} + +.file-item:hover { + transform: translateX(5px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.file-item a { + color: #3498db; + text-decoration: none; + margin-right: 15px; + font-weight: 500; + transition: color 0.3s ease; +} + +.file-item a:hover { + color: #2980b9; + text-decoration: underline; +} + +.file-item small { + color: #6c757d; + margin-left: auto; + font-size: 0.85rem; +} + +.task-meta { + margin-top: 15px; + padding-top: 15px; + border-top: 2px solid #e9ecef; +} + +.task-meta small { + color: #6c757d; + font-size: 0.9rem; +} + +.log-entry { + padding: 15px; + border-bottom: 1px solid #e9ecef; + font-size: 0.95rem; + transition: all 0.3s ease; +} + +.log-entry:hover { + background: #f8f9fa; + border-radius: 8px; +} + +.log-time { + color: #6c757d; + font-size: 0.85rem; + margin-bottom: 5px; +} + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(5px); + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + margin: 8% auto; + padding: 30px; + border-radius: 20px; + width: 90%; + max-width: 600px; + max-height: 85vh; + overflow-y: auto; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.3); + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { transform: translateY(-50px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.close { + color: #6c757d; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + position: absolute; + right: 20px; + top: 15px; + transition: all 0.3s ease; +} + +.close:hover { + color: #e74c3c; + transform: rotate(90deg); +} + +.checkbox-group { + max-height: 250px; + overflow-y: auto; + border: 2px solid #e9ecef; + padding: 15px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.8); +} + +.checkbox-item { + margin: 8px 0; + padding: 8px 12px; + border-radius: 8px; + transition: all 0.3s ease; +} + +.checkbox-item:hover { + background: rgba(52, 152, 219, 0.1); + transform: translateX(5px); +} + +.checkbox-item label { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + font-weight: normal; + cursor: pointer; + color: #495057; +} + +.checkbox-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +#login-modal .modal-content { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.85)); + text-align: center; + max-width: 500px; +} + +#login-form .form-group { + display: flex; + flex-direction: column; + align-items: center; +} + +#login-form input[type="text"], +#login-form input[type="password"] { + width: 100%; + max-width: 300px; + margin: 0 auto; + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 10px; + font-size: 1rem; + transition: all 0.3s ease; +} + +#login-form input[type="text"]:focus, +#login-form input[type="password"]:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); + transform: translateY(-2px); +} + +.test-users { + margin-top: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border-radius: 15px; + border-left: 5px solid #3498db; +} + +.test-users h3 { + margin-bottom: 15px; + color: #2c3e50; + font-size: 1.2rem; +} + +.test-users ul { + list-style: none; + padding: 0; +} + +.test-users li { + padding: 8px 0; + border-bottom: 1px solid #dee2e6; + color: #495057; +} + +.test-users li:last-child { + border-bottom: none; +} + +.test-users strong { + color: #2c3e50; +} + +.loading { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-size: 1.1rem; +} + +.error { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + color: #721c24; + padding: 15px 20px; + border-radius: 10px; + margin: 15px 0; + border: 1px solid #f5c6cb; + border-left: 5px solid #e74c3c; +} + +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .task-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .task-actions { + position: static; + margin-top: 15px; + justify-content: center; + width: 100%; + } + + nav { + flex-direction: column; + } + + .modal-content { + width: 95%; + margin: 5% auto; + padding: 20px; + } + + header h1 { + font-size: 1.8rem; + } + + .user-info { + flex-direction: column; + gap: 10px; + text-align: center; + } + + .assignment { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .action-buttons { + width: 100%; + justify-content: flex-start; + } + + #login-form input[type="text"], + #login-form input[type="password"] { + max-width: 100%; + } +} + +@media (max-width: 480px) { + .section { + padding: 15px; + } + + .task-card { + padding: 15px; + } + + .task-title { + font-size: 1.2rem; + } + + .task-status { + font-size: 0.8rem; + padding: 6px 12px; + } + + .task-actions { + gap: 5px; + } + + .task-actions button { + padding: 8px 12px; + font-size: 0.8rem; + } + + nav button { + padding: 10px 15px; + font-size: 0.9rem; + } +} + +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, #3498db, #2980b9); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, #2980b9, #1f618d); +} + +button:focus, +input:focus, +textarea:focus, +select:focus { + outline: 2px solid #3498db; + outline-offset: 2px; +} + +@media print { + .task-actions, + nav, + .user-info button { + display: none !important; + } + + .task-card { + break-inside: avoid; + box-shadow: none; + border: 1px solid #ddd; + } +} + +.filters { + display: flex; + gap: 20px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.filter-group label { + font-weight: 600; + color: #2c3e50; + margin-bottom: 0; +} + +.filter-group input, +.filter-group select { + padding: 8px 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 0.9rem; +} + +.user-search { + margin-bottom: 10px; +} + +.user-search input { + width: 100%; + padding: 8px 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 0.9rem; +} + +.status-yellow { + background: linear-gradient(135deg, #ffc107, #e0a800); + color: white; +} + +.task-card.closed { + background: linear-gradient(135deg, #e9ecef, #dee2e6); + border-left-color: #6c757d; + opacity: 0.8; +} + +.closed-badge { + background: #6c757d; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.rework-comment { + margin: 10px 0; + padding: 12px 15px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 8px; + border-left: 4px solid #ffc107; + color: #856404; +} + +.assignment-rework-comment { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border-radius: 6px; + border-left: 3px solid #ffc107; +} + +.assignment.rework { + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border: 1px solid #ffc107; +} + +button.rework-btn { + background: linear-gradient(135deg, #ffc107, #e0a800); + box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3); +} + +button.rework-btn:hover { + box-shadow: 0 6px 20px rgba(255, 193, 7, 0.4); +} + +button.close-btn { + background: linear-gradient(135deg, #6c757d, #5a6268); + box-shadow: 0 4px 15px rgba(108, 117, 125, 0.3); +} + +button.close-btn:hover { + box-shadow: 0 6px 20px rgba(108, 117, 125, 0.4); +} + +button.reopen-btn { + background: linear-gradient(135deg, #20c997, #1ea085); + box-shadow: 0 4px 15px rgba(32, 201, 151, 0.3); +} + +button.reopen-btn:hover { + box-shadow: 0 6px 20px rgba(32, 201, 151, 0.4); +} + +.show-deleted-label { + display: flex; + align-items: center; + gap: 8px; + font-weight: normal; + color: #2c3e50; + cursor: pointer; +} + +.show-deleted-label input { + margin: 0; +} + +.show-deleted-label[style*="display: none"] { + display: none !important; +} + +.deadline-badge { + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.deadline-48h { + background: linear-gradient(135deg, #ffc107, #e0a800); + color: #856404; +} + +.deadline-24h { + background: linear-gradient(135deg, #fd7e14, #e8590c); + color: white; +} + +.deadline-indicator { + padding: 2px 8px; + border-radius: 12px; + font-size: 0.7rem; + margin-left: 8px; + font-weight: 600; +} + +.task-title .deadline-badge { + vertical-align: middle; +} + +.filter-group select { + min-width: 150px; +} + +.filters { + gap: 15px; + margin-bottom: 15px; +} + +@media (max-width: 768px) { + .filters { + flex-direction: column; + gap: 10px; + } + + .filter-group select { + width: 100%; + } +} + +.admin-container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; + display: none; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e9ecef; +} + +.admin-tabs { + display: flex; + gap: 10px; + margin-bottom: 20px; + border-bottom: 2px solid #e9ecef; + padding-bottom: 10px; +} + +.admin-tab { + padding: 10px 20px; + background: #f8f9fa; + border: none; + border-radius: 8px 8px 0 0; + cursor: pointer; + font-weight: 600; + color: #495057; + transition: all 0.3s ease; +} + +.admin-tab:hover { + background: #e9ecef; +} + +.admin-tab.active { + background: #3498db; + color: white; +} + +.admin-section { + display: none; + padding: 20px; + background: white; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.admin-section.active { + display: block; + animation: fadeIn 0.3s ease; +} + +.users-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.users-table th, +.users-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #e9ecef; +} + +.users-table th { + background: #f8f9fa; + font-weight: 600; + color: #495057; +} + +.users-table tr:hover { + background: #f8f9fa; +} + +.user-actions { + display: flex; + gap: 8px; +} + +.user-actions button { + padding: 6px 12px; + font-size: 0.85rem; + border-radius: 6px; +} + +.ldap-badge { + background: #3498db; + color: white; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + margin-left: 5px; +} + +.admin-badge { + background: #e74c3c; + color: white; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + margin-left: 5px; +} + +.form-row { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.form-row .form-group { + flex: 1; +} + +.password-fields { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid #3498db; +} + +.modal-lg { + max-width: 800px; +} + +.search-container { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.search-container input { + flex: 1; +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background: white; + padding: 20px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + border-left: 4px solid #3498db; +} + +.stat-card h3 { + margin: 0 0 10px 0; + color: #495057; + font-size: 1.1rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: #2c3e50; + margin-bottom: 10px; +} + +.stat-desc { + color: #6c757d; + font-size: 0.9rem; +} + +.stat-card.admin-stat { + border-left-color: #e74c3c; +} + +.stat-card.ldap-stat { + border-left-color: #2ecc71; +} + +.stat-card.local-stat { + border-left-color: #f39c12; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.error { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + color: #721c24; + padding: 15px 20px; + border-radius: 10px; + margin: 15px 0; + border: 1px solid #f5c6cb; + border-left: 5px solid #e74c3c; + text-align: center; +} + +.task-number { + background: #3498db; + color: white; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.8rem; + margin-right: 10px; + font-weight: bold; +} + +.task-header .task-title .expand-icon { + font-size: 0.8rem; + color: #6c757d; + margin-left: auto; + padding-left: 15px; +} + +.task-content { + padding: 20px; + border-top: 1px solid #e9ecef; + margin-top: 10px; + animation: fadeIn 0.3s ease; + overflow: hidden; + transition: max-height 0.3s ease; + max-height: 0; +} + +.task-content.expanded { + max-height: 2000px; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.tasks-no-date-btn { + background: linear-gradient(135deg, #17a2b8, #138496); + box-shadow: 0 4px 15px rgba(23, 162, 184, 0.3); +} + +.tasks-no-date-btn.active { + background: linear-gradient(135deg, #138496, #117a8b); + box-shadow: 0 6px 20px rgba(23, 162, 184, 0.4); +} + +.no-date-badge { + background: linear-gradient(135deg, #6c757d, #5a6268); + color: white; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + margin-left: 10px; + font-weight: 600; +} + +@media (max-width: 768px) { + .task-dates-files { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .task-dates-files .task-dates, + .task-dates-files .file-list { + min-width: 100%; + } +} +/* Стили для иконок файлов */ +.file-icons-container { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 10px; +} + +.file-icon-container { + display: flex; + flex-direction: column; + align-items: center; + width: 80px; + cursor: pointer; + transition: transform 0.2s ease; + position: relative; +} + +.file-icon-container:hover { + transform: translateY(-5px); +} + +.file-icon { + width: 60px; + height: 60px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.file-icon:hover { + transform: scale(1.05); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); +} + +.file-icon::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 20px; + background: rgba(255, 255, 255, 0.2); +} + +.file-extension { + color: white; + font-size: 0.8rem; + font-weight: bold; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); + z-index: 1; +} + +.file-name { + margin-top: 5px; + font-size: 0.75rem; + color: #495057; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.2s ease; +} + +.file-download-link { + text-decoration: none; + color: inherit; + width: 100%; +} + +.file-download-link:hover .file-name { + color: #3498db; + text-decoration: underline; +} + +/* Тултип при наведении */ +.file-icon-container::before { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 100; + pointer-events: none; +} + +.file-icon-container:hover::before { + opacity: 1; + visibility: visible; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .file-icons-container { + gap: 10px; + justify-content: center; + } + + .file-icon-container { + width: 70px; + } + + .file-icon { + width: 50px; + height: 50px; + } + + .file-extension { + font-size: 0.7rem; + } + + .file-name { + font-size: 0.7rem; + } +} + +@media (max-width: 480px) { + .file-icons-container { + gap: 8px; + } + + .file-icon-container { + width: 60px; + } + + .file-icon { + width: 45px; + height: 45px; + } + + .file-extension { + font-size: 0.65rem; + } + + .file-name { + font-size: 0.65rem; + } +} +.no-files { + color: #6c757d; + font-style: italic; + font-size: 0.9rem; +} + +/* Сделаем файлы видимыми всегда */ +.task-dates-files .file-list { + flex: 1; + min-width: 250px; + font-size: 0.95rem; + color: #856404; +} + +.task-dates-files .file-list strong { + display: block; + margin-bottom: 10px; +} + +/* Уменьшим отступы для компактности */ +.task-dates-files { + margin: 10px 0; + padding: 12px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 10px; + border-left: 4px solid #f39c12; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 15px; +} +/* Стили для иконок файлов */ +.file-icons-container { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 10px; +} + +.file-icon-container { + display: flex; + flex-direction: column; + align-items: center; + width: 80px; + cursor: pointer; + transition: transform 0.2s ease; + position: relative; +} + +.file-icon-container:hover { + transform: translateY(-5px); +} + +.file-icon { + width: 60px; + height: 60px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.file-icon:hover { + transform: scale(1.05); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); +} + +.file-icon::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 20px; + background: rgba(255, 255, 255, 0.2); +} + +.file-extension { + color: white; + font-size: 0.8rem; + font-weight: bold; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); + z-index: 1; + padding: 0 5px; + text-align: center; + line-height: 1.2; + max-width: 100%; + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Для коротких текстов (1-2 символа) */ +.file-extension.short { + font-size: 1rem; +} + +/* Для средних текстов (3-4 символа) */ +.file-extension.medium { + font-size: 0.9rem; +} + +/* Для длинных текстов (5+ символов) */ +.file-extension.long { + font-size: 0.7rem; +} + +.file-name { + margin-top: 5px; + font-size: 0.75rem; + color: #495057; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.2s ease; +} + +.file-download-link { + text-decoration: none; + color: inherit; + width: 100%; +} + +.file-download-link:hover .file-name { + color: #3498db; + text-decoration: underline; +} + +/* Тултип при наведении */ +.file-icon-container::before { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 100; + pointer-events: none; +} + +.file-icon-container:hover::before { + opacity: 1; + visibility: visible; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .file-icons-container { + gap: 10px; + justify-content: center; + } + + .file-icon-container { + width: 70px; + } + + .file-icon { + width: 50px; + height: 50px; + } + + .file-extension { + font-size: 0.7rem; + } + + .file-extension.short { + font-size: 0.9rem; + } + + .file-extension.medium { + font-size: 0.8rem; + } + + .file-extension.long { + font-size: 0.6rem; + } + + .file-name { + font-size: 0.7rem; + } +} + +@media (max-width: 480px) { + .file-icons-container { + gap: 8px; + } + + .file-icon-container { + width: 60px; + } + + .file-icon { + width: 45px; + height: 45px; + } + + .file-extension { + font-size: 0.65rem; + } + + .file-extension.short { + font-size: 0.8rem; + } + + .file-extension.medium { + font-size: 0.7rem; + } + + .file-extension.long { + font-size: 0.55rem; + } + + .file-name { + font-size: 0.65rem; + } +} +/* Добавьте эти стили в style.css */ + +.assignments-container { + margin-top: 10px; + border: 1px solid #e1e5e9; + border-radius: 8px; + padding: 10px; + background: #f8fafc; +} + +.assignments-filter { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid #e1e5e9; +} + +.assignment-filter-input { + flex: 1; + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 14px; + margin-right: 10px; +} + +.assignment-filter-input:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); +} + +.filter-count { + font-size: 12px; + color: #6c757d; + background: #e9ecef; + padding: 2px 8px; + border-radius: 12px; + white-space: nowrap; +} + +.assignments-scroll-container { + max-height: 300px; + overflow-y: auto; + padding-right: 5px; +} + +/* Стили для скроллбара */ +.assignments-scroll-container::-webkit-scrollbar { + width: 8px; +} + +.assignments-scroll-container::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +.assignments-scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +.assignments-scroll-container::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Стили для отдельных исполнителей */ +.assignment { + display: flex; + align-items: flex-start; + padding: 8px; + margin-bottom: 8px; + background: white; + border: 1px solid #e9ecef; + border-radius: 6px; + transition: all 0.2s; +} + +.assignment:hover { + background: #f8f9fa; + border-color: #dee2e6; +} + +.assignment:last-child { + margin-bottom: 0; +} +/* Добавить в стили */ +.kanban-section { + padding: 20px; +} + +.kanban-board { + background: #f5f5f5; + border-radius: 10px; + padding: 20px; + min-height: 70vh; +} + +.kanban-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 15px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.kanban-period { + display: flex; + align-items: center; + gap: 10px; +} + +.kanban-period label { + font-weight: bold; +} + +.kanban-period select { + padding: 8px 15px; + border: 2px solid #3498db; + border-radius: 5px; + background: white; + font-size: 14px; + cursor: pointer; +} + +.kanban-stats { + display: flex; + align-items: center; + gap: 15px; +} + +.kanban-stats span { + font-weight: bold; + color: #2c3e50; +} + +.refresh-btn { + padding: 8px 15px; + background: #3498db; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; +} + +.refresh-btn:hover { + background: #2980b9; +} + +.kanban-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.kanban-column { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + min-height: 500px; +} + +.kanban-column-header { + padding: 15px; + color: white; + display: flex; + justify-content: space-between; + align-items: center; +} + +.kanban-column-header h3 { + margin: 0; + font-size: 16px; +} + +.kanban-count { + background: rgba(255,255,255,0.3); + padding: 3px 10px; + border-radius: 20px; + font-weight: bold; +} + +.kanban-column-body { + padding: 15px; + min-height: 450px; + max-height: 600px; + overflow-y: auto; +} + +.kanban-empty { + text-align: center; + padding: 40px 20px; + color: #95a5a6; + font-style: italic; +} + +.kanban-card { + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 15px; + margin-bottom: 15px; + cursor: move; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.kanban-card:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + transform: translateY(-2px); + border-color: #3498db; +} + +.kanban-card.dragging { + opacity: 0.5; + transform: rotate(5deg); +} + +.kanban-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.kanban-task-id { + font-size: 12px; + color: #7f8c8d; + font-weight: bold; +} + +.kanban-task-actions { + display: flex; + gap: 5px; +} + +.kanban-task-actions button { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + opacity: 0.7; +} + +.kanban-task-actions button:hover { + opacity: 1; +} + +.kanban-task-title { + font-weight: bold; + margin-bottom: 10px; + cursor: pointer; + color: #2c3e50; +} + +.kanban-task-title:hover { + color: #3498db; +} + +.kanban-task-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 12px; +} + +.kanban-deadline { + display: flex; + align-items: center; + gap: 5px; +} + +.kanban-date { + color: #e74c3c; + font-weight: bold; +} + +.kanban-no-date { + color: #95a5a6; + font-style: italic; +} + +.kanban-assignees { + display: flex; + align-items: center; + gap: 5px; +} + +.kanban-assignee { + width: 24px; + height: 24px; + border-radius: 50%; + background: #3498db; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: bold; +} + +.kanban-no-assignee { + opacity: 0.5; +} + +.kanban-more-assignees { + font-size: 10px; + color: #7f8c8d; + margin-left: 5px; +} + +.kanban-task-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: #7f8c8d; + border-top: 1px solid #f0f0f0; + padding-top: 8px; + margin-top: 8px; +} + +.kanban-creator { + display: flex; + align-items: center; + gap: 5px; +} + +.kanban-files { + display: flex; + align-items: center; + gap: 5px; +} + +/* Адаптивность */ +@media (max-width: 1200px) { + .kanban-columns { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .kanban-columns { + grid-template-columns: 1fr; + } + + .kanban-controls { + flex-direction: column; + gap: 15px; + align-items: stretch; + } +} +.error-message { + background: #ffebee; + border: 1px solid #f44336; + border-radius: 8px; + padding: 20px; + text-align: center; + color: #c62828; + margin: 20px; +} + +.retry-btn { + background: #4CAF50; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + margin-top: 10px; + font-size: 14px; +} + +.retry-btn:hover { + background: #45a049; +} +.kanban-filters { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.kanban-period, .kanban-filter-type { + display: flex; + flex-direction: column; + gap: 5px; +} + +.kanban-period label, .kanban-filter-type label { + font-size: 12px; + color: #6c757d; + font-weight: 500; +} + +.kanban-period select, .kanban-filter-type select { + padding: 8px 12px; + border: 1px solid #dee2e6; + border-radius: 4px; + background: white; + font-size: 14px; + min-width: 150px; +} + +.kanban-stats { + display: flex; + align-items: center; + gap: 15px; + margin-left: auto; +} + +.filter-title { + font-weight: 600; + color: #495057; + background: #e9ecef; + padding: 6px 12px; + border-radius: 20px; + font-size: 14px; +} + +.task-count { + color: #6c757d; + font-size: 14px; +} + +.refresh-btn { + background: #6c757d; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 5px; +} + +.refresh-btn:hover { + background: #5a6268; +} + +.kanban-task-role { + font-size: 16px; + cursor: help; + margin: 0 5px; +} +.kanban-column[data-status="overdue"] .kanban-column-body, +.kanban-column[data-status="assigned"] .kanban-column-body { + opacity: 0.6; + cursor: not-allowed !important; +} + +.kanban-column[data-status="overdue"] .kanban-card, +.kanban-column[data-status="assigned"] .kanban-card { + cursor: not-allowed !important; +} + +.kanban-column[data-status="overdue"] .kanban-card:hover, +.kanban-column[data-status="assigned"] .kanban-card:hover { + transform: none !important; + box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important; +} +/* Добавим в существующий style.css */ + +.profile-card { + background: white; + border-radius: 10px; + padding: 20px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + margin-bottom: 20px; +} + +.profile-card p { + margin: 10px 0; + padding: 5px 0; + border-bottom: 1px solid #eee; +} + +.profile-card p:last-child { + border-bottom: none; +} + +.notification-settings { + background: white; + border-radius: 10px; + padding: 20px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.checkbox-label { + display: flex; + align-items: center; + margin: 10px 0; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + margin-right: 10px; + width: 18px; + height: 18px; +} + +.checkbox-label span { + font-weight: 500; +} + +#notification-email { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + margin-top: 5px; +} + +#notification-email:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +small { + color: #666; + font-size: 12px; + display: block; + margin-top: 5px; } \ No newline at end of file diff --git a/server.js b/server.js index c8677af..b34f3d7 100644 --- a/server.js +++ b/server.js @@ -1,861 +1,861 @@ -// server.js -const express = require('express'); -const multer = require('multer'); -const path = require('path'); -const fs = require('fs'); -const session = require('express-session'); -require('dotenv').config(); - -// Импортируем модули -const { initializeDatabase, getDb, isInitialized } = require('./database'); -const authService = require('./auth'); -const postgresLogger = require('./postgres'); -const { sendTaskNotifications, checkUpcomingDeadlines, getStatusText } = require('./notifications'); -const { setupUploadMiddleware } = require('./upload-middleware'); -const { setupTaskEndpoints } = require('./task-endpoints'); - -const app = express(); -const PORT = process.env.PORT || 3000; - -// Глобальные переменные -let db = null; -let serverReady = false; -let adminRouter = null; -let upload = null; - -// Middleware -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(express.static('public')); -app.use('/uploads', express.static(path.join(__dirname, 'data', 'uploads'))); -app.use(session({ - secret: process.env.SESSION_SECRET || 'fallback_secret_change_in_production', - resave: true, - saveUninitialized: false, - cookie: { - secure: false, - maxAge: 24 * 60 * 60 * 1000, - httpOnly: true - } -})); - -// Middleware для проверки готовности сервера -app.use((req, res, next) => { - if (!serverReady && req.path !== '/health' && req.path !== '/api/health') { - return res.status(503).json({ - error: 'Сервер запускается...', - status: 'initializing' - }); - } - next(); -}); - -// Health check endpoints -app.get('/health', (req, res) => { - res.json({ - status: serverReady ? 'ready' : 'initializing', - database: isInitialized() ? 'connected' : 'connecting', - auth: authService.isReady() ? 'ready' : 'waiting', - timestamp: new Date().toISOString() - }); -}); - -app.get('/api/health', (req, res) => { - res.json({ - status: serverReady ? 'ready' : 'initializing', - database: isInitialized() ? 'connected' : 'connecting', - auth: authService.isReady() ? 'ready' : 'waiting', - timestamp: new Date().toISOString() - }); -}); - -// Вспомогательные функции -const createDirIfNotExists = (dirPath) => { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } -}; - -function checkIfOverdue(dueDate, status) { - if (!dueDate || status === 'completed') return false; - const now = new Date(); - const due = new Date(dueDate); - return due < now; -} - -function checkOverdueTasks() { - if (!db) { - console.error('❌ База данных не доступна для проверки просроченных задач'); - return; - } - - const now = new Date().toISOString(); - - const query = ` - SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date - FROM task_assignments ta - JOIN tasks t ON ta.task_id = t.id - WHERE ta.due_date IS NOT NULL - AND ta.due_date < ? - AND ta.status NOT IN ('completed', 'overdue') - AND t.status = 'active' - AND t.closed_at IS NULL - `; - - db.all(query, [now], (err, assignments) => { - if (err) { - console.error('❌ Ошибка при проверке просроченных задач:', err); - return; - } - - assignments.forEach(assignment => { - db.run( - "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", - [assignment.id] - ); - - const { logActivity } = require('./database'); - if (logActivity) { - logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); - } - }); - }); -} - -// Middleware для аутентификации -const requireAuth = (req, res, next) => { - if (!req.session.user) { - return res.status(401).json({ error: 'Требуется аутентификация' }); - } - next(); -}; - -// API для аутентификации -app.post('/api/login', async (req, res) => { - const { login, password } = req.body; - - if (!login || !password) { - return res.status(400).json({ error: 'Логин и пароль обязательны' }); - } - - try { - const user = await authService.authenticate(login, password); - if (user) { - const sessionUser = { - id: user.id, - login: user.login, - name: user.name, - email: user.email, - role: user.role, - auth_type: user.auth_type, - groups: user.groups ? (typeof user.groups === 'string' ? JSON.parse(user.groups) : user.groups) : [] - }; - - req.session.user = sessionUser; - - req.session.save((err) => { - if (err) { - console.error('❌ Ошибка сохранения сессии:', err); - return res.status(500).json({ error: 'Ошибка сохранения сессии' }); - } - - console.log(`✅ Успешная авторизация: ${user.name} (${user.login}) через ${user.auth_type}`); - if (user.groups) { - console.log(`Группы пользователя: ${user.groups}`); - } - - res.json({ - success: true, - user: sessionUser - }); - }); - } else { - console.log(`❌ Неудачная попытка входа: ${login}`); - res.status(401).json({ error: 'Неверный логин или пароль' }); - } - } catch (error) { - console.error('❌ Ошибка аутентификации:', error.message); - res.status(500).json({ - error: 'Ошибка сервера при авторизации', - details: error.message - }); - } -}); - -app.post('/api/logout', (req, res) => { - req.session.destroy((err) => { - if (err) { - console.error('❌ Ошибка при выходе:', err); - return res.status(500).json({ error: 'Ошибка при выходе' }); - } - res.json({ success: true }); - }); -}); - -app.get('/api/user', (req, res) => { - if (req.session.user) { - if (req.session.user.auth_type === 'ldap') { - if (!db) { - return res.status(503).json({ error: 'База данных не готова' }); - } - - db.get("SELECT groups FROM users WHERE id = ?", [req.session.user.id], (err, user) => { - if (err || !user) { - req.session.destroy(); - return res.status(401).json({ error: 'Пользователь не найден' }); - } - - let groups = []; - try { - groups = JSON.parse(user.groups || '[]'); - } catch (e) { - groups = []; - } - - const allowedGroups = process.env.ALLOWED_GROUPS ? - process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : []; - - const isAdmin = groups.some(group => allowedGroups.includes(group)); - const actualRole = isAdmin ? 'admin' : 'teacher'; - - if (req.session.user.role !== actualRole) { - console.log(`Обновлена роль пользователя ${req.session.user.login} с ${req.session.user.role} на ${actualRole}`); - - db.run( - "UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?", - [actualRole, req.session.user.id] - ); - - req.session.user.role = actualRole; - } - - res.json({ user: req.session.user }); - }); - } else { - res.json({ user: req.session.user }); - } - } else { - res.status(401).json({ error: 'Не аутентифицирован' }); - } -}); - -// Middleware для проверки наличия БД в API endpoints -app.use((req, res, next) => { - if (!db && req.path.startsWith('/api/') && req.path !== '/api/health' && req.path !== '/api/login') { - return res.status(503).json({ error: 'База данных не готова' }); - } - next(); -}); - -// API для пользователей -app.get('/api/users', requireAuth, (req, res) => { - const search = req.query.search || ''; - - let query = ` - SELECT id, login, name, email, role, auth_type - FROM users - WHERE role IN ('admin', 'teacher') - `; - - const params = []; - - if (search) { - query += ` AND (login LIKE ? OR name LIKE ? OR email LIKE ?)`; - const searchPattern = `%${search}%`; - params.push(searchPattern, searchPattern, searchPattern); - } - - query += " ORDER BY name"; - - db.all(query, params, (err, rows) => { - if (err) { - res.status(500).json({ error: err.message }); - return; - } - res.json(rows); - }); -}); - -// API для файлов -app.get('/api/tasks/:taskId/files', requireAuth, (req, res) => { - const { taskId } = req.params; - const userId = req.session.user.id; - - const { checkTaskAccess } = require('./database'); - - checkTaskAccess(userId, taskId, (err, hasAccess) => { - if (err || !hasAccess) { - return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); - } - - db.all(` - SELECT tf.*, u.name as user_name, u.login as user_login - FROM task_files tf - LEFT JOIN users u ON tf.user_id = u.id - WHERE tf.task_id = ? - ORDER BY tf.uploaded_at DESC - `, [taskId], (err, files) => { - if (err) { - res.status(500).json({ error: err.message }); - return; - } - res.json(files); - }); - }); -}); - -app.get('/api/files/:fileId/download', requireAuth, (req, res) => { - const { fileId } = req.params; - const userId = req.session.user.id; - - db.get("SELECT tf.*, t.id as task_id FROM task_files tf JOIN tasks t ON tf.task_id = t.id WHERE tf.id = ?", [fileId], (err, file) => { - if (err || !file) { - return res.status(404).json({ error: 'Файл не найдена' }); - } - - const { checkTaskAccess } = require('./database'); - - checkTaskAccess(userId, file.task_id, (err, hasAccess) => { - if (err || !hasAccess) { - return res.status(404).json({ error: 'Файл не найден или у вас нет прав доступа' }); - } - - if (!fs.existsSync(file.file_path)) { - return res.status(404).json({ error: 'Файл не найден на сервере' }); - } - - // Исправляем кодировку имени файла - let decodedFileName = file.original_name; - - // Пробуем декодировать если это UTF-8 в Latin-1 (для старых записей) - try { - if (/^[A-Za-z0-9\.\-_]+$/.test(decodedFileName)) { - // Если имя содержит только латинские символы, оставляем как есть - } else if (decodedFileName.includes('Ð') || decodedFileName.includes('Ñ')) { - // Исправляем неправильно декодированную кириллицу - decodedFileName = Buffer.from(decodedFileName, 'binary').toString('utf8'); - } - } catch (e) { - console.error('Ошибка декодирования имени файла:', e); - } - - // Кодируем имя файла для безопасной передачи - const encodedFileName = encodeURIComponent(decodedFileName); - - // Устанавливаем заголовки для скачивания - res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); - res.setHeader('Content-Type', 'application/octet-stream'); - - // Отправляем файл - res.sendFile(file.file_path); - }); - }); -}); - -// API для логов активности -app.get('/api/activity-logs', requireAuth, (req, res) => { - const userId = req.session.user.id; - - let query = ` - SELECT al.*, u.name as user_name, t.title as task_title - FROM activity_logs al - LEFT JOIN users u ON al.user_id = u.id - LEFT JOIN tasks t ON al.task_id = t.id - WHERE 1=1 - `; - - if (req.session.user.role !== 'admin') { - query += ` AND (t.created_by = ${userId} OR al.task_id IN ( - SELECT task_id FROM task_assignments WHERE user_id = ${userId} - ))`; - } - - query += " ORDER BY al.created_at DESC LIMIT 100"; - - db.all(query, (err, logs) => { - if (err) { - res.status(500).json({ error: err.message }); - return; - } - res.json(logs); - }); -}); - -// API для логов уведомлений из PostgreSQL -app.get('/api/notification-logs', requireAuth, async (req, res) => { - try { - const { - taskId, - status, - startDate, - endDate, - limit = 50, - offset = 0 - } = req.query; - - // Получаем все логи для текущего пользователя - const query = ` - SELECT * FROM sms_logs - WHERE creator_id = ? OR assignee_id = ? - ${taskId ? 'AND task_id = ?' : ''} - ${status ? 'AND status = ?' : ''} - ${startDate ? 'AND created_at >= ?' : ''} - ${endDate ? 'AND created_at <= ?' : ''} - ORDER BY created_at DESC - LIMIT ? OFFSET ? - `; - - const params = [req.session.user.id, req.session.user.id]; - if (taskId) params.push(taskId); - if (status) params.push(status); - if (startDate) params.push(startDate); - if (endDate) params.push(endDate); - params.push(parseInt(limit), parseInt(offset)); - - const logs = await new Promise((resolve, reject) => { - db.all(query, params, (err, rows) => { - if (err) reject(err); - else resolve(rows); - }); - }); - - const countQuery = ` - SELECT COUNT(*) as total FROM sms_logs - WHERE creator_id = ? OR assignee_id = ? - ${taskId ? 'AND task_id = ?' : ''} - ${status ? 'AND status = ?' : ''} - ${startDate ? 'AND created_at >= ?' : ''} - ${endDate ? 'AND created_at <= ?' : ''} - `; - - const countParams = [req.session.user.id, req.session.user.id]; - if (taskId) countParams.push(taskId); - if (status) countParams.push(status); - if (startDate) countParams.push(startDate); - if (endDate) countParams.push(endDate); - - const countResult = await new Promise((resolve, reject) => { - db.get(countQuery, countParams, (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); - - res.json({ - logs: logs || [], - total: countResult?.total || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } catch (error) { - console.error('❌ Ошибка получения логов уведомлений:', error); - res.status(500).json({ error: 'Ошибка получения логов' }); - } -}); - -// API для статистики уведомлений -app.get('/api/notification-stats', requireAuth, async (req, res) => { - try { - const { period = 'day' } = req.query; - - if (req.session.user.role !== 'admin') { - return res.status(403).json({ error: 'Недостаточно прав' }); - } - - let dateFilter = ''; - switch (period) { - case 'day': - dateFilter = "created_at >= CURRENT_DATE"; - break; - case 'week': - dateFilter = "created_at >= DATE('now', '-7 days')"; - break; - case 'month': - dateFilter = "created_at >= DATE('now', '-30 days')"; - break; - case 'year': - dateFilter = "created_at >= DATE('now', '-365 days')"; - break; - default: - dateFilter = "created_at >= CURRENT_DATE"; - } - - const statsQuery = ` - SELECT - status, - COUNT(*) as count, - COUNT(CASE WHEN sent_at IS NOT NULL THEN 1 END) as sent_count, - COUNT(CASE WHEN error_message IS NOT NULL THEN 1 END) as error_count - FROM sms_logs - WHERE ${dateFilter} - GROUP BY status - `; - - const totalQuery = ` - SELECT - COUNT(*) as total, - COUNT(CASE WHEN sent_at IS NOT NULL THEN 1 END) as total_sent, - COUNT(CASE WHEN error_message IS NOT NULL THEN 1 END) as total_errors - FROM sms_logs - WHERE ${dateFilter} - `; - - const [stats, total] = await Promise.all([ - new Promise((resolve, reject) => { - db.all(statsQuery, [], (err, rows) => { - if (err) reject(err); - else resolve(rows || []); - }); - }), - new Promise((resolve, reject) => { - db.get(totalQuery, [], (err, row) => { - if (err) reject(err); - else resolve(row || { total: 0, total_sent: 0, total_errors: 0 }); - }); - }) - ]); - - res.json({ - period: period, - stats: stats, - total: total.total || 0, - totalSent: total.total_sent || 0, - totalErrors: total.total_errors || 0, - timestamp: new Date().toISOString() - }); - } catch (error) { - console.error('❌ Ошибка получения статистики:', error); - res.status(500).json({ error: 'Ошибка получения статистики' }); - } -}); - -// API для проверки состояния PostgreSQL -app.get('/api/postgres-health', requireAuth, async (req, res) => { - try { - if (req.session.user.role !== 'admin') { - return res.status(403).json({ error: 'Недостаточно прав' }); - } - - const health = await postgresLogger.healthCheck(); - res.json(health); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Админ панель -app.get('/admin', (req, res) => { - if (!req.session.user || req.session.user.role !== 'admin') { - return res.status(403).send('Доступ запрещен'); - } - res.sendFile(path.join(__dirname, 'public/admin.html')); -}); - -// API для получения настроек уведомлений пользователя -app.get('/api/user/settings', requireAuth, async (req, res) => { - try { - if (!req.session.user || !req.session.user.id) { - return res.status(401).json({ error: 'Не аутентифицирован' }); - } - - const userId = req.session.user.id; - const { getDb } = require('./database'); - const db = getDb(); - - db.get("SELECT email_notifications, notification_email, telegram_notifications, telegram_chat_id, vk_notifications, vk_user_id FROM user_settings WHERE user_id = ?", - [userId], - (err, settings) => { - if (err) { - console.error('❌ Ошибка получения настроек:', err); - return res.status(500).json({ error: 'Ошибка получения настроек' }); - } - - if (!settings) { - // Возвращаем настройки по умолчанию - res.json({ - email_notifications: true, - notification_email: req.session.user.email || '', - telegram_notifications: false, - telegram_chat_id: '', - vk_notifications: false, - vk_user_id: '' - }); - } else { - // Преобразуем boolean из SQLite (0/1) в true/false - const result = { - email_notifications: !!settings.email_notifications, - notification_email: settings.notification_email || '', - telegram_notifications: !!settings.telegram_notifications, - telegram_chat_id: settings.telegram_chat_id || '', - vk_notifications: !!settings.vk_notifications, - vk_user_id: settings.vk_user_id || '' - }; - res.json(result); - } - } - ); - - } catch (error) { - console.error('❌ Ошибка получения настроек:', error); - res.status(500).json({ error: 'Ошибка получения настроек' }); - } -}); - -// API для сохранения настроек уведомлений -app.post('/api/user/settings', requireAuth, async (req, res) => { - try { - if (!req.session.user || !req.session.user.id) { - return res.status(401).json({ error: 'Не аутентифицирован' }); - } - - const userId = req.session.user.id; - const { - email_notifications, - notification_email, - telegram_notifications, - telegram_chat_id, - vk_notifications, - vk_user_id - } = req.body; - - // Валидация - if (email_notifications === undefined || - telegram_notifications === undefined || - vk_notifications === undefined) { - return res.status(400).json({ - error: 'Не все обязательные поля заполнены' - }); - } - - const { getDb } = require('./database'); - const db = getDb(); - - // Проверяем, есть ли уже настройки для пользователя - db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => { - if (err) { - console.error('❌ Ошибка проверки настроек:', err); - return res.status(500).json({ error: 'Ошибка сохранения настроек' }); - } - - if (existing) { - // Обновляем существующие настройки - db.run( - `UPDATE user_settings SET - email_notifications = ?, - notification_email = ?, - telegram_notifications = ?, - telegram_chat_id = ?, - vk_notifications = ?, - vk_user_id = ?, - updated_at = CURRENT_TIMESTAMP - WHERE user_id = ?`, - [ - email_notifications ? 1 : 0, - notification_email || '', - telegram_notifications ? 1 : 0, - telegram_chat_id || '', - vk_notifications ? 1 : 0, - vk_user_id || '', - userId - ], - function(updateErr) { - if (updateErr) { - console.error('❌ Ошибка обновления настроек:', updateErr); - return res.status(500).json({ error: 'Ошибка сохранения настроек' }); - } - - console.log(`✅ Настройки пользователя ${userId} обновлены`); - res.json({ success: true }); - } - ); - } else { - // Создаем новые настройки - db.run( - `INSERT INTO user_settings - (user_id, email_notifications, notification_email, - telegram_notifications, telegram_chat_id, - vk_notifications, vk_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - userId, - email_notifications ? 1 : 0, - notification_email || '', - telegram_notifications ? 1 : 0, - telegram_chat_id || '', - vk_notifications ? 1 : 0, - vk_user_id || '' - ], - function(insertErr) { - if (insertErr) { - console.error('❌ Ошибка создания настроек:', insertErr); - return res.status(500).json({ error: 'Ошибка сохранения настроек' }); - } - - console.log(`✅ Настройки пользователя ${userId} созданы`); - res.json({ success: true }); - } - ); - } - }); - - } catch (error) { - console.error('❌ Ошибка сохранения настроек:', error); - res.status(500).json({ error: 'Ошибка сохранения настроек' }); - } -}); - -// API для проверки настроек -app.get('/api/user/settings/check', requireAuth, (req, res) => { - try { - if (!req.session.user || !req.session.user.id) { - return res.status(401).json({ error: 'Не аутентифицирован' }); - } - - const userId = req.session.user.id; - const { getDb } = require('./database'); - const db = getDb(); - - db.get("SELECT COUNT(*) as count FROM user_settings WHERE user_id = ?", [userId], (err, result) => { - if (err) { - console.error('❌ Ошибка проверки таблицы:', err); - return res.json({ - table_exists: false, - user_has_settings: false, - error: err.message - }); - } - - res.json({ - table_exists: true, - user_has_settings: result.count > 0, - user_id: userId - }); - }); - - } catch (error) { - console.error('❌ Ошибка проверки настроек:', error); - res.status(500).json({ error: 'Ошибка проверки настроек' }); - } -}); - -// API для проверки email уведомлений -app.get('/api/email-health', requireAuth, async (req, res) => { - try { - if (req.session.user.role !== 'admin') { - return res.status(403).json({ error: 'Недостаточно прав' }); - } - - const emailNotifications = require('./email-notifications'); - const health = { - ready: emailNotifications.isReady(), - email: process.env.YANDEX_EMAIL, - host: process.env.YANDEX_SMTP_HOST, - timestamp: new Date().toISOString() - }; - - res.json(health); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Инициализация сервера -async function initializeServer() { - console.log('🚀 Инициализация сервера...'); - - try { - // 1. Инициализируем базу данных - console.log('🔧 Инициализация базы данных...'); - await initializeDatabase(); - - // 2. Получаем объект БД - db = getDb(); - console.log('✅ База данных готова'); - - // 3. Настраиваем authService с БД - authService.setDatabase(db); - console.log('✅ Сервис аутентификации готов'); - - // 4. Настраиваем загрузку файлов - upload = setupUploadMiddleware(); - console.log('✅ Middleware загрузки файлов настроен'); - - // 5. Настраиваем endpoint'ы для задач - setupTaskEndpoints(app, db, upload); - console.log('✅ Endpoint\'ы задач настроены'); - - // 6. Загружаем админ роутер динамически - try { - adminRouter = require('./admin-server'); - console.log('Admin router loaded:', adminRouter); - console.log('Type:', typeof adminRouter); - - if (adminRouter && typeof adminRouter === 'function') { - app.use(adminRouter); - console.log('✅ Админ роутер подключен'); - } else { - console.error('❌ Admin router is not a valid middleware function'); - // Создаем заглушку, чтобы сервер работал - const express = require('express'); - const stubRouter = express.Router(); - stubRouter.get('*', (req, res) => { - res.status(501).json({ error: 'Admin router not available' }); - }); - app.use(stubRouter); - console.log('⚠️ Используется заглушка для админ роутера'); - } - } catch (error) { - console.error('❌ Ошибка загрузки админ роутера:', error.message); - console.error('Stack:', error.stack); - - // Создаем заглушку, чтобы сервер не падал - const express = require('express'); - const stubRouter = express.Router(); - stubRouter.get('*', (req, res) => { - res.status(503).json({ - error: 'Admin panel temporarily unavailable', - message: error.message - }); - }); - app.use(stubRouter); - console.log('⚠️ Создана заглушка для админ роутера из-за ошибки'); - } - - // 7. Помечаем сервер как готовый - serverReady = true; - - console.log('✅ Сервер полностью инициализирован'); - - } catch (error) { - console.error('❌ Ошибка инициализации сервера:', error.message); - console.error(error.stack); - process.exit(1); - } -} - -// Запускаем инициализацию и сервер -initializeServer().then(() => { - // Запускаем сервер - app.listen(PORT, () => { - console.log(`🚀 CRM сервер запущен на порту ${PORT}`); - console.log(`🌐 Откройте http://localhost:${PORT} в браузере`); - console.log('📁 Данные хранятся в папке:', path.join(__dirname, 'data')); - console.log('👤 Тестовые пользователи:'); - console.log(' - Логин: director, Пароль: director123 (Администратор)'); - console.log(' - Логин: zavuch, Пароль: zavuch123'); - console.log(' - Логин: teacher, Пароль: teacher123'); - console.log('🔐 LDAP авторизация доступна для пользователей школы'); - console.log(`👥 Разрешенные группы: ${process.env.ALLOWED_GROUPS}`); - console.log('📢 Система уведомлений активна'); - - // Запускаем фоновые задачи - setInterval(checkOverdueTasks, 60000); - setInterval(checkUpcomingDeadlines, 60000); - }); -}).catch(error => { - console.error('❌ Не удалось запустить сервер:', error); - process.exit(1); -}); - -// Экспортируем приложение для тестирования +// server.js +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const session = require('express-session'); +require('dotenv').config(); + +// Импортируем модули +const { initializeDatabase, getDb, isInitialized } = require('./database'); +const authService = require('./auth'); +const postgresLogger = require('./postgres'); +const { sendTaskNotifications, checkUpcomingDeadlines, getStatusText } = require('./notifications'); +const { setupUploadMiddleware } = require('./upload-middleware'); +const { setupTaskEndpoints } = require('./task-endpoints'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Глобальные переменные +let db = null; +let serverReady = false; +let adminRouter = null; +let upload = null; + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static('public')); +app.use('/uploads', express.static(path.join(__dirname, 'data', 'uploads'))); +app.use(session({ + secret: process.env.SESSION_SECRET || 'fallback_secret_change_in_production', + resave: true, + saveUninitialized: false, + cookie: { + secure: false, + maxAge: 24 * 60 * 60 * 1000, + httpOnly: true + } +})); + +// Middleware для проверки готовности сервера +app.use((req, res, next) => { + if (!serverReady && req.path !== '/health' && req.path !== '/api/health') { + return res.status(503).json({ + error: 'Сервер запускается...', + status: 'initializing' + }); + } + next(); +}); + +// Health check endpoints +app.get('/health', (req, res) => { + res.json({ + status: serverReady ? 'ready' : 'initializing', + database: isInitialized() ? 'connected' : 'connecting', + auth: authService.isReady() ? 'ready' : 'waiting', + timestamp: new Date().toISOString() + }); +}); + +app.get('/api/health', (req, res) => { + res.json({ + status: serverReady ? 'ready' : 'initializing', + database: isInitialized() ? 'connected' : 'connecting', + auth: authService.isReady() ? 'ready' : 'waiting', + timestamp: new Date().toISOString() + }); +}); + +// Вспомогательные функции +const createDirIfNotExists = (dirPath) => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +function checkIfOverdue(dueDate, status) { + if (!dueDate || status === 'completed') return false; + const now = new Date(); + const due = new Date(dueDate); + return due < now; +} + +function checkOverdueTasks() { + if (!db) { + console.error('❌ База данных не доступна для проверки просроченных задач'); + return; + } + + const now = new Date().toISOString(); + + const query = ` + SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date + FROM task_assignments ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.due_date IS NOT NULL + AND ta.due_date < ? + AND ta.status NOT IN ('completed', 'overdue') + AND t.status = 'active' + AND t.closed_at IS NULL + `; + + db.all(query, [now], (err, assignments) => { + if (err) { + console.error('❌ Ошибка при проверке просроченных задач:', err); + return; + } + + assignments.forEach(assignment => { + db.run( + "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", + [assignment.id] + ); + + const { logActivity } = require('./database'); + if (logActivity) { + logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); + } + }); + }); +} + +// Middleware для аутентификации +const requireAuth = (req, res, next) => { + if (!req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); +}; + +// API для аутентификации +app.post('/api/login', async (req, res) => { + const { login, password } = req.body; + + if (!login || !password) { + return res.status(400).json({ error: 'Логин и пароль обязательны' }); + } + + try { + const user = await authService.authenticate(login, password); + if (user) { + const sessionUser = { + id: user.id, + login: user.login, + name: user.name, + email: user.email, + role: user.role, + auth_type: user.auth_type, + groups: user.groups ? (typeof user.groups === 'string' ? JSON.parse(user.groups) : user.groups) : [] + }; + + req.session.user = sessionUser; + + req.session.save((err) => { + if (err) { + console.error('❌ Ошибка сохранения сессии:', err); + return res.status(500).json({ error: 'Ошибка сохранения сессии' }); + } + + console.log(`✅ Успешная авторизация: ${user.name} (${user.login}) через ${user.auth_type}`); + if (user.groups) { + console.log(`Группы пользователя: ${user.groups}`); + } + + res.json({ + success: true, + user: sessionUser + }); + }); + } else { + console.log(`❌ Неудачная попытка входа: ${login}`); + res.status(401).json({ error: 'Неверный логин или пароль' }); + } + } catch (error) { + console.error('❌ Ошибка аутентификации:', error.message); + res.status(500).json({ + error: 'Ошибка сервера при авторизации', + details: error.message + }); + } +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + console.error('❌ Ошибка при выходе:', err); + return res.status(500).json({ error: 'Ошибка при выходе' }); + } + res.json({ success: true }); + }); +}); + +app.get('/api/user', (req, res) => { + if (req.session.user) { + if (req.session.user.auth_type === 'ldap') { + if (!db) { + return res.status(503).json({ error: 'База данных не готова' }); + } + + db.get("SELECT groups FROM users WHERE id = ?", [req.session.user.id], (err, user) => { + if (err || !user) { + req.session.destroy(); + return res.status(401).json({ error: 'Пользователь не найден' }); + } + + let groups = []; + try { + groups = JSON.parse(user.groups || '[]'); + } catch (e) { + groups = []; + } + + const allowedGroups = process.env.ALLOWED_GROUPS ? + process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : []; + + const isAdmin = groups.some(group => allowedGroups.includes(group)); + const actualRole = isAdmin ? 'admin' : 'teacher'; + + if (req.session.user.role !== actualRole) { + console.log(`Обновлена роль пользователя ${req.session.user.login} с ${req.session.user.role} на ${actualRole}`); + + db.run( + "UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?", + [actualRole, req.session.user.id] + ); + + req.session.user.role = actualRole; + } + + res.json({ user: req.session.user }); + }); + } else { + res.json({ user: req.session.user }); + } + } else { + res.status(401).json({ error: 'Не аутентифицирован' }); + } +}); + +// Middleware для проверки наличия БД в API endpoints +app.use((req, res, next) => { + if (!db && req.path.startsWith('/api/') && req.path !== '/api/health' && req.path !== '/api/login') { + return res.status(503).json({ error: 'База данных не готова' }); + } + next(); +}); + +// API для пользователей +app.get('/api/users', requireAuth, (req, res) => { + const search = req.query.search || ''; + + let query = ` + SELECT id, login, name, email, role, auth_type + FROM users + WHERE role IN ('admin', 'teacher') + `; + + const params = []; + + if (search) { + query += ` AND (login LIKE ? OR name LIKE ? OR email LIKE ?)`; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern); + } + + query += " ORDER BY name"; + + db.all(query, params, (err, rows) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(rows); + }); +}); + +// API для файлов +app.get('/api/tasks/:taskId/files', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + const { checkTaskAccess } = require('./database'); + + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); + } + + db.all(` + SELECT tf.*, u.name as user_name, u.login as user_login + FROM task_files tf + LEFT JOIN users u ON tf.user_id = u.id + WHERE tf.task_id = ? + ORDER BY tf.uploaded_at DESC + `, [taskId], (err, files) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(files); + }); + }); +}); + +app.get('/api/files/:fileId/download', requireAuth, (req, res) => { + const { fileId } = req.params; + const userId = req.session.user.id; + + db.get("SELECT tf.*, t.id as task_id FROM task_files tf JOIN tasks t ON tf.task_id = t.id WHERE tf.id = ?", [fileId], (err, file) => { + if (err || !file) { + return res.status(404).json({ error: 'Файл не найдена' }); + } + + const { checkTaskAccess } = require('./database'); + + checkTaskAccess(userId, file.task_id, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Файл не найден или у вас нет прав доступа' }); + } + + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: 'Файл не найден на сервере' }); + } + + // Исправляем кодировку имени файла + let decodedFileName = file.original_name; + + // Пробуем декодировать если это UTF-8 в Latin-1 (для старых записей) + try { + if (/^[A-Za-z0-9\.\-_]+$/.test(decodedFileName)) { + // Если имя содержит только латинские символы, оставляем как есть + } else if (decodedFileName.includes('Ð') || decodedFileName.includes('Ñ')) { + // Исправляем неправильно декодированную кириллицу + decodedFileName = Buffer.from(decodedFileName, 'binary').toString('utf8'); + } + } catch (e) { + console.error('Ошибка декодирования имени файла:', e); + } + + // Кодируем имя файла для безопасной передачи + const encodedFileName = encodeURIComponent(decodedFileName); + + // Устанавливаем заголовки для скачивания + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + res.setHeader('Content-Type', 'application/octet-stream'); + + // Отправляем файл + res.sendFile(file.file_path); + }); + }); +}); + +// API для логов активности +app.get('/api/activity-logs', requireAuth, (req, res) => { + const userId = req.session.user.id; + + let query = ` + SELECT al.*, u.name as user_name, t.title as task_title + FROM activity_logs al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN tasks t ON al.task_id = t.id + WHERE 1=1 + `; + + if (req.session.user.role !== 'admin') { + query += ` AND (t.created_by = ${userId} OR al.task_id IN ( + SELECT task_id FROM task_assignments WHERE user_id = ${userId} + ))`; + } + + query += " ORDER BY al.created_at DESC LIMIT 100"; + + db.all(query, (err, logs) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(logs); + }); +}); + +// API для логов уведомлений из PostgreSQL +app.get('/api/notification-logs', requireAuth, async (req, res) => { + try { + const { + taskId, + status, + startDate, + endDate, + limit = 50, + offset = 0 + } = req.query; + + // Получаем все логи для текущего пользователя + const query = ` + SELECT * FROM sms_logs + WHERE creator_id = ? OR assignee_id = ? + ${taskId ? 'AND task_id = ?' : ''} + ${status ? 'AND status = ?' : ''} + ${startDate ? 'AND created_at >= ?' : ''} + ${endDate ? 'AND created_at <= ?' : ''} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `; + + const params = [req.session.user.id, req.session.user.id]; + if (taskId) params.push(taskId); + if (status) params.push(status); + if (startDate) params.push(startDate); + if (endDate) params.push(endDate); + params.push(parseInt(limit), parseInt(offset)); + + const logs = await new Promise((resolve, reject) => { + db.all(query, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + + const countQuery = ` + SELECT COUNT(*) as total FROM sms_logs + WHERE creator_id = ? OR assignee_id = ? + ${taskId ? 'AND task_id = ?' : ''} + ${status ? 'AND status = ?' : ''} + ${startDate ? 'AND created_at >= ?' : ''} + ${endDate ? 'AND created_at <= ?' : ''} + `; + + const countParams = [req.session.user.id, req.session.user.id]; + if (taskId) countParams.push(taskId); + if (status) countParams.push(status); + if (startDate) countParams.push(startDate); + if (endDate) countParams.push(endDate); + + const countResult = await new Promise((resolve, reject) => { + db.get(countQuery, countParams, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + + res.json({ + logs: logs || [], + total: countResult?.total || 0, + limit: parseInt(limit), + offset: parseInt(offset) + }); + } catch (error) { + console.error('❌ Ошибка получения логов уведомлений:', error); + res.status(500).json({ error: 'Ошибка получения логов' }); + } +}); + +// API для статистики уведомлений +app.get('/api/notification-stats', requireAuth, async (req, res) => { + try { + const { period = 'day' } = req.query; + + if (req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Недостаточно прав' }); + } + + let dateFilter = ''; + switch (period) { + case 'day': + dateFilter = "created_at >= CURRENT_DATE"; + break; + case 'week': + dateFilter = "created_at >= DATE('now', '-7 days')"; + break; + case 'month': + dateFilter = "created_at >= DATE('now', '-30 days')"; + break; + case 'year': + dateFilter = "created_at >= DATE('now', '-365 days')"; + break; + default: + dateFilter = "created_at >= CURRENT_DATE"; + } + + const statsQuery = ` + SELECT + status, + COUNT(*) as count, + COUNT(CASE WHEN sent_at IS NOT NULL THEN 1 END) as sent_count, + COUNT(CASE WHEN error_message IS NOT NULL THEN 1 END) as error_count + FROM sms_logs + WHERE ${dateFilter} + GROUP BY status + `; + + const totalQuery = ` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN sent_at IS NOT NULL THEN 1 END) as total_sent, + COUNT(CASE WHEN error_message IS NOT NULL THEN 1 END) as total_errors + FROM sms_logs + WHERE ${dateFilter} + `; + + const [stats, total] = await Promise.all([ + new Promise((resolve, reject) => { + db.all(statsQuery, [], (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }), + new Promise((resolve, reject) => { + db.get(totalQuery, [], (err, row) => { + if (err) reject(err); + else resolve(row || { total: 0, total_sent: 0, total_errors: 0 }); + }); + }) + ]); + + res.json({ + period: period, + stats: stats, + total: total.total || 0, + totalSent: total.total_sent || 0, + totalErrors: total.total_errors || 0, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('❌ Ошибка получения статистики:', error); + res.status(500).json({ error: 'Ошибка получения статистики' }); + } +}); + +// API для проверки состояния PostgreSQL +app.get('/api/postgres-health', requireAuth, async (req, res) => { + try { + if (req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Недостаточно прав' }); + } + + const health = await postgresLogger.healthCheck(); + res.json(health); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Админ панель +app.get('/admin', (req, res) => { + if (!req.session.user || req.session.user.role !== 'admin') { + return res.status(403).send('Доступ запрещен'); + } + res.sendFile(path.join(__dirname, 'public/admin.html')); +}); + +// API для получения настроек уведомлений пользователя +app.get('/api/user/settings', requireAuth, async (req, res) => { + try { + if (!req.session.user || !req.session.user.id) { + return res.status(401).json({ error: 'Не аутентифицирован' }); + } + + const userId = req.session.user.id; + const { getDb } = require('./database'); + const db = getDb(); + + db.get("SELECT email_notifications, notification_email, telegram_notifications, telegram_chat_id, vk_notifications, vk_user_id FROM user_settings WHERE user_id = ?", + [userId], + (err, settings) => { + if (err) { + console.error('❌ Ошибка получения настроек:', err); + return res.status(500).json({ error: 'Ошибка получения настроек' }); + } + + if (!settings) { + // Возвращаем настройки по умолчанию + res.json({ + email_notifications: true, + notification_email: req.session.user.email || '', + telegram_notifications: false, + telegram_chat_id: '', + vk_notifications: false, + vk_user_id: '' + }); + } else { + // Преобразуем boolean из SQLite (0/1) в true/false + const result = { + email_notifications: !!settings.email_notifications, + notification_email: settings.notification_email || '', + telegram_notifications: !!settings.telegram_notifications, + telegram_chat_id: settings.telegram_chat_id || '', + vk_notifications: !!settings.vk_notifications, + vk_user_id: settings.vk_user_id || '' + }; + res.json(result); + } + } + ); + + } catch (error) { + console.error('❌ Ошибка получения настроек:', error); + res.status(500).json({ error: 'Ошибка получения настроек' }); + } +}); + +// API для сохранения настроек уведомлений +app.post('/api/user/settings', requireAuth, async (req, res) => { + try { + if (!req.session.user || !req.session.user.id) { + return res.status(401).json({ error: 'Не аутентифицирован' }); + } + + const userId = req.session.user.id; + const { + email_notifications, + notification_email, + telegram_notifications, + telegram_chat_id, + vk_notifications, + vk_user_id + } = req.body; + + // Валидация + if (email_notifications === undefined || + telegram_notifications === undefined || + vk_notifications === undefined) { + return res.status(400).json({ + error: 'Не все обязательные поля заполнены' + }); + } + + const { getDb } = require('./database'); + const db = getDb(); + + // Проверяем, есть ли уже настройки для пользователя + db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => { + if (err) { + console.error('❌ Ошибка проверки настроек:', err); + return res.status(500).json({ error: 'Ошибка сохранения настроек' }); + } + + if (existing) { + // Обновляем существующие настройки + db.run( + `UPDATE user_settings SET + email_notifications = ?, + notification_email = ?, + telegram_notifications = ?, + telegram_chat_id = ?, + vk_notifications = ?, + vk_user_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ?`, + [ + email_notifications ? 1 : 0, + notification_email || '', + telegram_notifications ? 1 : 0, + telegram_chat_id || '', + vk_notifications ? 1 : 0, + vk_user_id || '', + userId + ], + function(updateErr) { + if (updateErr) { + console.error('❌ Ошибка обновления настроек:', updateErr); + return res.status(500).json({ error: 'Ошибка сохранения настроек' }); + } + + console.log(`✅ Настройки пользователя ${userId} обновлены`); + res.json({ success: true }); + } + ); + } else { + // Создаем новые настройки + db.run( + `INSERT INTO user_settings + (user_id, email_notifications, notification_email, + telegram_notifications, telegram_chat_id, + vk_notifications, vk_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + userId, + email_notifications ? 1 : 0, + notification_email || '', + telegram_notifications ? 1 : 0, + telegram_chat_id || '', + vk_notifications ? 1 : 0, + vk_user_id || '' + ], + function(insertErr) { + if (insertErr) { + console.error('❌ Ошибка создания настроек:', insertErr); + return res.status(500).json({ error: 'Ошибка сохранения настроек' }); + } + + console.log(`✅ Настройки пользователя ${userId} созданы`); + res.json({ success: true }); + } + ); + } + }); + + } catch (error) { + console.error('❌ Ошибка сохранения настроек:', error); + res.status(500).json({ error: 'Ошибка сохранения настроек' }); + } +}); + +// API для проверки настроек +app.get('/api/user/settings/check', requireAuth, (req, res) => { + try { + if (!req.session.user || !req.session.user.id) { + return res.status(401).json({ error: 'Не аутентифицирован' }); + } + + const userId = req.session.user.id; + const { getDb } = require('./database'); + const db = getDb(); + + db.get("SELECT COUNT(*) as count FROM user_settings WHERE user_id = ?", [userId], (err, result) => { + if (err) { + console.error('❌ Ошибка проверки таблицы:', err); + return res.json({ + table_exists: false, + user_has_settings: false, + error: err.message + }); + } + + res.json({ + table_exists: true, + user_has_settings: result.count > 0, + user_id: userId + }); + }); + + } catch (error) { + console.error('❌ Ошибка проверки настроек:', error); + res.status(500).json({ error: 'Ошибка проверки настроек' }); + } +}); + +// API для проверки email уведомлений +app.get('/api/email-health', requireAuth, async (req, res) => { + try { + if (req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Недостаточно прав' }); + } + + const emailNotifications = require('./email-notifications'); + const health = { + ready: emailNotifications.isReady(), + email: process.env.YANDEX_EMAIL, + host: process.env.YANDEX_SMTP_HOST, + timestamp: new Date().toISOString() + }; + + res.json(health); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Инициализация сервера +async function initializeServer() { + console.log('🚀 Инициализация сервера...'); + + try { + // 1. Инициализируем базу данных + console.log('🔧 Инициализация базы данных...'); + await initializeDatabase(); + + // 2. Получаем объект БД + db = getDb(); + console.log('✅ База данных готова'); + + // 3. Настраиваем authService с БД + authService.setDatabase(db); + console.log('✅ Сервис аутентификации готов'); + + // 4. Настраиваем загрузку файлов + upload = setupUploadMiddleware(); + console.log('✅ Middleware загрузки файлов настроен'); + + // 5. Настраиваем endpoint'ы для задач + setupTaskEndpoints(app, db, upload); + console.log('✅ Endpoint\'ы задач настроены'); + + // 6. Загружаем админ роутер динамически + try { + adminRouter = require('./admin-server'); + console.log('Admin router loaded:', adminRouter); + console.log('Type:', typeof adminRouter); + + if (adminRouter && typeof adminRouter === 'function') { + app.use(adminRouter); + console.log('✅ Админ роутер подключен'); + } else { + console.error('❌ Admin router is not a valid middleware function'); + // Создаем заглушку, чтобы сервер работал + const express = require('express'); + const stubRouter = express.Router(); + stubRouter.get('*', (req, res) => { + res.status(501).json({ error: 'Admin router not available' }); + }); + app.use(stubRouter); + console.log('⚠️ Используется заглушка для админ роутера'); + } + } catch (error) { + console.error('❌ Ошибка загрузки админ роутера:', error.message); + console.error('Stack:', error.stack); + + // Создаем заглушку, чтобы сервер не падал + const express = require('express'); + const stubRouter = express.Router(); + stubRouter.get('*', (req, res) => { + res.status(503).json({ + error: 'Admin panel temporarily unavailable', + message: error.message + }); + }); + app.use(stubRouter); + console.log('⚠️ Создана заглушка для админ роутера из-за ошибки'); + } + + // 7. Помечаем сервер как готовый + serverReady = true; + + console.log('✅ Сервер полностью инициализирован'); + + } catch (error) { + console.error('❌ Ошибка инициализации сервера:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +// Запускаем инициализацию и сервер +initializeServer().then(() => { + // Запускаем сервер + app.listen(PORT, () => { + console.log(`🚀 CRM сервер запущен на порту ${PORT}`); + console.log(`🌐 Откройте http://localhost:${PORT} в браузере`); + console.log('📁 Данные хранятся в папке:', path.join(__dirname, 'data')); + console.log('👤 Тестовые пользователи:'); + console.log(' - Логин: director, Пароль: director123 (Администратор)'); + console.log(' - Логин: zavuch, Пароль: zavuch123'); + console.log(' - Логин: teacher, Пароль: teacher123'); + console.log('🔐 LDAP авторизация доступна для пользователей школы'); + console.log(`👥 Разрешенные группы: ${process.env.ALLOWED_GROUPS}`); + console.log('📢 Система уведомлений активна'); + + // Запускаем фоновые задачи + setInterval(checkOverdueTasks, 60000); + setInterval(checkUpcomingDeadlines, 60000); + }); +}).catch(error => { + console.error('❌ Не удалось запустить сервер:', error); + process.exit(1); +}); + +// Экспортируем приложение для тестирования module.exports = app; \ No newline at end of file