diff --git a/database.js b/database.js index 02ca5db..fc47a90 100644 --- a/database.js +++ b/database.js @@ -174,9 +174,176 @@ function createSQLiteTables() { notification_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); - + console.log('✅ База данных SQLite инициализирована'); - setTimeout(addMissingColumns, 1000); + + // Добавляем таблицу для пользовательских настроек + 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) { @@ -400,6 +567,22 @@ async function createPostgresTables() { ) `); + // Добавляем таблицу для пользовательских настроек + 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)', @@ -410,7 +593,8 @@ async function createPostgresTables() { '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_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) { @@ -424,40 +608,91 @@ async function createPostgresTables() { client.release(); console.log('✅ Таблицы PostgreSQL проверены/созданы'); + // Проверяем структуру PostgreSQL таблиц + await checkPostgresTableStructure(); + } catch (error) { console.error('❌ Ошибка создания таблиц PostgreSQL:', error.message); } } -function addMissingColumns() { - const columnsToAdd = [ - { table: 'tasks', column: 'rework_comment', type: 'TEXT' }, - { table: 'tasks', column: 'closed_at', type: 'DATETIME' }, - { table: 'tasks', column: 'closed_by', type: 'INTEGER' }, - { table: 'task_assignments', column: 'rework_comment', type: 'TEXT' } - ]; - - columnsToAdd.forEach(({ table, column, type }) => { - db.all(`PRAGMA table_info(${table})`, (err, rows) => { - if (err) { - console.error(`Ошибка при проверке таблицы ${table}:`, err); - return; - } - - const columnExists = rows.some(row => row.name === column); - if (!columnExists) { - db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`, (err) => { - if (err) { - console.error(`Ошибка при добавлении колонки ${column} в таблицу ${table}:`, err); - } else { - console.log(`✅ Добавлена колонка ${column} в таблицу ${table}`); +// Функция для проверки структуры таблиц 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); + } } - }); - } else { - console.log(`✅ Колонка ${column} уже существует в таблице ${table}`); + } + } catch (error) { + console.error(`❌ Ошибка проверки таблицы PostgreSQL ${tableName}:`, error.message); } - }); - }); + } + + client.release(); + console.log('✅ Проверка структуры таблиц PostgreSQL завершена'); + + } catch (error) { + console.error('❌ Ошибка проверки структуры таблиц PostgreSQL:', error.message); + } } function createTaskFolder(taskId) { @@ -598,7 +833,8 @@ module.exports = { updateTaskMetadata, checkTaskAccess, USE_POSTGRES, - getDatabaseType: () => USE_POSTGRES ? 'PostgreSQL' : 'SQLite' + getDatabaseType: () => USE_POSTGRES ? 'PostgreSQL' : 'SQLite', + checkAndUpdateTableStructure // Экспортируем для ручного запуска }; // Запускаем инициализацию при экспорте (но она завершится позже) diff --git a/email-notifications.js b/email-notifications.js new file mode 100644 index 0000000..374f8d6 --- /dev/null +++ b/email-notifications.js @@ -0,0 +1,525 @@ +// email-notifications.js +const nodemailer = require('nodemailer'); +const { getDb } = require('./database'); + +class EmailNotifications { + constructor() { + this.transporter = null; + this.initialized = false; + this.init(); + } + + async init() { + try { + console.log('🔧 Инициализация Email уведомлений...'); + + if (!process.env.YANDEX_EMAIL || !process.env.YANDEX_PASSWORD) { + console.warn('⚠️ Настройки Яндекс почты не указаны в .env'); + console.warn(' Email уведомления будут отключены'); + this.initialized = false; + return; + } + + this.transporter = nodemailer.createTransport({ + host: process.env.YANDEX_SMTP_HOST || 'smtp.yandex.ru', + port: parseInt(process.env.YANDEX_SMTP_PORT) || 587, + secure: process.env.YANDEX_SMTP_SECURE === 'true', + auth: { + user: process.env.YANDEX_EMAIL, + pass: process.env.YANDEX_PASSWORD + }, + tls: { + rejectUnauthorized: false + } + }); + + // Тестируем подключение + await this.transporter.verify(); + this.initialized = true; + console.log('✅ Email уведомления инициализированы'); + console.log(`📧 Отправитель: ${process.env.YANDEX_EMAIL}`); + + } catch (error) { + console.error('❌ Ошибка инициализации Email уведомлений:', error.message); + this.initialized = false; + } + } + + async getUserNotificationSettings(userId) { + if (!getDb) return null; + + return new Promise((resolve, reject) => { + const db = getDb(); + db.get(` + SELECT us.*, u.email as user_email, u.name as user_name + FROM user_settings us + LEFT JOIN users u ON us.user_id = u.id + WHERE us.user_id = ? + `, [userId], (err, settings) => { + if (err) { + reject(err); + } else { + resolve(settings); + } + }); + }); + } + + async saveUserNotificationSettings(userId, settings) { + if (!getDb) return false; + + return new Promise((resolve, reject) => { + const db = getDb(); + const { + email_notifications = true, + notification_email = '', + telegram_notifications = false, + telegram_chat_id = '', + vk_notifications = false, + vk_user_id = '' + } = settings; + + // Проверяем существование записи + db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => { + if (err) { + reject(err); + return; + } + + 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(err) { + if (err) reject(err); + else resolve(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(err) { + if (err) reject(err); + else resolve(true); + }); + } + }); + }); + } + + async sendEmailNotification(to, subject, htmlContent) { + if (!this.initialized || !this.transporter) { + console.warn('⚠️ Email уведомления отключены'); + return false; + } + + try { + const info = await this.transporter.sendMail({ + from: `"School CRM" <${process.env.YANDEX_EMAIL}>`, + to: to, + subject: subject, + html: htmlContent, + text: htmlContent.replace(/<[^>]*>/g, '') // Конвертируем HTML в текст + }); + + console.log(`📧 Email отправлен: ${to}, Message ID: ${info.messageId}`); + return true; + + } catch (error) { + console.error('❌ Ошибка отправки email:', error.message); + return false; + } + } + + async sendTaskNotification(userId, taskData, notificationType) { + try { + const settings = await this.getUserNotificationSettings(userId); + if (!settings || !settings.email_notifications) { + return false; + } + + // Используем указанную email или email из профиля пользователя + const emailTo = settings.notification_email || settings.user_email; + if (!emailTo) { + console.log(`⚠️ У пользователя ${userId} не указан email для уведомлений`); + return false; + } + + let subject = ''; + let htmlContent = ''; + + switch (notificationType) { + case 'created': + subject = `Новая задача: ${taskData.title}`; + htmlContent = this.getTaskCreatedHtml(taskData); + break; + case 'updated': + subject = `Обновлена задача: ${taskData.title}`; + htmlContent = this.getTaskUpdatedHtml(taskData); + break; + case 'rework': + subject = `Задача возвращена на доработку: ${taskData.title}`; + htmlContent = this.getTaskReworkHtml(taskData); + break; + case 'closed': + subject = `Задача закрыта: ${taskData.title}`; + htmlContent = this.getTaskClosedHtml(taskData); + break; + case 'status_changed': + subject = `Изменен статус задачи: ${taskData.title}`; + htmlContent = this.getStatusChangedHtml(taskData); + break; + case 'deadline': + subject = `Скоро срок выполнения: ${taskData.title}`; + htmlContent = this.getDeadlineHtml(taskData); + break; + default: + subject = `Уведомление по задаче: ${taskData.title}`; + htmlContent = this.getDefaultHtml(taskData); + } + + return await this.sendEmailNotification(emailTo, subject, htmlContent); + + } catch (error) { + console.error('❌ Ошибка отправки уведомления о задаче:', error); + return false; + } + } + + // HTML шаблоны для разных типов уведомлений + getTaskCreatedHtml(taskData) { + return ` + + + + + + +
+
+

📋 Новая задача

+
+
+
${taskData.title}
+
+

Описание: ${taskData.description || 'Без описания'}

+

Срок выполнения: ${new Date(taskData.due_date).toLocaleString('ru-RU')}

+

Создал: ${taskData.author_name || 'Неизвестно'}

+
+

Для просмотра подробной информации перейдите в систему управления задачами.

+ Перейти в CRM +
+ +
+ + + `; + } + + getTaskUpdatedHtml(taskData) { + return ` + + + + + + +
+
+

🔄 Обновлена задача

+
+
+
${taskData.title}
+
+

Изменения внес: ${taskData.author_name || 'Неизвестно'}

+

Время: ${new Date().toLocaleString('ru-RU')}

+
+

Для просмотра изменений перейдите в систему управления задачами.

+ Перейти в CRM +
+ +
+ + + `; + } + + getTaskReworkHtml(taskData) { + return ` + + + + + + +
+
+

🔄 Задача возвращена на доработку

+
+
+
${taskData.title}
+
+

Автор замечания: ${taskData.author_name || 'Неизвестно'}

+
+
+

Комментарий:

+

${taskData.comment || 'Требуется доработка'}

+
+

Пожалуйста, исправьте замечания и обновите статус задачи.

+ Перейти к задаче +
+ +
+ + + `; + } + + getTaskClosedHtml(taskData) { + return ` + + + + + + +
+
+

✅ Задача закрыта

+
+
+
${taskData.title}
+
+

Закрыта: ${taskData.author_name || 'Неизвестно'}

+

Время закрытия: ${new Date().toLocaleString('ru-RU')}

+
+

Задача завершена и перемещена в архив.

+ Перейти в CRM +
+ +
+ + + `; + } + + getStatusChangedHtml(taskData) { + return ` + + + + + + +
+
+

🔄 Изменен статус задачи

+
+
+
${taskData.title}
+
+

Новый статус: ${this.getStatusText(taskData.status)}

+

Изменил: ${taskData.user_name || taskData.author_name || 'Неизвестно'}

+

Время: ${new Date().toLocaleString('ru-RU')}

+
+

Для просмотра деталей перейдите в систему управления задачами.

+ Перейти в CRM +
+ +
+ + + `; + } + + getDeadlineHtml(taskData) { + return ` + + + + + + +
+
+

⚠️ Скоро срок выполнения

+
+
+
${taskData.title}
+
+

ВНИМАНИЕ! До окончания срока задачи осталось менее ${taskData.hours_left} часов!

+

Срок выполнения: ${new Date(taskData.due_date).toLocaleString('ru-RU')}

+
+

Пожалуйста, завершите задачу в указанный срок.

+ Перейти к задаче +
+ +
+ + + `; + } + + getStatusText(status) { + const statusMap = { + 'assigned': 'Назначена', + 'in_progress': 'В работе', + 'completed': 'Завершена', + 'overdue': 'Просрочена', + 'rework': 'На доработке' + }; + return statusMap[status] || status; + } + + getDefaultHtml(taskData) { + return ` + + + + + + +
+
+

📢 Уведомление от School CRM

+
+
+
${taskData.title}
+
+

${taskData.message || 'Новое уведомление по задаче'}

+
+ Перейти в CRM +
+ +
+ + + `; + } + + isReady() { + return this.initialized; + } +} + +// Singleton +const emailNotifications = new EmailNotifications(); +module.exports = emailNotifications; \ No newline at end of file diff --git a/notifications.js b/notifications.js index d17f857..124b7a7 100644 --- a/notifications.js +++ b/notifications.js @@ -1,97 +1,7 @@ -const fetch = require('node-fetch'); +// notifications.js const postgresLogger = require('./postgres'); const { getDb } = require('./database'); - -async function sendDeadlineNotification(assignment, hoursLeft) { - try { - if (!process.env.NOTIFICATION_SERVICE_URL || - !process.env.NOTIFICATION_SERVICE_LOGIN || - !process.env.NOTIFICATION_SERVICE_PASSWORD) { - return; - } - - const notificationKey = `deadline_${hoursLeft}h_task_${assignment.task_id}_user_${assignment.user_id}`; - const lastSent = await getLastNotificationSent(notificationKey); - const now = new Date(); - - if (lastSent) { - const timeSinceLast = now.getTime() - new Date(lastSent).getTime(); - if (timeSinceLast < 12 * 60 * 60 * 1000) { - return; - } - } - - const subject = `⚠️ До окончания срока задачи осталось менее ${hoursLeft} часов`; - const content = `Задача: ${assignment.title}\n\n` + - `Описание: ${assignment.description || 'Без описания'}\n` + - `Срок выполнения: ${new Date(assignment.due_date).toLocaleString('ru-RU')}\n` + - `Осталось времени: ${hoursLeft} часов\n\n` + - `Пожалуйста, завершите задачу в срок.`; - - const recipients = [ - { id: assignment.user_id, name: assignment.user_name, email: assignment.user_email }, - { id: assignment.created_by, name: assignment.creator_name, email: assignment.creator_email } - ].filter((value, index, self) => - self.findIndex(r => r.id === value.id) === index - ); - - const recipientIds = recipients.map(r => r.id); - - const authHeader = encodeBasicAuth( - process.env.NOTIFICATION_SERVICE_LOGIN, - process.env.NOTIFICATION_SERVICE_PASSWORD - ); - - const FormData = require('form-data'); - const formData = new FormData(); - formData.append('subject', subject); - formData.append('content', content); - formData.append('recipients', JSON.stringify(recipientIds)); - formData.append('deliveryMethods', JSON.stringify(['email', 'telegram', 'vk'])); - - const response = await fetch(process.env.NOTIFICATION_SERVICE_URL, { - method: 'POST', - headers: { - 'Authorization': `Basic ${authHeader}` - }, - body: formData - }); - - if (response.ok) { - await saveNotificationSent(notificationKey); - console.log(`✅ Уведомление о сроке (${hoursLeft}ч) отправлено для задачи ${assignment.task_id}`); - } - } catch (error) { - console.error('❌ Ошибка отправки уведомления о сроке:', error); - } -} - -function getLastNotificationSent(key) { - return new Promise((resolve) => { - const db = getDb(); - if (!db) { - resolve(null); - return; - } - - db.get("SELECT created_at FROM notification_logs WHERE notification_key = ? ORDER BY created_at DESC LIMIT 1", - [key], (err, row) => { - resolve(row ? row.created_at : null); - } - ); - }); -} - -function saveNotificationSent(key) { - const db = getDb(); - if (!db) return; - - db.run("INSERT INTO notification_logs (notification_key) VALUES (?)", [key]); -} - -function encodeBasicAuth(login, password) { - return Buffer.from(`${login}:${password}`).toString('base64'); -} +const emailNotifications = require('./email-notifications'); async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, authorId, comment = '', status = '', userName = '') { try { @@ -101,31 +11,10 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a return; } - if (!process.env.NOTIFICATION_SERVICE_URL || - !process.env.NOTIFICATION_SERVICE_LOGIN || - !process.env.NOTIFICATION_SERVICE_PASSWORD) { - console.log('⚠️ Настройки сервиса уведомлений не заданы'); - - // Логируем в PostgreSQL даже если уведомления не отправляются - await logNotificationToPostgres({ - type, - taskId, - taskTitle, - taskDescription, - authorId, - comment, - status, - userName, - error: 'Сервис уведомлений не настроен' - }); - - return; - } - console.log(`📢 Начинаем отправку уведомлений для задачи ${taskId}:`); console.log(` Тип: ${type}, Автор действия: ${authorId}, Название: ${taskTitle}`); - // Получаем заказчика (создателя задачи) ОТДЕЛЬНО + // Получаем заказчика const creator = await new Promise((resolve, reject) => { db.get(` SELECT t.created_by as user_id, u.name as user_name, u.login as user_login, u.email @@ -138,7 +27,7 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a }); }); - // Получаем исполнителей ОТДЕЛЬНО + // Получаем исполнителей const assignees = await new Promise((resolve, reject) => { db.all(` SELECT ta.user_id, u.name as user_name, u.login as user_login, u.email @@ -151,29 +40,9 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a }); }); - // Собираем всех участников - const participants = []; - if (creator) { - participants.push({ - ...creator, - role: 'creator', - is_creator: true - }); - } - - if (assignees && assignees.length > 0) { - assignees.forEach(assignee => { - participants.push({ - ...assignee, - role: 'assignee', - is_creator: false - }); - }); - } - // Получаем информацию об авторе действия const author = await new Promise((resolve, reject) => { - db.get("SELECT name, login FROM users WHERE id = ?", [authorId], (err, row) => { + db.get("SELECT name, login, email FROM users WHERE id = ?", [authorId], (err, row) => { if (err) reject(err); else resolve(row); }); @@ -182,148 +51,134 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a const authorName = author ? author.name : 'Система'; const authorLogin = author ? author.login : 'system'; - // Логируем в PostgreSQL - const postgresLogIds = await logNotificationToPostgres({ - type, - taskId, - taskTitle, - taskDescription, - authorId, - authorName, - authorLogin, - participants, - comment, - status, - userName - }); - - let subject, content; - - switch (type) { - case 'created': - subject = `Новая задача: ${taskTitle}`; - content = `Создана новая задача:\n\n` + - `📋 ${taskTitle}\n` + - `📝 ${taskDescription || 'Без описания'}\n` + - `👤 Автор: ${authorName}\n\n` + - `Для просмотра перейдите в систему управления задачами.`; - break; - - case 'updated': - subject = `Обновлена задача: ${taskTitle}`; - content = `Задача была обновлена:\n\n` + - `📋 ${taskTitle}\n` + - `📝 ${taskDescription || 'Без описания'}\n` + - `👤 Изменено: ${authorName}\n\n` + - `Для просмотра изменений перейдите в систему управления задачами.`; - break; - - case 'rework': - subject = `Задача возвращена на доработку: ${taskTitle}`; - content = `Задача возвращена на доработку:\n\n` + - `📋 ${taskTitle}\n` + - `📝 Комментарий: ${comment}\n` + - `👤 Автор замечания: ${authorName}\n\n` + - `Пожалуйста, исправьте замечания и обновите статус задачи.`; - break; - - case 'closed': - subject = `Задача закрыта: ${taskTitle}`; - content = `Задача была закрыта:\n\n` + - `📋 ${taskTitle}\n` + - `👤 Закрыта: ${authorName}\n\n` + - `Задача завершена и перемещена в архив.`; - break; - - case 'status_changed': - const statusText = getStatusText(status); - subject = `Изменен статус задачи: ${taskTitle}`; - content = `Статус задачи изменен:\n\n` + - `📋 ${taskTitle}\n` + - `🔄 Новый статус: ${statusText}\n` + - `👤 Изменил: ${userName || authorName}\n\n` + - `Для просмотра перейдите в систему управления задачами.`; - break; - - default: - console.log(`⚠️ Неизвестный тип уведомления: ${type}`); - return; - } - - // Фильтруем получателей: исключаем автора действия - const recipientIds = participants - .filter(p => { - const shouldExclude = p.user_id === authorId; - if (shouldExclude) { - console.log(` ✋ Исключаем автора действия: ${p.user_name} (ID: ${p.user_id})`); - } - return !shouldExclude; - }) - .map(p => p.user_id); - - if (recipientIds.length === 0) { - console.log('❌ Нет получателей для уведомления (все участники - автор изменения)'); - - // Обновляем статус в PostgreSQL - await updatePostgresLogStatus(postgresLogIds, 'no_recipients', 'Нет получателей после фильтрации'); - return; - } - - const authHeader = encodeBasicAuth( - process.env.NOTIFICATION_SERVICE_LOGIN, - process.env.NOTIFICATION_SERVICE_PASSWORD - ); - - const FormData = require('form-data'); - const formData = new FormData(); - formData.append('subject', subject); - formData.append('content', content); - formData.append('recipients', JSON.stringify(recipientIds)); - formData.append('deliveryMethods', JSON.stringify(['email', 'telegram', 'vk'])); - - console.log(`🚀 Отправляем запрос на сервис уведомлений...`); - - try { - const response = await fetch(process.env.NOTIFICATION_SERVICE_URL, { - method: 'POST', - headers: { - 'Authorization': `Basic ${authHeader}` - }, - body: formData - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const result = await response.json(); - console.log(`✅ Уведомления успешно отправлены для задачи ${taskId}`); - - // Обновляем статус в PostgreSQL - await updatePostgresLogStatus(postgresLogIds, 'sent', null, new Date().toISOString()); - - console.log(` Результат от сервиса:`, result); - - } catch (error) { - console.error('❌ Ошибка отправки уведомлений:', error); - - // Обновляем статус в PostgreSQL - await updatePostgresLogStatus(postgresLogIds, 'failed', error.message); - - console.error(' Детали ошибки:', { - taskId, + // Логируем в PostgreSQL (если настроено) + let postgresLogIds = []; + if (postgresLogger.initialized) { + postgresLogIds = await logNotificationToPostgres({ type, + taskId, + taskTitle, + taskDescription, authorId, - errorMessage: error.message, - stack: error.stack + authorName, + authorLogin, + participants: [...(creator ? [{...creator, role: 'creator'}] : []), ...assignees.map(a => ({...a, role: 'assignee'}))], + comment, + status, + userName }); } + // Отправляем email уведомления + const participants = [...(creator ? [creator] : []), ...assignees].filter(p => p.user_id !== authorId); + + for (const participant of participants) { + const taskData = { + taskId, + title: taskTitle, + description: taskDescription, + due_date: null, // Можно добавить получение срока из БД + author_name: authorName, + comment: comment, + status: status, + user_name: userName || participant.user_name, + hours_left: type === 'deadline' ? 24 : null // Для уведомлений о дедлайне + }; + + await emailNotifications.sendTaskNotification( + participant.user_id, + taskData, + type + ); + } + + console.log(`✅ Уведомления отправлены для задачи ${taskId}`); + } catch (error) { console.error('❌ Общая ошибка при обработке уведомлений:', error); } } +// Обновим функцию для уведомлений о дедлайнах +async function sendDeadlineNotification(assignment, hoursLeft) { + try { + const taskData = { + taskId: assignment.task_id, + title: assignment.title, + description: assignment.description || '', + due_date: assignment.due_date, + author_name: assignment.creator_name, + hours_left: hoursLeft + }; + + // Отправляем уведомление исполнителю + await emailNotifications.sendTaskNotification( + assignment.user_id, + taskData, + 'deadline' + ); + + // Отправляем уведомление заказчику + await emailNotifications.sendTaskNotification( + assignment.created_by, + taskData, + 'deadline' + ); + + console.log(`✅ Email уведомление о сроке (${hoursLeft}ч) отправлено для задачи ${assignment.task_id}`); + + } catch (error) { + console.error('❌ Ошибка отправки email уведомления о сроке:', error); + } +} + +async function checkUpcomingDeadlines() { + const now = new Date(); + const in48Hours = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString(); + const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + const nowISO = now.toISOString(); + + const query = ` + SELECT DISTINCT ta.*, t.title, t.description, t.created_by, u.name as user_name, u.email as user_email, + creator.name as creator_name, creator.email as creator_email + FROM task_assignments ta + JOIN tasks t ON ta.task_id = t.id + JOIN users u ON ta.user_id = u.id + JOIN users creator ON t.created_by = creator.id + WHERE ta.due_date IS NOT NULL + AND ta.due_date > ? + AND ta.due_date <= ? + AND ta.status NOT IN ('completed', 'overdue') + AND t.status = 'active' + AND t.closed_at IS NULL + `; + + const db = getDb(); + if (!db) { + console.error('❌ База данных не доступна для проверки сроков'); + return; + } + + db.all(query, [nowISO, in48Hours], async (err, assignments) => { + if (err) { + console.error('❌ Ошибка при проверке сроков задач:', err); + return; + } + + for (const assignment of assignments) { + const dueDate = new Date(assignment.due_date); + const timeLeft = dueDate.getTime() - now.getTime(); + const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); + + if (hoursLeft <= 48 && hoursLeft > 24) { + await sendDeadlineNotification(assignment, 48); + } else if (hoursLeft <= 24) { + await sendDeadlineNotification(assignment, 24); + } + } + }); +} + // Вспомогательные функции для работы с PostgreSQL async function logNotificationToPostgres(data) { try { @@ -433,57 +288,11 @@ function getStatusText(status) { return statusMap[status] || status; } -function checkUpcomingDeadlines() { - const now = new Date(); - const in48Hours = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString(); - const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); - const nowISO = now.toISOString(); - - const query = ` - SELECT DISTINCT ta.*, t.title, t.description, t.created_by, u.name as user_name, u.email as user_email, - creator.name as creator_name, creator.email as creator_email - FROM task_assignments ta - JOIN tasks t ON ta.task_id = t.id - JOIN users u ON ta.user_id = u.id - JOIN users creator ON t.created_by = creator.id - WHERE ta.due_date IS NOT NULL - AND ta.due_date > ? - AND ta.due_date <= ? - AND ta.status NOT IN ('completed', 'overdue') - AND t.status = 'active' - AND t.closed_at IS NULL - `; - - const db = getDb(); - if (!db) { - console.error('❌ База данных не доступна для проверки сроков'); - return; - } - - db.all(query, [nowISO, in48Hours], async (err, assignments) => { - if (err) { - console.error('❌ Ошибка при проверке сроков задач:', err); - return; - } - - for (const assignment of assignments) { - const dueDate = new Date(assignment.due_date); - const timeLeft = dueDate.getTime() - now.getTime(); - const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); - - if (hoursLeft <= 48 && hoursLeft > 24) { - await sendDeadlineNotification(assignment, 48); - } else if (hoursLeft <= 24) { - await sendDeadlineNotification(assignment, 24); - } - } - }); -} - // Экспортируем функции module.exports = { sendTaskNotifications, checkUpcomingDeadlines, sendDeadlineNotification, - getStatusText + getStatusText, + emailNotifications // Экспортируем для доступа к методам }; \ No newline at end of file diff --git a/package.json b/package.json index 4610258..0b22f85 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "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" }, diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..6abd9c7 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,97 @@ +// auth.js - Аутентификация и авторизация +let currentUser = null; + +async function checkAuth() { + try { + const response = await fetch('/api/user'); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + showMainInterface(); + } else { + showLoginInterface(); + } + } catch (error) { + showLoginInterface(); + } +} + +function showLoginInterface() { + document.getElementById('login-modal').style.display = 'block'; + document.querySelector('.container').style.display = 'none'; +} + +function showMainInterface() { + document.getElementById('login-modal').style.display = 'none'; + document.querySelector('.container').style.display = 'block'; + + let userInfo = `Вы вошли как: ${currentUser.name}`; + if (currentUser.auth_type === 'ldap') { + userInfo += ` (LDAP)`; + } + if (currentUser.groups && currentUser.groups.length > 0) { + userInfo += ` | Группы: ${currentUser.groups.join(', ')}`; + } + + document.getElementById('current-user').textContent = userInfo; + + document.getElementById('tasks-controls').style.display = 'block'; + + const showDeletedLabel = document.querySelector('.show-deleted-label'); + if (showDeletedLabel) { + if (currentUser.role === 'admin') { + showDeletedLabel.style.display = 'flex'; + } else { + showDeletedLabel.style.display = 'none'; + } + } + + loadUsers(); + loadTasks(); + loadActivityLogs(); + showSection('tasks'); + loadKanbanTasks(); + + showingTasksWithoutDate = false; + const btn = document.getElementById('tasks-no-date-btn'); + if (btn) btn.classList.remove('active'); +} + +async function login(event) { + event.preventDefault(); + + const login = document.getElementById('login').value; + const password = document.getElementById('password').value; + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ login, password }) + }); + + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + showMainInterface(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка входа'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка подключения к серверу'); + } +} + +async function logout() { + try { + await fetch('/api/logout', { method: 'POST' }); + currentUser = null; + showLoginInterface(); + } catch (error) { + console.error('Ошибка выхода:', error); + } +} \ No newline at end of file diff --git a/public/files.js b/public/files.js new file mode 100644 index 0000000..d37666a --- /dev/null +++ b/public/files.js @@ -0,0 +1,451 @@ +// files.js - Работа с файлами +let currentTaskFiles = []; +let currentEditTaskFiles = []; + +function initializeFileUploads() { + // Создание задачи + document.getElementById('files').addEventListener('change', function(e) { + currentTaskFiles = Array.from(e.target.files); + updateFileList(); + }); + + // Редактирование задачи + document.getElementById('edit-files').addEventListener('change', function(e) { + const newFiles = Array.from(e.target.files); + currentEditTaskFiles.push(...newFiles); + updateEditFileList(); + }); +} + +function updateFileList() { + const fileInput = document.getElementById('files'); + const fileList = document.getElementById('file-list'); + updateFileListForInput(fileInput, fileList); +} + +function updateEditFileList() { + const fileInput = document.getElementById('edit-files'); + const fileList = document.getElementById('edit-file-list'); + + // Используем улучшенный рендеринг файлов + const files = fileInput.files; + const existingFiles = currentEditTaskFiles.filter(file => !(file instanceof File)); + + if (files.length === 0 && existingFiles.length === 0) { + fileList.innerHTML = ''; + return; + } + + let html = ''; + html += `

Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB

`; + + fileList.innerHTML = html; +} + +function updateFileListForInput(fileInput, fileList) { + const files = fileInput.files; + + if (files.length === 0) { + fileList.innerHTML = ''; + return; + } + + let html = ''; + html += `

Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB

`; + + fileList.innerHTML = html; +} + +// Удаление файлов из списка +function removeFile(index) { + currentTaskFiles.splice(index, 1); + updateFileList(); + + // Обновляем input files + const dataTransfer = new DataTransfer(); + currentTaskFiles.forEach(file => dataTransfer.items.add(file)); + document.getElementById('files').files = dataTransfer.files; +} + +function removeEditFile(index) { + currentEditTaskFiles.splice(index, 1); + updateEditFileList(); + + // Обновляем input files + const dataTransfer = new DataTransfer(); + const newFiles = currentEditTaskFiles.filter(file => file instanceof File); + newFiles.forEach(file => dataTransfer.items.add(file)); + document.getElementById('edit-files').files = dataTransfer.files; +} + +async function loadTaskFiles(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}/files`); + const files = await response.json(); + + const container = document.getElementById(`files-${taskId}`); + if (container) { + if (files.length === 0) { + container.innerHTML = 'Файлы: скрыто'; + } else { + container.innerHTML = ` + Файлы: +
+ ${files.map(file => renderFileIcon(file)).join('')} +
+ `; + } + } + } catch (error) { + console.error('Ошибка загрузки файлов:', error); + } +} + +function renderFileIcon(file) { + // Исправляем кодировку имени файла + const fixEncoding = (str) => { + if (!str) return ''; + try { + // Пробуем разные способы декодирования + if (str.includes('Ð') || str.includes('Ñ')) { + // UTF-8 неправильно декодированный как Latin-1 + return decodeURIComponent(escape(str)); + } + return str; + } catch (e) { + return str; + } + }; + + const fileName = fixEncoding(file.original_name); + const fileSize = (file.file_size / 1024 / 1024).toFixed(2); + const uploadedBy = file.user_name; + + let iconColor = ''; + let iconText = ''; + let textClass = ''; + + // Определяем расширение файла + const extension = fileName.includes('.') ? + fileName.split('.').pop().toLowerCase() : + ''; + + // Определяем тип файла на основе расширения + if (extension) { + switch (extension) { + case 'pdf': + iconColor = '#e74c3c'; + iconText = 'PDF'; + textClass = 'short'; + break; + case 'doc': + iconColor = '#3498db'; + iconText = 'DOC'; + textClass = 'short'; + break; + case 'docx': + iconColor = '#3498db'; + iconText = 'DOCX'; + textClass = 'medium'; + break; + case 'xls': + iconColor = '#2ecc71'; + iconText = 'XLS'; + textClass = 'short'; + break; + case 'xlsx': + iconColor = '#2ecc71'; + iconText = 'XLSX'; + textClass = 'medium'; + break; + case 'csv': + iconColor = '#2ecc71'; + iconText = 'CSV'; + textClass = 'short'; + break; + case 'ppt': + iconColor = '#e67e22'; + iconText = 'PPT'; + textClass = 'short'; + break; + case 'pptx': + iconColor = '#e67e22'; + iconText = 'PPTX'; + textClass = 'medium'; + break; + case 'zip': + iconColor = '#f39c12'; + iconText = 'ZIP'; + textClass = 'short'; + break; + case 'rar': + iconColor = '#f39c12'; + iconText = 'RAR'; + textClass = 'short'; + break; + case '7z': + iconColor = '#f39c12'; + iconText = '7Z'; + textClass = 'short'; + break; + case 'tar': + iconColor = '#f39c12'; + iconText = 'TAR'; + textClass = 'short'; + break; + case 'gz': + iconColor = '#f39c12'; + iconText = 'GZ'; + textClass = 'short'; + break; + case 'txt': + iconColor = '#95a5a6'; + iconText = 'TXT'; + textClass = 'short'; + break; + case 'log': + iconColor = '#95a5a6'; + iconText = 'LOG'; + textClass = 'short'; + break; + case 'md': + iconColor = '#95a5a6'; + iconText = 'MD'; + textClass = 'short'; + break; + case 'jpg': + iconColor = '#9b59b6'; + iconText = 'JPG'; + textClass = 'short'; + break; + case 'jpeg': + iconColor = '#9b59b6'; + iconText = 'JPEG'; + textClass = 'medium'; + break; + case 'png': + iconColor = '#9b59b6'; + iconText = 'PNG'; + textClass = 'short'; + break; + case 'gif': + iconColor = '#9b59b6'; + iconText = 'GIF'; + textClass = 'short'; + break; + case 'bmp': + iconColor = '#9b59b6'; + iconText = 'BMP'; + textClass = 'short'; + break; + case 'svg': + iconColor = '#9b59b6'; + iconText = 'SVG'; + textClass = 'short'; + break; + case 'webp': + iconColor = '#9b59b6'; + iconText = 'WEBP'; + textClass = 'medium'; + break; + case 'mp3': + iconColor = '#1abc9c'; + iconText = 'MP3'; + textClass = 'short'; + break; + case 'wav': + iconColor = '#1abc9c'; + iconText = 'WAV'; + textClass = 'short'; + break; + case 'ogg': + iconColor = '#1abc9c'; + iconText = 'OGG'; + textClass = 'short'; + break; + case 'flac': + iconColor = '#1abc9c'; + iconText = 'FLAC'; + textClass = 'medium'; + break; + case 'mp4': + iconColor = '#d35400'; + iconText = 'MP4'; + textClass = 'short'; + break; + case 'avi': + iconColor = '#d35400'; + iconText = 'AVI'; + textClass = 'short'; + break; + case 'mkv': + iconColor = '#d35400'; + iconText = 'MKV'; + textClass = 'short'; + break; + case 'mov': + iconColor = '#d35400'; + iconText = 'MOV'; + textClass = 'short'; + break; + case 'wmv': + iconColor = '#d35400'; + iconText = 'WMV'; + textClass = 'short'; + break; + case 'exe': + iconColor = '#c0392b'; + iconText = 'EXE'; + textClass = 'short'; + break; + case 'msi': + iconColor = '#c0392b'; + iconText = 'MSI'; + textClass = 'short'; + break; + case 'js': + iconColor = '#2980b9'; + iconText = 'JS'; + textClass = 'short'; + break; + case 'html': + iconColor = '#2980b9'; + iconText = 'HTML'; + textClass = 'medium'; + break; + case 'css': + iconColor = '#2980b9'; + iconText = 'CSS'; + textClass = 'short'; + break; + case 'php': + iconColor = '#2980b9'; + iconText = 'PHP'; + textClass = 'short'; + break; + case 'py': + iconColor = '#2980b9'; + iconText = 'PY'; + textClass = 'short'; + break; + case 'java': + iconColor = '#2980b9'; + iconText = 'JAVA'; + textClass = 'medium'; + break; + case 'json': + iconColor = '#8e44ad'; + iconText = 'JSON'; + textClass = 'medium'; + break; + case 'xml': + iconColor = '#8e44ad'; + iconText = 'XML'; + textClass = 'short'; + break; + case 'yml': + iconColor = '#8e44ad'; + iconText = 'YML'; + textClass = 'short'; + break; + case 'yaml': + iconColor = '#8e44ad'; + iconText = 'YAML'; + textClass = 'medium'; + break; + case 'sql': + iconColor = '#27ae60'; + iconText = 'SQL'; + textClass = 'short'; + break; + case 'db': + iconColor = '#27ae60'; + iconText = 'DB'; + textClass = 'short'; + break; + case 'sqlite': + iconColor = '#27ae60'; + iconText = 'SQLITE'; + textClass = 'long'; + break; + default: + // Для других расширений используем расширение или первые 4 символа + iconColor = '#7f8c8d'; + iconText = extension.length > 4 ? + extension.substring(0, 4).toUpperCase() : + extension.toUpperCase(); + + // Определяем класс по длине текста + if (iconText.length <= 2) { + textClass = 'short'; + } else if (iconText.length <= 4) { + textClass = 'medium'; + } else { + textClass = 'long'; + } + } + } else { + // Если нет расширения + iconColor = '#7f8c8d'; + iconText = 'ФАЙЛ'; + textClass = 'short'; + } + + // Исправляем кодировку для отображения + const safeFileName = fileName; + const displayFileName = truncateFileName(safeFileName); + + return ` + +
+ ${iconText} +
+
${displayFileName}
+
+ `; +} + +function truncateFileName(fileName, maxLength = 20) { + if (fileName.length <= maxLength) return fileName; + const extension = fileName.split('.').pop(); + const name = fileName.substring(0, fileName.lastIndexOf('.')); + const truncatedName = name.substring(0, maxLength - extension.length - 3) + '...'; + return truncatedName + '.' + extension; +} + +// Вспомогательная функция для форматирования размера файла +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index b47d7ca..0ef7809 100644 --- a/public/index.html +++ b/public/index.html @@ -5,26 +5,29 @@ School CRM - Управление задачами + @@ -208,7 +299,7 @@ @@ -224,27 +317,53 @@ -
-
-

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

-

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

+ +
+
+

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

+

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

+
+
+ + +
+
+
+ +
+
Загрузка Канбан-доски...
+
- -
-
Загрузка Канбан-доски...
-
-
- + + + + + + + + + \ No newline at end of file diff --git a/public/kanban.js b/public/kanban.js new file mode 100644 index 0000000..3875d2c --- /dev/null +++ b/public/kanban.js @@ -0,0 +1,300 @@ +// kanban.js - Канбан-доска +let kanbanTasks = []; +let kanbanDays = 14; +let currentDraggedTask = null; + +function showKanbanSection() { + showSection('kanban'); + loadKanbanTasks(); +} + +async function loadKanbanTasks() { + try { + const daysSelect = document.getElementById('kanban-days'); + const filterSelect = document.getElementById('kanban-filter'); + + // Если есть выбор в интерфейсе - используем его, иначе - значение по умолчанию + if (daysSelect) { + kanbanDays = parseInt(daysSelect.value) || 14; + } else { + kanbanDays = 14; + } + + let filter = 'all'; + if (filterSelect) { + filter = filterSelect.value; + } + + const response = await fetch(`/api/kanban-tasks?days=${kanbanDays}&filter=${filter}`); + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); + } + const data = await response.json(); + kanbanTasks = data.tasks || []; + renderKanban(data.filter); + } catch (error) { + console.error('Ошибка загрузки задач для Канбана:', error); + document.getElementById('kanban-board').innerHTML = ` +
+ ❌ Ошибка загрузки Канбана: ${error.message} + +
+ `; + } +} + +function renderKanban(filter = 'all') { + const container = document.getElementById('kanban-board'); + + // Группируем задачи по статусам (убрали 'unassigned') + const columns = { + 'assigned': { title: 'Назначены', tasks: [], color: '#e74c3c' }, + 'in_progress': { title: 'В работе', tasks: [], color: '#f39c12' }, + 'rework': { title: 'На доработке', tasks: [], color: '#f1c40f' }, + 'overdue': { title: 'Просрочены', tasks: [], color: '#c0392b' }, + 'completed': { title: 'Выполнены', tasks: [], color: '#2ecc71' } + }; + + // Распределяем задачи по колонкам + kanbanTasks.forEach(task => { + const status = task.kanbanStatus || 'assigned'; + // Преобразуем 'unassigned' в 'assigned' + const actualStatus = status === 'unassigned' ? 'assigned' : status; + + if (columns[actualStatus]) { + columns[actualStatus].tasks.push(task); + } else { + // Если статус не найден, добавляем в 'assigned' + columns['assigned'].tasks.push(task); + } + }); + + // Статистика по фильтру + let filterTitle = 'Все задачи'; + if (filter === 'created') filterTitle = 'Задачи, которые я поставил'; + if (filter === 'assigned') filterTitle = 'Задачи, которые мне поставили'; + + container.innerHTML = ` +
+
+
+ + +
+
+ + +
+
+ ${filterTitle} + Всего задач: ${kanbanTasks.length} + +
+
+
+ +
+ ${Object.entries(columns).map(([status, column]) => ` +
+
+

${column.title}

+ ${column.tasks.length} +
+
+ ${renderKanbanCards(column.tasks, filter)} +
+
+ `).join('')} +
+ `; + + // Делаем колонки перетаскиваемыми (кроме 'overdue' и 'assigned') + makeKanbanDraggable(); +} + +function renderKanbanCards(tasks, filter) { + if (tasks.length === 0) { + return '
Нет задач
'; + } + + return tasks.map(task => { + // Определяем иконку роли + let roleIcon = ''; + let roleTitle = ''; + + if (task.userRole === 'creator') { + roleIcon = '👤'; + roleTitle = 'Вы поставили эту задачу'; + } else if (task.userRole === 'assignee') { + roleIcon = '🎯'; + roleTitle = 'Вам поставили эту задачу'; + } + + // Исправление: безопасное получение имени пользователя + const userName = task.assignments && task.assignments.length > 0 && task.assignments[0]?.user_name + ? task.assignments[0].user_name + : 'Неизвестно'; + + // Исправление: безопасное получение первого символа имени + const userInitial = userName && userName.length > 0 ? userName.charAt(0) : '?'; + + return ` +
+
+
#${task.id}
+
${roleIcon}
+
+ + +
+
+
+ ${task.title || 'Без названия'} +
+
+
+ ${task.due_date ? `📅 ${formatDate(task.due_date)}` : 'Без срока'} +
+
+ ${task.assignments && task.assignments.length > 0 ? + task.assignments.slice(0, 3).map(a => { + // Исправление: безопасное получение имени исполнителя + const assigneeName = a.user_name || 'Неизвестно'; + const assigneeInitial = assigneeName && assigneeName.length > 0 ? assigneeName.charAt(0) : '?'; + return `${assigneeInitial}`; + }).join('') : + '👤' + } + ${task.assignments && task.assignments.length > 3 ? + `+${task.assignments.length - 3}` : '' + } +
+
+ +
+ `; + }).join(''); +} + +function getDayWord(days) { + if (days === 1) return 'день'; + if (days >= 2 && days <= 4) return 'дня'; + return 'дней'; +} + +function makeKanbanDraggable() { + const cards = document.querySelectorAll('.kanban-card'); + const columns = document.querySelectorAll('.kanban-column-body:not([style*="opacity: 0.6"])'); + + cards.forEach(card => { + card.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', card.dataset.taskId); + card.classList.add('dragging'); + }); + + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + }); + }); + + columns.forEach(column => { + const status = column.parentElement.dataset.status; + + // Запрещаем перетаскивание в 'overdue' и 'assigned' + if (status === 'overdue' || status === 'assigned') { + return; + } + + column.addEventListener('dragover', (e) => { + e.preventDefault(); + const draggingCard = document.querySelector('.dragging'); + if (draggingCard) { + column.appendChild(draggingCard); + } + }); + + column.addEventListener('drop', async (e) => { + e.preventDefault(); + const taskId = e.dataTransfer.getData('text/plain'); + const newStatus = column.parentElement.dataset.status; + + if (taskId) { + try { + // Запрещаем установку статуса 'overdue' и 'assigned' + if (newStatus === 'overdue' || newStatus === 'assigned') { + alert('Невозможно изменить статус задачи на "Просрочены" или "Назначены" через Канбан'); + // Возвращаем задачу в исходное положение + loadKanbanTasks(); + return; + } + + // Обновляем статус на сервере + const response = await fetch(`/api/kanban-tasks/${taskId}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ status: newStatus }) + }); + + if (response.ok) { + // Перезагружаем Канбан + loadKanbanTasks(); + } else { + const error = await response.json(); + alert(`Ошибка обновления статуса: ${error.error || 'Неизвестная ошибка'}`); + // Возвращаем задачу в исходное положение + loadKanbanTasks(); + } + } catch (error) { + console.error('Ошибка обновления статуса:', error); + alert('Ошибка обновления статуса'); + loadKanbanTasks(); + } + } + }); + }); +} + +function openKanbanTask(taskId) { + // Находим задачу и открываем её в основном интерфейсе + const task = kanbanTasks.find(t => t.id == taskId); + if (task) { + showSection('tasks'); + // Прокручиваем к задаче + setTimeout(() => { + const taskElement = document.querySelector(`.task-card[data-task-id="${taskId}"]`); + if (taskElement) { + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Раскрываем задачу если она свернута + if (!expandedTasks.has(taskId)) { + toggleTask(taskId); + } + } + }, 100); + } +} + +function copyKanbanTask(taskId) { + openCopyModal(taskId); +} + +function formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ru-RU'); +} \ No newline at end of file diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..fbe8b52 --- /dev/null +++ b/public/main.js @@ -0,0 +1,38 @@ +// main.js - Главный файл инициализации +document.addEventListener('DOMContentLoaded', function() { + checkAuth(); + setupEventListeners(); + + // Устанавливаем дату по умолчанию для создания задачи (через 3 дня) + const defaultDate = new Date(); + defaultDate.setDate(defaultDate.getDate() + 3); + document.getElementById('due-date').value = defaultDate.toISOString().substring(0, 16); + + // По умолчанию показываем секцию задач + showSection('tasks'); +}); + +function setupEventListeners() { + // Форма входа + document.getElementById('login-form').addEventListener('submit', login); + + // Формы задач + document.getElementById('create-task-form').addEventListener('submit', createTask); + document.getElementById('edit-task-form').addEventListener('submit', updateTask); + document.getElementById('copy-task-form').addEventListener('submit', copyTask); + document.getElementById('edit-assignment-form').addEventListener('submit', updateAssignment); + document.getElementById('rework-task-form').addEventListener('submit', sendForRework); + + // Файлы + document.getElementById('files').addEventListener('change', updateFileList); + document.getElementById('edit-files').addEventListener('change', updateEditFileList); + + // Настройки уведомлений + const notificationForm = document.getElementById('notification-settings-form'); + if (notificationForm) { + notificationForm.addEventListener('submit', saveNotificationSettings); + } + + // Инициализация загрузки файлов + initializeFileUploads(); +} \ No newline at end of file diff --git a/public/profile.js b/public/profile.js new file mode 100644 index 0000000..3b880a1 --- /dev/null +++ b/public/profile.js @@ -0,0 +1,116 @@ +// profile.js - Личный кабинет и настройки + +// Личный кабинет +function showProfileSection() { + showSection('profile'); + loadUserProfile(); + loadNotificationSettings(); +} + +async function loadUserProfile() { + try { + const response = await fetch('/api/user'); + const data = await response.json(); + + if (data.user) { + const userInfo = document.getElementById('user-profile-info'); + userInfo.innerHTML = ` +
+
+ +
+ Имя: +

${data.user.name}

+
+
+
+ +
+ Логин: +

${data.user.login}

+
+
+
+ +
+ Email: +

${data.user.email || 'Не указан'}

+
+
+
+ +
+ Роль: +

${data.user.role === 'admin' ? 'Администратор' : 'Учитель'}

+
+
+
+ +
+ Тип авторизации: +

${data.user.auth_type === 'ldap' ? 'LDAP' : 'Локальная'}

+
+
+ ${data.user.groups && data.user.groups.length > 0 ? ` +
+ +
+ Группы: +

${Array.isArray(data.user.groups) ? data.user.groups.join(', ') : data.user.groups}

+
+
+ ` : ''} +
+ `; + } + } catch (error) { + console.error('Ошибка загрузки профиля:', error); + } +} + +// Настройки уведомлений +async function loadNotificationSettings() { + try { + const response = await fetch('/api/user/settings'); + const settings = await response.json(); + + document.getElementById('email-notifications').checked = settings.email_notifications; + document.getElementById('notification-email').value = settings.notification_email || ''; + document.getElementById('telegram-notifications').checked = settings.telegram_notifications; + document.getElementById('vk-notifications').checked = settings.vk_notifications; + } catch (error) { + console.error('Ошибка загрузки настроек:', error); + } +} + +async function saveNotificationSettings(event) { + event.preventDefault(); + + const settings = { + email_notifications: document.getElementById('email-notifications').checked, + notification_email: document.getElementById('notification-email').value.trim(), + telegram_notifications: document.getElementById('telegram-notifications').checked, + vk_notifications: document.getElementById('vk-notifications').checked + }; + + try { + const response = await fetch('/api/user/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + const result = await response.json(); + + if (result.success) { + alert('Настройки уведомлений сохранены!'); + } else { + alert('Ошибка сохранения настроек: ' + (result.error || 'Неизвестная ошибка')); + } + } catch (error) { + console.error('Ошибка сохранения настроек:', error); + alert('Ошибка сохранения настроек'); + } +} \ No newline at end of file diff --git a/public/script.js b/public/script.js deleted file mode 100644 index 969db24..0000000 --- a/public/script.js +++ /dev/null @@ -1,1932 +0,0 @@ -let currentUser = null; -let users = []; -let tasks = []; -let filteredUsers = []; -let expandedTasks = new Set(); -let showingTasksWithoutDate = false; - -let kanbanTasks = []; -let kanbanDays = 14; - -document.addEventListener('DOMContentLoaded', function() { - checkAuth(); - setupEventListeners(); -}); - -async function checkAuth() { - try { - const response = await fetch('/api/user'); - if (response.ok) { - const data = await response.json(); - currentUser = data.user; - showMainInterface(); - } else { - showLoginInterface(); - } - } catch (error) { - showLoginInterface(); - } -} - -function showLoginInterface() { - document.getElementById('login-modal').style.display = 'block'; - document.querySelector('.container').style.display = 'none'; -} - -function showMainInterface() { - document.getElementById('login-modal').style.display = 'none'; - document.querySelector('.container').style.display = 'block'; - - let userInfo = `Вы вошли как: ${currentUser.name}`; - if (currentUser.auth_type === 'ldap') { - userInfo += ` (LDAP)`; - } - if (currentUser.groups && currentUser.groups.length > 0) { - userInfo += ` | Группы: ${currentUser.groups.join(', ')}`; - } - - document.getElementById('current-user').textContent = userInfo; - - document.getElementById('tasks-controls').style.display = 'block'; - - const showDeletedLabel = document.querySelector('.show-deleted-label'); - if (showDeletedLabel) { - if (currentUser.role === 'admin') { - showDeletedLabel.style.display = 'flex'; - } else { - showDeletedLabel.style.display = 'none'; - } - } - - loadUsers(); - loadTasks(); - loadActivityLogs(); - showSection('tasks'); - loadKanbanTasks(); - - showingTasksWithoutDate = false; - const btn = document.getElementById('tasks-no-date-btn'); - if (btn) btn.classList.remove('active'); -} - -function setupEventListeners() { - document.getElementById('login-form').addEventListener('submit', login); - document.getElementById('create-task-form').addEventListener('submit', createTask); - document.getElementById('edit-task-form').addEventListener('submit', updateTask); - document.getElementById('copy-task-form').addEventListener('submit', copyTask); - document.getElementById('edit-assignment-form').addEventListener('submit', updateAssignment); - document.getElementById('rework-task-form').addEventListener('submit', sendForRework); - document.getElementById('files').addEventListener('change', updateFileList); - document.getElementById('edit-files').addEventListener('change', updateEditFileList); -} - -async function login(event) { - event.preventDefault(); - - const login = document.getElementById('login').value; - const password = document.getElementById('password').value; - - try { - const response = await fetch('/api/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ login, password }) - }); - - if (response.ok) { - const data = await response.json(); - currentUser = data.user; - showMainInterface(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка входа'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка подключения к серверу'); - } -} - -async function logout() { - try { - await fetch('/api/logout', { method: 'POST' }); - currentUser = null; - showLoginInterface(); - } catch (error) { - console.error('Ошибка выхода:', error); - } -} - -function showSection(sectionName) { - document.querySelectorAll('.section').forEach(section => { - section.classList.remove('active'); - }); - - document.getElementById(sectionName + '-section').classList.add('active'); - - if (sectionName === 'tasks') { - loadTasks(); - } else if (sectionName === 'logs') { - loadActivityLogs(); - } - if (sectionName === 'kanban') { - loadKanbanTasks(); -} -} - -async function loadUsers() { - try { - const response = await fetch('/api/users'); - users = await response.json(); - filteredUsers = [...users]; - renderUsersChecklist(); - renderEditUsersChecklist(); - renderCopyUsersChecklist(); - populateFilterDropdowns(); - } catch (error) { - console.error('Ошибка загрузки пользователей:', error); - } -} - -function populateFilterDropdowns() { - const creatorFilter = document.getElementById('creator-filter'); - const assigneeFilter = document.getElementById('assignee-filter'); - - creatorFilter.innerHTML = ''; - assigneeFilter.innerHTML = ''; - - users.forEach(user => { - const creatorOption = document.createElement('option'); - creatorOption.value = user.id; - creatorOption.textContent = `${user.name} (${user.login})`; - creatorFilter.appendChild(creatorOption.cloneNode(true)); - - const assigneeOption = creatorOption.cloneNode(true); - assigneeFilter.appendChild(assigneeOption); - }); -} - -function showKanbanSection() { - showSection('kanban'); -} - -async function loadKanbanTasks() { - try { - const daysSelect = document.getElementById('kanban-days'); - const filterSelect = document.getElementById('kanban-filter'); - - // Если есть выбор в интерфейсе - используем его, иначе - значение по умолчанию - if (daysSelect) { - kanbanDays = parseInt(daysSelect.value) || 14; - } else { - kanbanDays = 14; - } - - let filter = 'all'; - if (filterSelect) { - filter = filterSelect.value; - } - - const response = await fetch(`/api/kanban-tasks?days=${kanbanDays}&filter=${filter}`); - if (!response.ok) { - throw new Error(`Ошибка сервера: ${response.status}`); - } - const data = await response.json(); - kanbanTasks = data.tasks || []; - renderKanban(data.filter); - } catch (error) { - console.error('Ошибка загрузки задач для Канбана:', error); - document.getElementById('kanban-board').innerHTML = ` -
- ❌ Ошибка загрузки Канбана: ${error.message} - -
- `; - } -} - -function renderKanban() { - const container = document.getElementById('kanban-board'); - - // Группируем задачи по статусам - const columns = { - 'unassigned': { title: 'Не назначены', tasks: [], color: '#95a5a6' }, - 'assigned': { title: 'Назначены', tasks: [], color: '#e74c3c' }, - 'in_progress': { title: 'В работе', tasks: [], color: '#f39c12' }, - 'rework': { title: 'На доработке', tasks: [], color: '#f1c40f' }, - 'overdue': { title: 'Просрочены', tasks: [], color: '#c0392b' }, - 'completed': { title: 'Выполнены', tasks: [], color: '#2ecc71' } - }; - - // Распределяем задачи по колонкам - kanbanTasks.forEach(task => { - const status = task.kanbanStatus || 'unassigned'; - if (columns[status]) { - columns[status].tasks.push(task); - } - }); - - // Рендерим доску - container.innerHTML = ` -
-
- - -
-
- Всего задач: ${kanbanTasks.length} - -
-
- -
- ${Object.entries(columns).map(([status, column]) => ` -
-
-

${column.title}

- ${column.tasks.length} -
-
- ${renderKanbanCards(column.tasks)} -
-
- `).join('')} -
- `; - - // Делаем колонки перетаскиваемыми - makeKanbanDraggable(); -} -function renderKanbanCards(tasks, filter) { - if (tasks.length === 0) { - return '
Нет задач
'; - } - - return tasks.map(task => { - // Определяем иконку роли - let roleIcon = ''; - let roleTitle = ''; - - if (task.userRole === 'creator') { - roleIcon = '👤'; - roleTitle = 'Вы поставили эту задачу'; - } else if (task.userRole === 'assignee') { - roleIcon = '🎯'; - roleTitle = 'Вам поставили эту задачу'; - } - - // Исправление: безопасное получение имени пользователя - const userName = task.assignments && task.assignments.length > 0 && task.assignments[0]?.user_name - ? task.assignments[0].user_name - : 'Неизвестно'; - - // Исправление: безопасное получение первого символа имени - const userInitial = userName && userName.length > 0 ? userName.charAt(0) : '?'; - - return ` -
-
-
#${task.id}
-
${roleIcon}
-
- - -
-
-
- ${task.title || 'Без названия'} -
-
-
- ${task.due_date ? `📅 ${formatDate(task.due_date)}` : 'Без срока'} -
-
- ${task.assignments && task.assignments.length > 0 ? - task.assignments.slice(0, 3).map(a => { - // Исправление: безопасное получение имени исполнителя - const assigneeName = a.user_name || 'Неизвестно'; - const assigneeInitial = assigneeName && assigneeName.length > 0 ? assigneeName.charAt(0) : '?'; - return `${assigneeInitial}`; - }).join('') : - '👤' - } - ${task.assignments && task.assignments.length > 3 ? - `+${task.assignments.length - 3}` : '' - } -
-
- -
- `; - }).join(''); -} -function renderKanban(filter = 'all') { - const container = document.getElementById('kanban-board'); - - // Группируем задачи по статусам (убрали 'unassigned') - const columns = { - 'assigned': { title: 'Назначены', tasks: [], color: '#e74c3c' }, - 'in_progress': { title: 'В работе', tasks: [], color: '#f39c12' }, - 'rework': { title: 'На доработке', tasks: [], color: '#f1c40f' }, - 'overdue': { title: 'Просрочены', tasks: [], color: '#c0392b' }, - 'completed': { title: 'Выполнены', tasks: [], color: '#2ecc71' } - }; - - // Распределяем задачи по колонкам - kanbanTasks.forEach(task => { - const status = task.kanbanStatus || 'assigned'; - // Преобразуем 'unassigned' в 'assigned' - const actualStatus = status === 'unassigned' ? 'assigned' : status; - - if (columns[actualStatus]) { - columns[actualStatus].tasks.push(task); - } else { - // Если статус не найден, добавляем в 'assigned' - columns['assigned'].tasks.push(task); - } - }); - - // Статистика по фильтру - let filterTitle = 'Все задачи'; - if (filter === 'created') filterTitle = 'Задачи, которые я поставил'; - if (filter === 'assigned') filterTitle = 'Задачи, которые мне поставили'; - - container.innerHTML = ` -
-
-
- - -
-
- - -
-
- ${filterTitle} - Всего задач: ${kanbanTasks.length} - -
-
-
- -
- ${Object.entries(columns).map(([status, column]) => ` -
-
-

${column.title}

- ${column.tasks.length} -
-
- ${renderKanbanCards(column.tasks, filter)} -
-
- `).join('')} -
- `; - - // Делаем колонки перетаскиваемыми (кроме 'overdue' и 'assigned') - makeKanbanDraggable(); -} - -function getDayWord(days) { - if (days === 1) return 'день'; - if (days >= 2 && days <= 4) return 'дня'; - return 'дней'; -} - -function makeKanbanDraggable() { - const cards = document.querySelectorAll('.kanban-card'); - const columns = document.querySelectorAll('.kanban-column-body:not([style*="opacity: 0.6"])'); - - cards.forEach(card => { - card.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', card.dataset.taskId); - card.classList.add('dragging'); - }); - - card.addEventListener('dragend', () => { - card.classList.remove('dragging'); - }); - }); - - columns.forEach(column => { - const status = column.parentElement.dataset.status; - - // Запрещаем перетаскивание в 'overdue' и 'assigned' - if (status === 'overdue' || status === 'assigned') { - return; - } - - column.addEventListener('dragover', (e) => { - e.preventDefault(); - const draggingCard = document.querySelector('.dragging'); - if (draggingCard) { - column.appendChild(draggingCard); - } - }); - - column.addEventListener('drop', async (e) => { - e.preventDefault(); - const taskId = e.dataTransfer.getData('text/plain'); - const newStatus = column.parentElement.dataset.status; - - if (taskId) { - try { - // Запрещаем установку статуса 'overdue' и 'assigned' - if (newStatus === 'overdue' || newStatus === 'assigned') { - alert('Невозможно изменить статус задачи на "Просрочены" или "Назначены" через Канбан'); - // Возвращаем задачу в исходное положение - loadKanbanTasks(); - return; - } - - // Обновляем статус на сервере - const response = await fetch(`/api/kanban-tasks/${taskId}/status`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ status: newStatus }) - }); - - if (response.ok) { - // Перезагружаем Канбан - loadKanbanTasks(); - } else { - const error = await response.json(); - alert(`Ошибка обновления статуса: ${error.error || 'Неизвестная ошибка'}`); - // Возвращаем задачу в исходное положение - loadKanbanTasks(); - } - } catch (error) { - console.error('Ошибка обновления статуса:', error); - alert('Ошибка обновления статуса'); - loadKanbanTasks(); - } - } - }); - }); -} - -function openKanbanTask(taskId) { - // Находим задачу и открываем её в основном интерфейсе - const task = kanbanTasks.find(t => t.id == taskId); - if (task) { - showSection('tasks'); - // Прокручиваем к задаче - setTimeout(() => { - const taskElement = document.querySelector(`.task-card[data-task-id="${taskId}"]`); - if (taskElement) { - taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Раскрываем задачу если она свернута - if (!expandedTasks.has(taskId)) { - toggleTask(taskId); - } - } - }, 100); - } -} - -function copyKanbanTask(taskId) { - openCopyModal(taskId); -} - -function formatDate(dateString) { - if (!dateString) return ''; - const date = new Date(dateString); - return date.toLocaleDateString('ru-RU'); -} -function filterUsers() { - const search = document.getElementById('user-search').value.toLowerCase(); - filteredUsers = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - renderUsersChecklist(); -} - -function filterEditUsers() { - const search = document.getElementById('edit-user-search').value.toLowerCase(); - const filtered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - renderEditUsersChecklist(filtered); -} - -function filterCopyUsers() { - const search = document.getElementById('copy-user-search').value.toLowerCase(); - const filtered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - renderCopyUsersChecklist(filtered); -} - -async function loadTasks() { - try { - showingTasksWithoutDate = false; - const btn = document.getElementById('tasks-no-date-btn'); - if (btn) btn.classList.remove('active'); - - const search = document.getElementById('search-tasks')?.value || ''; - const statusFilter = document.getElementById('status-filter')?.value || 'active,in_progress,assigned,overdue,rework'; - const creatorFilter = document.getElementById('creator-filter')?.value || ''; - const assigneeFilter = document.getElementById('assignee-filter')?.value || ''; - const deadlineFilter = document.getElementById('deadline-filter')?.value || ''; - const showDeleted = document.getElementById('show-deleted')?.checked || false; - - let url = '/api/tasks?'; - if (search) url += `search=${encodeURIComponent(search)}&`; - if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; - if (creatorFilter) url += `creator=${encodeURIComponent(creatorFilter)}&`; - if (assigneeFilter) url += `assignee=${encodeURIComponent(assigneeFilter)}&`; - if (deadlineFilter) url += `deadline=${encodeURIComponent(deadlineFilter)}&`; - if (showDeleted) url += `showDeleted=true&`; - - const response = await fetch(url); - tasks = await response.json(); - - // Загружаем файлы для всех задач - await Promise.all(tasks.map(async (task) => { - try { - const filesResponse = await fetch(`/api/tasks/${task.id}/files`); - if (filesResponse.ok) { - task.files = await filesResponse.json(); - } else { - task.files = []; - } - } catch (error) { - console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error); - task.files = []; - } - })); - - renderTasks(); - - } catch (error) { - console.error('Ошибка загрузки задач:', error); - } -} - -function showTasksWithoutDate() { - showingTasksWithoutDate = true; - const btn = document.getElementById('tasks-no-date-btn'); - if (btn) btn.classList.add('active'); - loadTasksWithoutDate(); -} - -async function loadTasksWithoutDate() { - try { - const response = await fetch('/api/tasks'); - if (!response.ok) throw new Error('Ошибка загрузки задач'); - - const allTasks = await response.json(); - tasks = allTasks.filter(task => { - const hasTaskDueDate = !task.due_date; - const hasAssignmentDueDates = task.assignments && - task.assignments.every(assignment => !assignment.due_date); - return hasTaskDueDate && hasAssignmentDueDates; - }); - - // Загружаем файлы для всех задач - await Promise.all(tasks.map(async (task) => { - try { - const filesResponse = await fetch(`/api/tasks/${task.id}/files`); - if (filesResponse.ok) { - task.files = await filesResponse.json(); - } else { - task.files = []; - } - } catch (error) { - console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error); - task.files = []; - } - })); - - renderTasks(); - } catch (error) { - console.error('Ошибка загрузки задач без срока:', error); - } -} - -async function loadActivityLogs() { - try { - const response = await fetch('/api/activity-logs'); - const logs = await response.json(); - renderLogs(logs); - } catch (error) { - console.error('Ошибка загрузки логов:', error); - } -} - -function renderUsersChecklist() { - const container = document.getElementById('users-checklist'); - container.innerHTML = filteredUsers - .filter(user => user.id !== currentUser.id) - .map(user => ` -
- -
- `).join(''); -} - -function renderEditUsersChecklist(filtered = users) { - const container = document.getElementById('edit-users-checklist'); - container.innerHTML = filtered - .filter(user => user.id !== currentUser.id) - .map(user => ` -
- -
- `).join(''); -} - -function renderCopyUsersChecklist(filtered = users) { - const container = document.getElementById('copy-users-checklist'); - container.innerHTML = filtered - .filter(user => user.id !== currentUser.id) - .map(user => ` -
- -
- `).join(''); -} - -function renderTasks() { - const container = document.getElementById('tasks-list'); - const showDeleted = document.getElementById('show-deleted')?.checked || false; - - let filteredTasks = tasks; - if (!showDeleted) { - filteredTasks = tasks.filter(task => task.status === 'active'); - } - - if (filteredTasks.length === 0) { - container.innerHTML = '
Задачи не найдены
'; - return; - } - - container.innerHTML = filteredTasks.map(task => { - const isExpanded = expandedTasks.has(task.id); - const overallStatus = getTaskOverallStatus(task); - const statusClass = getStatusClass(overallStatus); - const isDeleted = task.status === 'deleted'; - const isClosed = task.closed_at !== null; - const userRole = getUserRoleInTask(task); - const canEdit = canUserEditTask(task); - const isCopy = task.original_task_id !== null; - - const timeLeftInfo = getTimeLeftInfo(task); - - return ` -
-
-
-
- Задача №${task.id} - ${task.title} - ${isDeleted ? 'Удалена' : ''} - ${isClosed ? 'Закрыта' : ''} - ${isCopy ? 'Копия' : ''} - ${timeLeftInfo ? `${timeLeftInfo.text}` : ''} - ${userRole} -
-
${getStatusText(overallStatus)}
-
- ▼ -
-
-
- -
-
- ${!isDeleted && !isClosed ? ` - ${canEdit ? `` : ''} - - ${canEdit ? `` : ''} - ${canEdit ? `` : ''} - ${canEdit ? `` : ''} - ` : ''} - ${isClosed && canEdit ? ` - - ` : ''} - ${isDeleted && currentUser.role === 'admin' ? ` - - ` : ''} -
- - ${isCopy && task.original_task_title ? ` -
- Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name}) -
- ` : ''} - -
${task.description || 'Нет описания'}
- - ${task.rework_comment ? ` -
- Комментарий к доработке: ${task.rework_comment} -
- ` : ''} - -
-
- Создана: ${formatDateTime(task.start_date || task.created_at)} - ${task.due_date ? ` | Выполнить до: ${formatDateTime(task.due_date)}` : ''} - ${showingTasksWithoutDate ? 'Без срока' : ''} -
-
- Файлы: - ${task.files && task.files.length > 0 ? - `
${task.files.map(file => renderFileIcon(file)).join('')}
` : - 'нет файлов' - } -
-
- -
- Исполнители: -${task.assignments && task.assignments.length > 0 ? - renderAssignmentList(task.assignments, task.id, canEdit) : - '
Не назначены
' -} -
- -
- Создана: ${formatDateTime(task.created_at)} | Автор: ${task.creator_name} - ${task.deleted_at ? `
Удалена: ${formatDateTime(task.deleted_at)}` : ''} - ${task.closed_at ? `
Закрыта: ${formatDateTime(task.closed_at)}` : ''} -
-
-
- `; - }).join(''); -} -function renderAssignmentList(assignments, taskId, canEdit) { - if (!assignments || assignments.length === 0) { - return '
Не назначены
'; - } - - // Создаем контейнер с возможностью фильтрации - return ` -
-
- - ${assignments.length} исполнителей -
-
- ${assignments.map(assignment => renderAssignment(assignment, taskId, canEdit)).join('')} -
-
- `; -} - -// Функция для фильтрации исполнителей в конкретной задаче -function filterAssignments(taskId) { - const filterInput = document.querySelector(`.assignment-filter-input[data-task-id="${taskId}"]`); - const scrollContainer = document.getElementById(`assignments-${taskId}`); - const filterCount = document.getElementById(`filter-count-${taskId}`); - - if (!filterInput || !scrollContainer) return; - - const searchTerm = filterInput.value.toLowerCase(); - const assignments = scrollContainer.querySelectorAll('.assignment'); - - let visibleCount = 0; - - assignments.forEach(assignment => { - const userName = assignment.querySelector('strong')?.textContent?.toLowerCase() || ''; - const userLogin = assignment.querySelector('small')?.textContent?.toLowerCase() || ''; - - const isVisible = userName.includes(searchTerm) || - userLogin.includes(searchTerm) || - searchTerm === ''; - - assignment.style.display = isVisible ? '' : 'none'; - - if (isVisible) { - visibleCount++; - } - }); - - if (filterCount) { - filterCount.textContent = `${visibleCount} из ${assignments.length} исполнителей`; - } -} -function toggleTask(taskId) { - if (expandedTasks.has(taskId)) { - expandedTasks.delete(taskId); - } else { - expandedTasks.add(taskId); - loadTaskFiles(taskId); // Эта строка должна быть - } - renderTasks(); -} - -function getTimeLeftInfo(task) { - if (!task.due_date || task.closed_at) return null; - - const dueDate = new Date(task.due_date); - const now = new Date(); - const timeLeft = dueDate.getTime() - now.getTime(); - const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); - - if (hoursLeft <= 0) return null; - - if (hoursLeft <= 24) { - return { - text: `Менее 24ч`, - class: 'deadline-24h' - }; - } else if (hoursLeft <= 48) { - return { - text: `Менее 48ч`, - class: 'deadline-48h' - }; - } - - return null; -} - -function renderAssignment(assignment, taskId, canEdit) { - const statusClass = getStatusClass(assignment.status); - const isCurrentUser = assignment.user_id === currentUser.id; - const isOverdue = assignment.status === 'overdue'; - const isRework = assignment.status === 'rework'; - - const timeLeftInfo = getAssignmentTimeLeftInfo(assignment); - - return ` -
- -
- ${assignment.user_name} - ${isCurrentUser ? '(Вы)' : ''} - ${timeLeftInfo ? `${timeLeftInfo.text}` : ''} - ${assignment.start_date || assignment.due_date ? ` -
- ${assignment.start_date ? `Начало: ${formatDateTime(assignment.start_date)}` : ''} - ${assignment.due_date ? `Выполнить до: ${formatDateTime(assignment.due_date)}` : ''} -
- ` : ''} - ${assignment.rework_comment ? ` -
- Комментарий: ${assignment.rework_comment} -
- ` : ''} -
-
- ${isCurrentUser && assignment.status === 'assigned' ? - `` : ''} - ${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue' || assignment.status === 'rework') ? - `` : ''} - ${canEdit ? - `` : ''} -
-
- `; -} - -function getAssignmentTimeLeftInfo(assignment) { - if (!assignment.due_date || assignment.status === 'completed') return null; - - const dueDate = new Date(assignment.due_date); - const now = new Date(); - const timeLeft = dueDate.getTime() - now.getTime(); - const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); - - if (hoursLeft <= 0) return null; - - if (hoursLeft <= 24) { - return { - text: `Осталось ${hoursLeft}ч`, - class: 'deadline-24h' - }; - } else if (hoursLeft <= 48) { - return { - text: `Осталось ${hoursLeft}ч`, - class: 'deadline-48h' - }; - } - - return null; -} - -async function createTask(event) { - event.preventDefault(); - - if (!currentUser) { - alert('Требуется аутентификация'); - return; - } - - const formData = new FormData(); - formData.append('title', document.getElementById('title').value); - formData.append('description', document.getElementById('description').value); - - const dueDate = document.getElementById('due-date').value; - if (!dueDate) { - alert('Дата и время выполнения обязательны'); - return; - } - formData.append('dueDate', dueDate); - - const assignedUsers = document.querySelectorAll('#users-checklist input[name="assignedUsers"]:checked'); - if (assignedUsers.length === 0) { - alert('Выберите хотя бы одного исполнителя'); - return; - } - assignedUsers.forEach(checkbox => { - formData.append('assignedUsers', checkbox.value); - }); - - const files = document.getElementById('files').files; - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); - } - - try { - const response = await fetch('/api/tasks', { - method: 'POST', - body: formData - }); - - if (response.ok) { - alert('Задача успешно создана!'); - document.getElementById('create-task-form').reset(); - document.getElementById('file-list').innerHTML = ''; - document.getElementById('user-search').value = ''; - filterUsers(); - loadTasks(); - loadActivityLogs(); - showSection('tasks'); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка создания задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка создания задачи'); - } -} - -async function openEditModal(taskId) { - try { - const response = await fetch(`/api/tasks/${taskId}`); - if (!response.ok) { - if (response.status === 404) { - alert('Задача не найдена или у вас нет прав доступа'); - } - throw new Error('Ошибка загрузки задачи'); - } - - const task = await response.json(); - - if (!canUserEditTask(task)) { - alert('У вас нет прав для редактирования этой задачи'); - return; - } - - document.getElementById('edit-task-id').value = task.id; - document.getElementById('edit-title').value = task.title; - document.getElementById('edit-description').value = task.description || ''; - - document.getElementById('edit-due-date').value = task.due_date ? formatDateTimeForInput(task.due_date) : ''; - - const checkboxes = document.querySelectorAll('#edit-users-checklist input[name="assignedUsers"]'); - checkboxes.forEach(checkbox => { - checkbox.checked = task.assignments?.some(assignment => - assignment.user_id === parseInt(checkbox.value) - ) || false; - }); - - document.getElementById('edit-task-modal').style.display = 'block'; - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка загрузки задачи'); - } -} - -function closeEditModal() { - document.getElementById('edit-task-modal').style.display = 'none'; - document.getElementById('edit-file-list').innerHTML = ''; - document.getElementById('edit-user-search').value = ''; - filterEditUsers(); -} - -async function updateTask(event) { - event.preventDefault(); - - const taskId = document.getElementById('edit-task-id').value; - const title = document.getElementById('edit-title').value; - const description = document.getElementById('edit-description').value; - const dueDate = document.getElementById('edit-due-date').value; - - if (!dueDate) { - alert('Дата и время выполнения обязательны'); - return; - } - - const assignedUsers = document.querySelectorAll('#edit-users-checklist input[name="assignedUsers"]:checked'); - const assignedUserIds = Array.from(assignedUsers).map(cb => parseInt(cb.value)); - - const formData = new FormData(); - formData.append('title', title); - formData.append('description', description); - formData.append('assignedUsers', JSON.stringify(assignedUserIds)); - formData.append('dueDate', dueDate); - - const files = document.getElementById('edit-files').files; - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); - } - - try { - const response = await fetch(`/api/tasks/${taskId}`, { - method: 'PUT', - body: formData - }); - - if (response.ok) { - alert('Задача успешно обновлена!'); - closeEditModal(); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка обновления задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка обновления задачи'); - } -} - -function openCopyModal(taskId) { - document.getElementById('copy-task-id').value = taskId; - document.getElementById('copy-task-modal').style.display = 'block'; -} - -function closeCopyModal() { - document.getElementById('copy-task-modal').style.display = 'none'; - document.getElementById('copy-user-search').value = ''; - filterCopyUsers(); -} - -async function copyTask(event) { - event.preventDefault(); - - const taskId = document.getElementById('copy-task-id').value; - const dueDate = document.getElementById('copy-due-date').value; - - if (!dueDate) { - alert('Дата и время выполнения обязательны для копии задачи'); - return; - } - - const checkboxes = document.querySelectorAll('#copy-users-checklist input[name="assignedUsers"]:checked'); - const assignedUserIds = Array.from(checkboxes).map(cb => parseInt(cb.value)); - - if (assignedUserIds.length === 0) { - alert('Выберите хотя бы одного исполнителя для копии задачи'); - return; - } - - try { - const response = await fetch(`/api/tasks/${taskId}/copy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - assignedUsers: assignedUserIds, - dueDate: dueDate - }) - }); - - if (response.ok) { - alert('Копия задачи успешно создана!'); - closeCopyModal(); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка создания копии задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка создания копии задачи'); - } -} - -function openEditAssignmentModal(taskId, userId) { - const task = tasks.find(t => t.id === taskId); - if (!task) return; - - const assignment = task.assignments.find(a => a.user_id === userId); - if (!assignment) return; - - document.getElementById('edit-assignment-task-id').value = taskId; - document.getElementById('edit-assignment-user-id').value = userId; - document.getElementById('edit-assignment-due-date').value = assignment.due_date ? formatDateTimeForInput(assignment.due_date) : ''; - - document.getElementById('edit-assignment-modal').style.display = 'block'; -} - -function closeEditAssignmentModal() { - document.getElementById('edit-assignment-modal').style.display = 'none'; -} - -async function updateAssignment(event) { - event.preventDefault(); - - const taskId = document.getElementById('edit-assignment-task-id').value; - const userId = document.getElementById('edit-assignment-user-id').value; - const dueDate = document.getElementById('edit-assignment-due-date').value; - - if (!dueDate) { - alert('Дата и время выполнения обязательны'); - return; - } - - try { - const response = await fetch(`/api/tasks/${taskId}/assignment/${userId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - dueDate: dueDate - }) - }); - - if (response.ok) { - alert('Сроки исполнителя обновлены!'); - closeEditAssignmentModal(); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка обновления сроков'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка обновления сроков'); - } -} - -function openReworkModal(taskId) { - document.getElementById('rework-task-id').value = taskId; - document.getElementById('rework-task-modal').style.display = 'block'; -} - -function closeReworkModal() { - document.getElementById('rework-task-modal').style.display = 'none'; - document.getElementById('rework-comment').value = ''; -} - -async function sendForRework(event) { - event.preventDefault(); - - const taskId = document.getElementById('rework-task-id').value; - const comment = document.getElementById('rework-comment').value; - - try { - const response = await fetch(`/api/tasks/${taskId}/rework`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ comment }) - }); - - if (response.ok) { - alert('Задача возвращена на доработку!'); - closeReworkModal(); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка возврата задачи на доработку'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка возврата задачи на доработку'); - } -} - -async function closeTask(taskId) { - if (!confirm('Вы уверены, что хотите закрыть эту задачу? Исполнители больше не будут видеть её.')) { - return; - } - - try { - const response = await fetch(`/api/tasks/${taskId}/close`, { - method: 'POST' - }); - - if (response.ok) { - alert('Задача закрыта!'); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка закрытия задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка закрытия задачи'); - } -} - -async function reopenTask(taskId) { - try { - const response = await fetch(`/api/tasks/${taskId}/reopen`, { - method: 'POST' - }); - - if (response.ok) { - alert('Задача открыта!'); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка открытия задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка открытия задачи'); - } -} - -async function deleteTask(taskId) { - if (!confirm('Вы уверены, что хотите удалить эту задачу?')) { - return; - } - - try { - const response = await fetch(`/api/tasks/${taskId}`, { - method: 'DELETE' - }); - - if (response.ok) { - alert('Задача удалена!'); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка удаления задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка удаления задачи'); - } -} - -async function restoreTask(taskId) { - try { - const response = await fetch(`/api/tasks/${taskId}/restore`, { - method: 'POST' - }); - - if (response.ok) { - alert('Задача восстановлена!'); - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка восстановления задачи'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка восстановления задачи'); - } -} - -async function updateStatus(taskId, userId, status) { - try { - const response = await fetch(`/api/tasks/${taskId}/status`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userId, status }) - }); - - if (response.ok) { - loadTasks(); - loadActivityLogs(); - } else { - const error = await response.json(); - alert(error.error || 'Ошибка обновления статуса'); - } - } catch (error) { - console.error('Ошибка:', error); - alert('Ошибка обновления статуса'); - } -} - -function getTaskOverallStatus(task) { - if (task.status === 'deleted') return 'deleted'; - if (task.closed_at) return 'closed'; - if (!task.assignments || task.assignments.length === 0) return 'unassigned'; - - const assignments = task.assignments; - let hasAssigned = false; - let hasInProgress = false; - let hasOverdue = false; - let hasRework = false; - let allCompleted = true; - - for (let assignment of assignments) { - if (assignment.status === 'assigned') { - hasAssigned = true; - allCompleted = false; - } else if (assignment.status === 'in_progress') { - hasInProgress = true; - allCompleted = false; - } else if (assignment.status === 'overdue') { - hasOverdue = true; - allCompleted = false; - } else if (assignment.status === 'rework') { - hasRework = true; - allCompleted = false; - } else if (assignment.status !== 'completed') { - allCompleted = false; - } - } - - if (allCompleted) return 'completed'; - if (hasRework) return 'rework'; - if (hasOverdue) return 'overdue'; - if (hasInProgress) return 'in_progress'; - if (hasAssigned) return 'assigned'; - return 'unassigned'; -} - -function getStatusClass(status) { - switch (status) { - case 'deleted': return 'status-gray'; - case 'closed': return 'status-gray'; - case 'unassigned': return 'status-purple'; - case 'assigned': return 'status-red'; - case 'in_progress': return 'status-orange'; - case 'rework': return 'status-yellow'; - case 'overdue': return 'status-darkred'; - case 'completed': return 'status-green'; - default: return 'status-purple'; - } -} - -function getStatusText(status) { - switch (status) { - case 'deleted': return 'Удалена'; - case 'closed': return 'Закрыта'; - case 'unassigned': return 'Не назначена'; - case 'assigned': return 'Назначена'; - case 'in_progress': return 'В работе'; - case 'rework': return 'На доработке'; - case 'overdue': return 'Просрочена'; - case 'completed': return 'Выполнена'; - default: return 'Неизвестно'; - } -} - -function getUserRoleInTask(task) { - if (!currentUser) return 'Нет доступа'; - - if (currentUser.role === 'admin') return 'Администратор'; - - if (parseInt(task.created_by) === currentUser.id) { - if (task.assignments && task.assignments.length > 0) { - const assignedToOthers = task.assignments.some(assignment => - parseInt(assignment.user_id) !== currentUser.id - ); - if (assignedToOthers) { - return 'Создатель (только просмотр)'; - } - } - return 'Создатель'; - } - - if (task.assignments) { - const isExecutor = task.assignments.some(assignment => - parseInt(assignment.user_id) === currentUser.id - ); - if (isExecutor) return 'Исполнитель'; - } - - return 'Наблюдатель'; -} - -function getRoleBadgeClass(role) { - switch (role) { - case 'Администратор': return 'role-admin'; - case 'Заказчик': return 'role-creator'; - case 'Исполнитель': return 'role-executor'; - default: return ''; - } -} - -function canUserEditTask(task) { - if (!currentUser) return false; - - // Администратор может всё - if (currentUser.role === 'admin') return true; - - // Создатель может редактировать свою задачу - if (parseInt(task.created_by) === currentUser.id) { - // Но если задача уже назначена другим пользователям, - // создатель может только просматривать - if (task.assignments && task.assignments.length > 0) { - // Проверяем, назначена ли задача другим пользователям (не только себе) - const assignedToOthers = task.assignments.some(assignment => - parseInt(assignment.user_id) !== currentUser.id - ); - - if (assignedToOthers) { - // Создатель может только просматривать и закрывать задачу - return false; - } - } - return true; - } - - // Исполнитель может менять только свой статус - if (task.assignments) { - const isExecutor = task.assignments.some(assignment => - parseInt(assignment.user_id) === currentUser.id - ); - if (isExecutor) { - // Исполнитель может менять только статус - return false; - } - } - - return false; -} - -function formatDateTime(dateTimeString) { - if (!dateTimeString) return ''; - const date = new Date(dateTimeString); - return date.toLocaleString('ru-RU'); -} - -function formatDateTimeForInput(dateTimeString) { - if (!dateTimeString) return ''; - const date = new Date(dateTimeString); - return date.toISOString().slice(0, 16); -} - -function updateFileList() { - const fileInput = document.getElementById('files'); - const fileList = document.getElementById('file-list'); - updateFileListForInput(fileInput, fileList); -} - -function updateEditFileList() { - const fileInput = document.getElementById('edit-files'); - const fileList = document.getElementById('edit-file-list'); - updateFileListForInput(fileInput, fileList); -} - -function updateFileListForInput(fileInput, fileList) { - const files = fileInput.files; - - if (files.length === 0) { - fileList.innerHTML = ''; - return; - } - - let html = ''; - html += `

Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB

`; - - fileList.innerHTML = html; -} - -function renderLogs(logs) { - const container = document.getElementById('logs-list'); - - if (logs.length === 0) { - container.innerHTML = '
Логи не найдены
'; - return; - } - - container.innerHTML = logs.map(log => ` -
-
${formatDateTime(log.created_at)}
-
${log.user_name} - ${getActionText(log.action)}
-
Задача: "${log.task_title}"
- ${log.details ? `
Детали: ${log.details}
` : ''} -
- `).join(''); -} - -function getActionText(action) { - const actions = { - 'TASK_CREATED': 'создал задачу', - 'TASK_COPIED': 'создал копию задачи', - 'TASK_UPDATED': 'обновил задачу', - 'TASK_DELETED': 'удалил задачу', - 'TASK_RESTORED': 'восстановил задачу', - 'TASK_ASSIGNED': 'назначил задачу', - 'TASK_ASSIGNMENTS_UPDATED': 'обновил назначения', - 'ASSIGNMENT_UPDATED': 'обновил сроки исполнителя', - 'STATUS_CHANGED': 'изменил статус задачи', - 'FILE_UPLOADED': 'загрузил файл', - 'FILE_COPIED': 'скопировал файл', - 'TASK_SENT_FOR_REWORK': 'вернул задачу на доработку', - 'TASK_CLOSED': 'закрыл задачу', - 'TASK_REOPENED': 'открыл задачу' - }; - - return actions[action] || action; -} - -async function loadTaskFiles(taskId) { - try { - const response = await fetch(`/api/tasks/${taskId}/files`); - const files = await response.json(); - - const container = document.getElementById(`files-${taskId}`); - if (container) { - if (files.length === 0) { - container.innerHTML = 'Файлы: скрыто'; - } else { - container.innerHTML = ` - Файлы: -
- ${files.map(file => renderFileIcon(file)).join('')} -
- `; - } - } - } catch (error) { - console.error('Ошибка загрузки файлов:', error); - } -} - -function renderFileIcon(file) { - // Исправляем кодировку имени файла - const fixEncoding = (str) => { - if (!str) return ''; - try { - // Пробуем разные способы декодирования - if (str.includes('Ð') || str.includes('Ñ')) { - // UTF-8 неправильно декодированный как Latin-1 - return decodeURIComponent(escape(str)); - } - return str; - } catch (e) { - return str; - } - }; - - const fileName = fixEncoding(file.original_name); - const fileSize = (file.file_size / 1024 / 1024).toFixed(2); - const uploadedBy = file.user_name; - - let iconColor = ''; - let iconText = ''; - let textClass = ''; - - // Определяем расширение файла - const extension = fileName.includes('.') ? - fileName.split('.').pop().toLowerCase() : - ''; - - // Определяем тип файла на основе расширения - if (extension) { - switch (extension) { - case 'pdf': - iconColor = '#e74c3c'; - iconText = 'PDF'; - textClass = 'short'; - break; - case 'doc': - iconColor = '#3498db'; - iconText = 'DOC'; - textClass = 'short'; - break; - case 'docx': - iconColor = '#3498db'; - iconText = 'DOCX'; - textClass = 'medium'; - break; - case 'xls': - iconColor = '#2ecc71'; - iconText = 'XLS'; - textClass = 'short'; - break; - case 'xlsx': - iconColor = '#2ecc71'; - iconText = 'XLSX'; - textClass = 'medium'; - break; - case 'csv': - iconColor = '#2ecc71'; - iconText = 'CSV'; - textClass = 'short'; - break; - case 'ppt': - iconColor = '#e67e22'; - iconText = 'PPT'; - textClass = 'short'; - break; - case 'pptx': - iconColor = '#e67e22'; - iconText = 'PPTX'; - textClass = 'medium'; - break; - case 'zip': - iconColor = '#f39c12'; - iconText = 'ZIP'; - textClass = 'short'; - break; - case 'rar': - iconColor = '#f39c12'; - iconText = 'RAR'; - textClass = 'short'; - break; - case '7z': - iconColor = '#f39c12'; - iconText = '7Z'; - textClass = 'short'; - break; - case 'tar': - iconColor = '#f39c12'; - iconText = 'TAR'; - textClass = 'short'; - break; - case 'gz': - iconColor = '#f39c12'; - iconText = 'GZ'; - textClass = 'short'; - break; - case 'txt': - iconColor = '#95a5a6'; - iconText = 'TXT'; - textClass = 'short'; - break; - case 'log': - iconColor = '#95a5a6'; - iconText = 'LOG'; - textClass = 'short'; - break; - case 'md': - iconColor = '#95a5a6'; - iconText = 'MD'; - textClass = 'short'; - break; - case 'jpg': - iconColor = '#9b59b6'; - iconText = 'JPG'; - textClass = 'short'; - break; - case 'jpeg': - iconColor = '#9b59b6'; - iconText = 'JPEG'; - textClass = 'medium'; - break; - case 'png': - iconColor = '#9b59b6'; - iconText = 'PNG'; - textClass = 'short'; - break; - case 'gif': - iconColor = '#9b59b6'; - iconText = 'GIF'; - textClass = 'short'; - break; - case 'bmp': - iconColor = '#9b59b6'; - iconText = 'BMP'; - textClass = 'short'; - break; - case 'svg': - iconColor = '#9b59b6'; - iconText = 'SVG'; - textClass = 'short'; - break; - case 'webp': - iconColor = '#9b59b6'; - iconText = 'WEBP'; - textClass = 'medium'; - break; - case 'mp3': - iconColor = '#1abc9c'; - iconText = 'MP3'; - textClass = 'short'; - break; - case 'wav': - iconColor = '#1abc9c'; - iconText = 'WAV'; - textClass = 'short'; - break; - case 'ogg': - iconColor = '#1abc9c'; - iconText = 'OGG'; - textClass = 'short'; - break; - case 'flac': - iconColor = '#1abc9c'; - iconText = 'FLAC'; - textClass = 'medium'; - break; - case 'mp4': - iconColor = '#d35400'; - iconText = 'MP4'; - textClass = 'short'; - break; - case 'avi': - iconColor = '#d35400'; - iconText = 'AVI'; - textClass = 'short'; - break; - case 'mkv': - iconColor = '#d35400'; - iconText = 'MKV'; - textClass = 'short'; - break; - case 'mov': - iconColor = '#d35400'; - iconText = 'MOV'; - textClass = 'short'; - break; - case 'wmv': - iconColor = '#d35400'; - iconText = 'WMV'; - textClass = 'short'; - break; - case 'exe': - iconColor = '#c0392b'; - iconText = 'EXE'; - textClass = 'short'; - break; - case 'msi': - iconColor = '#c0392b'; - iconText = 'MSI'; - textClass = 'short'; - break; - case 'js': - iconColor = '#2980b9'; - iconText = 'JS'; - textClass = 'short'; - break; - case 'html': - iconColor = '#2980b9'; - iconText = 'HTML'; - textClass = 'medium'; - break; - case 'css': - iconColor = '#2980b9'; - iconText = 'CSS'; - textClass = 'short'; - break; - case 'php': - iconColor = '#2980b9'; - iconText = 'PHP'; - textClass = 'short'; - break; - case 'py': - iconColor = '#2980b9'; - iconText = 'PY'; - textClass = 'short'; - break; - case 'java': - iconColor = '#2980b9'; - iconText = 'JAVA'; - textClass = 'medium'; - break; - case 'json': - iconColor = '#8e44ad'; - iconText = 'JSON'; - textClass = 'medium'; - break; - case 'xml': - iconColor = '#8e44ad'; - iconText = 'XML'; - textClass = 'short'; - break; - case 'yml': - iconColor = '#8e44ad'; - iconText = 'YML'; - textClass = 'short'; - break; - case 'yaml': - iconColor = '#8e44ad'; - iconText = 'YAML'; - textClass = 'medium'; - break; - case 'sql': - iconColor = '#27ae60'; - iconText = 'SQL'; - textClass = 'short'; - break; - case 'db': - iconColor = '#27ae60'; - iconText = 'DB'; - textClass = 'short'; - break; - case 'sqlite': - iconColor = '#27ae60'; - iconText = 'SQLITE'; - textClass = 'long'; - break; - default: - // Для других расширений используем расширение или первые 4 символа - iconColor = '#7f8c8d'; - iconText = extension.length > 4 ? - extension.substring(0, 4).toUpperCase() : - extension.toUpperCase(); - - // Определяем класс по длине текста - if (iconText.length <= 2) { - textClass = 'short'; - } else if (iconText.length <= 4) { - textClass = 'medium'; - } else { - textClass = 'long'; - } - } - } else { - // Если нет расширения - iconColor = '#7f8c8d'; - iconText = 'ФАЙЛ'; - textClass = 'short'; - } - - // Исправляем кодировку для отображения - const safeFileName = fileName; - const displayFileName = truncateFileName(safeFileName); - - return ` - -
- ${iconText} -
-
${displayFileName}
-
- `; -} - -function truncateFileName(fileName, maxLength = 20) { - if (fileName.length <= maxLength) return fileName; - const extension = fileName.split('.').pop(); - const name = fileName.substring(0, fileName.lastIndexOf('.')); - const truncatedName = name.substring(0, maxLength - extension.length - 3) + '...'; - return truncatedName + '.' + extension; -} \ No newline at end of file diff --git a/public/style.css b/public/style.css index dd4a175..b7422e4 100644 --- a/public/style.css +++ b/public/style.css @@ -2211,4 +2211,69 @@ button.reopen-btn: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/public/tasks.js b/public/tasks.js new file mode 100644 index 0000000..e3bd3fa --- /dev/null +++ b/public/tasks.js @@ -0,0 +1,558 @@ +// tasks.js - Основные операции с задачами +let tasks = []; +let expandedTasks = new Set(); +let showingTasksWithoutDate = false; + +async function loadTasks() { + try { + showingTasksWithoutDate = false; + const btn = document.getElementById('tasks-no-date-btn'); + if (btn) btn.classList.remove('active'); + + const search = document.getElementById('search-tasks')?.value || ''; + const statusFilter = document.getElementById('status-filter')?.value || 'active,in_progress,assigned,overdue,rework'; + const creatorFilter = document.getElementById('creator-filter')?.value || ''; + const assigneeFilter = document.getElementById('assignee-filter')?.value || ''; + const deadlineFilter = document.getElementById('deadline-filter')?.value || ''; + const showDeleted = document.getElementById('show-deleted')?.checked || false; + + let url = '/api/tasks?'; + if (search) url += `search=${encodeURIComponent(search)}&`; + if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; + if (creatorFilter) url += `creator=${encodeURIComponent(creatorFilter)}&`; + if (assigneeFilter) url += `assignee=${encodeURIComponent(assigneeFilter)}&`; + if (deadlineFilter) url += `deadline=${encodeURIComponent(deadlineFilter)}&`; + if (showDeleted) url += `showDeleted=true&`; + + const response = await fetch(url); + tasks = await response.json(); + + // Загружаем файлы для всех задач + await Promise.all(tasks.map(async (task) => { + try { + const filesResponse = await fetch(`/api/tasks/${task.id}/files`); + if (filesResponse.ok) { + task.files = await filesResponse.json(); + } else { + task.files = []; + } + } catch (error) { + console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error); + task.files = []; + } + })); + + renderTasks(); + + } catch (error) { + console.error('Ошибка загрузки задач:', error); + } +} + +function showTasksWithoutDate() { + showingTasksWithoutDate = true; + const btn = document.getElementById('tasks-no-date-btn'); + if (btn) btn.classList.add('active'); + loadTasksWithoutDate(); +} + +async function loadTasksWithoutDate() { + try { + const response = await fetch('/api/tasks'); + if (!response.ok) throw new Error('Ошибка загрузки задач'); + + const allTasks = await response.json(); + tasks = allTasks.filter(task => { + const hasTaskDueDate = !task.due_date; + const hasAssignmentDueDates = task.assignments && + task.assignments.every(assignment => !assignment.due_date); + return hasTaskDueDate && hasAssignmentDueDates; + }); + + // Загружаем файлы для всех задач + await Promise.all(tasks.map(async (task) => { + try { + const filesResponse = await fetch(`/api/tasks/${task.id}/files`); + if (filesResponse.ok) { + task.files = await filesResponse.json(); + } else { + task.files = []; + } + } catch (error) { + console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error); + task.files = []; + } + })); + + renderTasks(); + } catch (error) { + console.error('Ошибка загрузки задач без срока:', error); + } +} + +async function createTask(event) { + event.preventDefault(); + + if (!currentUser) { + alert('Требуется аутентификация'); + return; + } + + const formData = new FormData(); + formData.append('title', document.getElementById('title').value); + formData.append('description', document.getElementById('description').value); + + const dueDate = document.getElementById('due-date').value; + if (!dueDate) { + alert('Дата и время выполнения обязательны'); + return; + } + formData.append('dueDate', dueDate); + + // Используем selectedUsers вместо прямого доступа к DOM + if (selectedUsers.length === 0) { + alert('Выберите хотя бы одного исполнителя'); + return; + } + selectedUsers.forEach(userId => { + formData.append('assignedUsers', userId); + }); + + const files = document.getElementById('files').files; + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + try { + const response = await fetch('/api/tasks', { + method: 'POST', + body: formData + }); + + if (response.ok) { + alert('Задача успешно создана!'); + document.getElementById('create-task-form').reset(); + document.getElementById('file-list').innerHTML = ''; + document.getElementById('user-search').value = ''; + selectedUsers = []; + renderUsersChecklist(); + loadTasks(); + loadActivityLogs(); + showSection('tasks'); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка создания задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка создания задачи'); + } +} + +async function openEditModal(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}`); + if (!response.ok) { + if (response.status === 404) { + alert('Задача не найдена или у вас нет прав доступа'); + } + throw new Error('Ошибка загрузки задачи'); + } + + const task = await response.json(); + + if (!canUserEditTask(task)) { + alert('У вас нет прав для редактирования этой задачи'); + return; + } + + document.getElementById('edit-task-id').value = task.id; + document.getElementById('edit-title').value = task.title; + document.getElementById('edit-description').value = task.description || ''; + + document.getElementById('edit-due-date').value = task.due_date ? formatDateTimeForInput(task.due_date) : ''; + + // Устанавливаем выбранных пользователей + editSelectedUsers = task.assignments ? task.assignments.map(a => a.user_id) : []; + renderEditUsersChecklist(users); + + // Показываем существующие файлы + currentEditTaskFiles = task.files || []; + updateEditFileList(); + + document.getElementById('edit-task-modal').style.display = 'block'; + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка загрузки задачи'); + } +} + +function closeEditModal() { + document.getElementById('edit-task-modal').style.display = 'none'; + document.getElementById('edit-file-list').innerHTML = ''; + document.getElementById('edit-user-search').value = ''; + editSelectedUsers = []; + currentEditTaskFiles = []; + filterEditUsers(); +} + +async function updateTask(event) { + event.preventDefault(); + + const taskId = document.getElementById('edit-task-id').value; + const title = document.getElementById('edit-title').value; + const description = document.getElementById('edit-description').value; + const dueDate = document.getElementById('edit-due-date').value; + + if (!dueDate) { + alert('Дата и время выполнения обязательны'); + return; + } + + // Используем editSelectedUsers + const assignedUserIds = editSelectedUsers; + + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + formData.append('assignedUsers', JSON.stringify(assignedUserIds)); + formData.append('dueDate', dueDate); + + const files = document.getElementById('edit-files').files; + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + try { + const response = await fetch(`/api/tasks/${taskId}`, { + method: 'PUT', + body: formData + }); + + if (response.ok) { + alert('Задача успешно обновлена!'); + closeEditModal(); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка обновления задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка обновления задачи'); + } +} + +function openCopyModal(taskId) { + document.getElementById('copy-task-id').value = taskId; + + // Устанавливаем дату по умолчанию (через 7 дней) + const defaultDate = new Date(); + defaultDate.setDate(defaultDate.getDate() + 7); + document.getElementById('copy-due-date').value = defaultDate.toISOString().substring(0, 16); + + // Сбрасываем выбранных пользователей + copySelectedUsers = []; + renderCopyUsersChecklist(users); + + document.getElementById('copy-task-modal').style.display = 'block'; +} + +function closeCopyModal() { + document.getElementById('copy-task-modal').style.display = 'none'; + document.getElementById('copy-user-search').value = ''; + copySelectedUsers = []; + filterCopyUsers(); +} + +async function copyTask(event) { + event.preventDefault(); + + const taskId = document.getElementById('copy-task-id').value; + const dueDate = document.getElementById('copy-due-date').value; + + if (!dueDate) { + alert('Дата и время выполнения обязательны для копии задачи'); + return; + } + + // Используем copySelectedUsers + const assignedUserIds = copySelectedUsers; + + if (assignedUserIds.length === 0) { + alert('Выберите хотя бы одного исполнителя для копии задачи'); + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}/copy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + assignedUsers: assignedUserIds, + dueDate: dueDate + }) + }); + + if (response.ok) { + alert('Копия задачи успешно создана!'); + closeCopyModal(); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка создания копии задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка создания копии задачи'); + } +} + +async function closeTask(taskId) { + if (!confirm('Вы уверены, что хотите закрыть эту задачу? Исполнители больше не будут видеть её.')) { + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}/close`, { + method: 'POST' + }); + + if (response.ok) { + alert('Задача закрыта!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка закрытия задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка закрытия задачи'); + } +} + +async function reopenTask(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}/reopen`, { + method: 'POST' + }); + + if (response.ok) { + alert('Задача открыта!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка открытия задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка открытия задачи'); + } +} + +async function deleteTask(taskId) { + if (!confirm('Вы уверены, что хотите удалить эту задачу?')) { + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}`, { + method: 'DELETE' + }); + + if (response.ok) { + alert('Задача удалена!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка удаления задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка удаления задачи'); + } +} + +async function restoreTask(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}/restore`, { + method: 'POST' + }); + + if (response.ok) { + alert('Задача восстановлена!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка восстановления задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка восстановления задачи'); + } +} + +function openEditAssignmentModal(taskId, userId) { + const task = tasks.find(t => t.id === taskId); + if (!task) return; + + const assignment = task.assignments.find(a => a.user_id === userId); + if (!assignment) return; + + document.getElementById('edit-assignment-task-id').value = taskId; + document.getElementById('edit-assignment-user-id').value = userId; + document.getElementById('edit-assignment-due-date').value = assignment.due_date ? formatDateTimeForInput(assignment.due_date) : ''; + + document.getElementById('edit-assignment-modal').style.display = 'block'; +} + +function closeEditAssignmentModal() { + document.getElementById('edit-assignment-modal').style.display = 'none'; +} + +async function updateAssignment(event) { + event.preventDefault(); + + const taskId = document.getElementById('edit-assignment-task-id').value; + const userId = document.getElementById('edit-assignment-user-id').value; + const dueDate = document.getElementById('edit-assignment-due-date').value; + + if (!dueDate) { + alert('Дата и время выполнения обязательны'); + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}/assignment/${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + dueDate: dueDate + }) + }); + + if (response.ok) { + alert('Сроки исполнителя обновлены!'); + closeEditAssignmentModal(); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка обновления сроков'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка обновления сроков'); + } +} + +function openReworkModal(taskId) { + document.getElementById('rework-task-id').value = taskId; + document.getElementById('rework-task-modal').style.display = 'block'; +} + +function closeReworkModal() { + document.getElementById('rework-task-modal').style.display = 'none'; + document.getElementById('rework-comment').value = ''; +} + +async function sendForRework(event) { + event.preventDefault(); + + const taskId = document.getElementById('rework-task-id').value; + const comment = document.getElementById('rework-comment').value; + + try { + const response = await fetch(`/api/tasks/${taskId}/rework`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ comment }) + }); + + if (response.ok) { + alert('Задача возвращена на доработку!'); + closeReworkModal(); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка возврата задачи на доработку'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка возврата задачи на доработку'); + } +} + +async function updateStatus(taskId, userId, status) { + try { + const response = await fetch(`/api/tasks/${taskId}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId, status }) + }); + + if (response.ok) { + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка обновления статуса'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка обновления статуса'); + } +} + +function canUserEditTask(task) { + if (!currentUser) return false; + + // Администратор может всё + if (currentUser.role === 'admin') return true; + + // Создатель может редактировать свою задачу + if (parseInt(task.created_by) === currentUser.id) { + // Но если задача уже назначена другим пользователям, + // создатель может только просматривать + if (task.assignments && task.assignments.length > 0) { + // Проверяем, назначена ли задача другим пользователям (не только себе) + const assignedToOthers = task.assignments.some(assignment => + parseInt(assignment.user_id) !== currentUser.id + ); + + if (assignedToOthers) { + // Создатель может только просматривать и закрывать задачу + return false; + } + } + return true; + } + + // Исполнитель может менять только свой статус + if (task.assignments) { + const isExecutor = task.assignments.some(assignment => + parseInt(assignment.user_id) === currentUser.id + ); + if (isExecutor) { + // Исполнитель может менять только статус + return false; + } + } + + return false; +} \ No newline at end of file diff --git a/public/ui.js b/public/ui.js new file mode 100644 index 0000000..e57426b --- /dev/null +++ b/public/ui.js @@ -0,0 +1,468 @@ +// ui.js - UI функции и рендеринг + +function showSection(sectionName) { + document.querySelectorAll('.section').forEach(section => { + section.classList.remove('active'); + }); + + document.getElementById(sectionName + '-section').classList.add('active'); + + if (sectionName === 'tasks') { + loadTasks(); + } else if (sectionName === 'logs') { + loadActivityLogs(); + } else if (sectionName === 'kanban') { + loadKanbanTasks(); + } + + // Загрузка профиля при переходе в личный кабинет + if (sectionName === 'profile') { + loadUserProfile(); + loadNotificationSettings(); + } +} + +function renderTasks() { + const container = document.getElementById('tasks-list'); + const showDeleted = document.getElementById('show-deleted')?.checked || false; + + let filteredTasks = tasks; + if (!showDeleted) { + filteredTasks = tasks.filter(task => task.status === 'active'); + } + + if (filteredTasks.length === 0) { + container.innerHTML = '
Задачи не найдены
'; + return; + } + + container.innerHTML = filteredTasks.map(task => { + const isExpanded = expandedTasks.has(task.id); + const overallStatus = getTaskOverallStatus(task); + const statusClass = getStatusClass(overallStatus); + const isDeleted = task.status === 'deleted'; + const isClosed = task.closed_at !== null; + const userRole = getUserRoleInTask(task); + const canEdit = canUserEditTask(task); + const isCopy = task.original_task_id !== null; + + const timeLeftInfo = getTimeLeftInfo(task); + + return ` +
+
+
+
+ Задача №${task.id} + ${task.title} + ${isDeleted ? 'Удалена' : ''} + ${isClosed ? 'Закрыта' : ''} + ${isCopy ? 'Копия' : ''} + ${timeLeftInfo ? `${timeLeftInfo.text}` : ''} + ${userRole} +
+
${getStatusText(overallStatus)}
+
+ ▼ +
+
+
+ +
+
+ ${!isDeleted && !isClosed ? ` + ${canEdit ? `` : ''} + + ${canEdit ? `` : ''} + ${canEdit ? `` : ''} + ${canEdit ? `` : ''} + ` : ''} + ${isClosed && canEdit ? ` + + ` : ''} + ${isDeleted && currentUser.role === 'admin' ? ` + + ` : ''} +
+ + ${isCopy && task.original_task_title ? ` +
+ Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name}) +
+ ` : ''} + +
${task.description || 'Нет описания'}
+ + ${task.rework_comment ? ` +
+ Комментарий к доработке: ${task.rework_comment} +
+ ` : ''} + +
+
+ Создана: ${formatDateTime(task.start_date || task.created_at)} + ${task.due_date ? ` | Выполнить до: ${formatDateTime(task.due_date)}` : ''} + ${showingTasksWithoutDate ? 'Без срока' : ''} +
+
+ Файлы: + ${task.files && task.files.length > 0 ? + `
${task.files.map(file => renderFileIcon(file)).join('')}
` : + 'нет файлов' + } +
+
+ +
+ Исполнители: +${task.assignments && task.assignments.length > 0 ? + renderAssignmentList(task.assignments, task.id, canEdit) : + '
Не назначены
' +} +
+ +
+ Создана: ${formatDateTime(task.created_at)} | Автор: ${task.creator_name} + ${task.deleted_at ? `
Удалена: ${formatDateTime(task.deleted_at)}` : ''} + ${task.closed_at ? `
Закрыта: ${formatDateTime(task.closed_at)}` : ''} +
+
+
+ `; + }).join(''); +} + +// Улучшенная функция рендеринга списка исполнителей с фильтрацией +function renderAssignmentList(assignments, taskId, canEdit) { + if (!assignments || assignments.length === 0) { + return '
Не назначены
'; + } + + // Создаем контейнер с возможностью фильтрации + return ` +
+
+ + ${assignments.length} исполнителей +
+
+ ${assignments.map(assignment => renderAssignment(assignment, taskId, canEdit)).join('')} +
+
+ `; +} + +// Функция для фильтрации исполнителей в конкретной задаче +function filterAssignments(taskId) { + const filterInput = document.querySelector(`.assignment-filter-input[data-task-id="${taskId}"]`); + const scrollContainer = document.getElementById(`assignments-${taskId}`); + const filterCount = document.getElementById(`filter-count-${taskId}`); + + if (!filterInput || !scrollContainer) return; + + const searchTerm = filterInput.value.toLowerCase(); + const assignments = scrollContainer.querySelectorAll('.assignment'); + + let visibleCount = 0; + + assignments.forEach(assignment => { + const userName = assignment.querySelector('strong')?.textContent?.toLowerCase() || ''; + const userLogin = assignment.querySelector('small')?.textContent?.toLowerCase() || ''; + + const isVisible = userName.includes(searchTerm) || + userLogin.includes(searchTerm) || + searchTerm === ''; + + assignment.style.display = isVisible ? '' : 'none'; + + if (isVisible) { + visibleCount++; + } + }); + + if (filterCount) { + filterCount.textContent = `${visibleCount} из ${assignments.length} исполнителей`; + } +} + +function toggleTask(taskId) { + if (expandedTasks.has(taskId)) { + expandedTasks.delete(taskId); + } else { + expandedTasks.add(taskId); + loadTaskFiles(taskId); + } + renderTasks(); +} + +function getTimeLeftInfo(task) { + if (!task.due_date || task.closed_at) return null; + + const dueDate = new Date(task.due_date); + const now = new Date(); + const timeLeft = dueDate.getTime() - now.getTime(); + const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); + + if (hoursLeft <= 0) return null; + + if (hoursLeft <= 24) { + return { + text: `Менее 24ч`, + class: 'deadline-24h' + }; + } else if (hoursLeft <= 48) { + return { + text: `Менее 48ч`, + class: 'deadline-48h' + }; + } + + return null; +} + +function renderAssignment(assignment, taskId, canEdit) { + const statusClass = getStatusClass(assignment.status); + const isCurrentUser = assignment.user_id === currentUser.id; + const isOverdue = assignment.status === 'overdue'; + const isRework = assignment.status === 'rework'; + + const timeLeftInfo = getAssignmentTimeLeftInfo(assignment); + + return ` +
+ +
+ ${assignment.user_name} + ${isCurrentUser ? '(Вы)' : ''} + ${timeLeftInfo ? `${timeLeftInfo.text}` : ''} + ${assignment.start_date || assignment.due_date ? ` +
+ ${assignment.start_date ? `Начало: ${formatDateTime(assignment.start_date)}` : ''} + ${assignment.due_date ? `Выполнить до: ${formatDateTime(assignment.due_date)}` : ''} +
+ ` : ''} + ${assignment.rework_comment ? ` +
+ Комментарий: ${assignment.rework_comment} +
+ ` : ''} +
+
+ ${isCurrentUser && assignment.status === 'assigned' ? + `` : ''} + ${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue' || assignment.status === 'rework') ? + `` : ''} + ${canEdit ? + `` : ''} +
+
+ `; +} + +function getAssignmentTimeLeftInfo(assignment) { + if (!assignment.due_date || assignment.status === 'completed') return null; + + const dueDate = new Date(assignment.due_date); + const now = new Date(); + const timeLeft = dueDate.getTime() - now.getTime(); + const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000)); + + if (hoursLeft <= 0) return null; + + if (hoursLeft <= 24) { + return { + text: `Осталось ${hoursLeft}ч`, + class: 'deadline-24h' + }; + } else if (hoursLeft <= 48) { + return { + text: `Осталось ${hoursLeft}ч`, + class: 'deadline-48h' + }; + } + + return null; +} + +function getTaskOverallStatus(task) { + if (task.status === 'deleted') return 'deleted'; + if (task.closed_at) return 'closed'; + if (!task.assignments || task.assignments.length === 0) return 'unassigned'; + + const assignments = task.assignments; + let hasAssigned = false; + let hasInProgress = false; + let hasOverdue = false; + let hasRework = false; + let allCompleted = true; + + for (let assignment of assignments) { + if (assignment.status === 'assigned') { + hasAssigned = true; + allCompleted = false; + } else if (assignment.status === 'in_progress') { + hasInProgress = true; + allCompleted = false; + } else if (assignment.status === 'overdue') { + hasOverdue = true; + allCompleted = false; + } else if (assignment.status === 'rework') { + hasRework = true; + allCompleted = false; + } else if (assignment.status !== 'completed') { + allCompleted = false; + } + } + + if (allCompleted) return 'completed'; + if (hasRework) return 'rework'; + if (hasOverdue) return 'overdue'; + if (hasInProgress) return 'in_progress'; + if (hasAssigned) return 'assigned'; + return 'unassigned'; +} + +function getStatusClass(status) { + switch (status) { + case 'deleted': return 'status-gray'; + case 'closed': return 'status-gray'; + case 'unassigned': return 'status-purple'; + case 'assigned': return 'status-red'; + case 'in_progress': return 'status-orange'; + case 'rework': return 'status-yellow'; + case 'overdue': return 'status-darkred'; + case 'completed': return 'status-green'; + default: return 'status-purple'; + } +} + +function getStatusText(status) { + switch (status) { + case 'deleted': return 'Удалена'; + case 'closed': return 'Закрыта'; + case 'unassigned': return 'Не назначена'; + case 'assigned': return 'Назначена'; + case 'in_progress': return 'В работе'; + case 'rework': return 'На доработке'; + case 'overdue': return 'Просрочена'; + case 'completed': return 'Выполнена'; + default: return 'Неизвестно'; + } +} + +function getUserRoleInTask(task) { + if (!currentUser) return 'Нет доступа'; + + if (currentUser.role === 'admin') return 'Администратор'; + + if (parseInt(task.created_by) === currentUser.id) { + if (task.assignments && task.assignments.length > 0) { + const assignedToOthers = task.assignments.some(assignment => + parseInt(assignment.user_id) !== currentUser.id + ); + if (assignedToOthers) { + return 'Создатель (только просмотр)'; + } + } + return 'Создатель'; + } + + if (task.assignments) { + const isExecutor = task.assignments.some(assignment => + parseInt(assignment.user_id) === currentUser.id + ); + if (isExecutor) return 'Исполнитель'; + } + + return 'Наблюдатель'; +} + +function getRoleBadgeClass(role) { + switch (role) { + case 'Администратор': return 'role-admin'; + case 'Заказчик': return 'role-creator'; + case 'Исполнитель': return 'role-executor'; + default: return ''; + } +} + +function formatDateTime(dateTimeString) { + if (!dateTimeString) return ''; + const date = new Date(dateTimeString); + return date.toLocaleString('ru-RU'); +} + +function formatDateTimeForInput(dateTimeString) { + if (!dateTimeString) return ''; + const date = new Date(dateTimeString); + return date.toISOString().slice(0, 16); +} + +// Логи активности +async function loadActivityLogs() { + try { + const response = await fetch('/api/activity-logs'); + const logs = await response.json(); + renderLogs(logs); + } catch (error) { + console.error('Ошибка загрузки логов:', error); + } +} + +function renderLogs(logs) { + const container = document.getElementById('logs-list'); + + if (logs.length === 0) { + container.innerHTML = '
Логи не найдены
'; + return; + } + + container.innerHTML = logs.map(log => ` +
+
${formatDateTime(log.created_at)}
+
${log.user_name} - ${getActionText(log.action)}
+
Задача: "${log.task_title}"
+ ${log.details ? `
Детали: ${log.details}
` : ''} +
+ `).join(''); +} + +function getActionText(action) { + const actions = { + 'TASK_CREATED': 'создал задачу', + 'TASK_COPIED': 'создал копию задачи', + 'TASK_UPDATED': 'обновил задачу', + 'TASK_DELETED': 'удалил задачу', + 'TASK_RESTORED': 'восстановил задачу', + 'TASK_ASSIGNED': 'назначил задачу', + 'TASK_ASSIGNMENTS_UPDATED': 'обновил назначения', + 'ASSIGNMENT_UPDATED': 'обновил сроки исполнителя', + 'STATUS_CHANGED': 'изменил статус задачи', + 'FILE_UPLOADED': 'загрузил файл', + 'FILE_COPIED': 'скопировал файл', + 'TASK_SENT_FOR_REWORK': 'вернул задачу на доработку', + 'TASK_CLOSED': 'закрыл задачу', + 'TASK_REOPENED': 'открыл задачу' + }; + + return actions[action] || action; +} + +// Функция для просмотра деталей задачи +async function viewTaskDetails(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}`); + const task = await response.json(); + + // Можно открыть модальное окно с подробной информацией + // Или показать в отдельной секции + alert(`Задача: ${task.title}\n\nОписание: ${task.description || 'Нет описания'}\n\nСоздатель: ${task.creator_name}\nСрок: ${task.due_date ? new Date(task.due_date).toLocaleString('ru-RU') : 'Не установлен'}`); + } catch (error) { + console.error('Ошибка загрузки деталей задачи:', error); + } +} \ No newline at end of file diff --git a/public/users.js b/public/users.js new file mode 100644 index 0000000..1c51d78 --- /dev/null +++ b/public/users.js @@ -0,0 +1,142 @@ +// users.js - Управление пользователями +let users = []; +let allUsers = []; +let filteredUsers = []; +let selectedUsers = []; +let editSelectedUsers = []; +let copySelectedUsers = []; + +async function loadUsers() { + try { + const response = await fetch('/api/users'); + users = await response.json(); + allUsers = users; + filteredUsers = [...users]; + renderUsersChecklist(); + renderEditUsersChecklist(); + renderCopyUsersChecklist(); + populateFilterDropdowns(); + } catch (error) { + console.error('Ошибка загрузки пользователей:', error); + } +} + +function populateFilterDropdowns() { + const creatorFilter = document.getElementById('creator-filter'); + const assigneeFilter = document.getElementById('assignee-filter'); + + creatorFilter.innerHTML = ''; + assigneeFilter.innerHTML = ''; + + users.forEach(user => { + const creatorOption = document.createElement('option'); + creatorOption.value = user.id; + creatorOption.textContent = `${user.name} (${user.login})`; + creatorFilter.appendChild(creatorOption.cloneNode(true)); + + const assigneeOption = creatorOption.cloneNode(true); + assigneeFilter.appendChild(assigneeOption); + }); +} + +function filterUsers() { + const search = document.getElementById('user-search').value.toLowerCase(); + filteredUsers = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderUsersChecklist(); +} + +function filterEditUsers() { + const search = document.getElementById('edit-user-search').value.toLowerCase(); + const filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderEditUsersChecklist(filtered); +} + +function filterCopyUsers() { + const search = document.getElementById('copy-user-search').value.toLowerCase(); + const filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderCopyUsersChecklist(filtered); +} + +function renderUsersChecklist() { + const container = document.getElementById('users-checklist'); + container.innerHTML = filteredUsers + .filter(user => user.id !== currentUser.id) + .map(user => ` +
+ +
+ `).join(''); +} + +function renderEditUsersChecklist(filtered = users) { + const container = document.getElementById('edit-users-checklist'); + container.innerHTML = filtered + .filter(user => user.id !== currentUser.id) + .map(user => ` +
+ +
+ `).join(''); +} + +function renderCopyUsersChecklist(filtered = users) { + const container = document.getElementById('copy-users-checklist'); + container.innerHTML = filtered + .filter(user => user.id !== currentUser.id) + .map(user => ` +
+ +
+ `).join(''); +} + +function toggleUserSelection(checkbox, userId) { + if (checkbox.checked) { + selectedUsers.push(userId); + } else { + selectedUsers = selectedUsers.filter(id => id !== userId); + } +} + +function toggleEditUserSelection(checkbox, userId) { + if (checkbox.checked) { + editSelectedUsers.push(userId); + } else { + editSelectedUsers = editSelectedUsers.filter(id => id !== userId); + } +} + +function toggleCopyUserSelection(checkbox, userId) { + if (checkbox.checked) { + copySelectedUsers.push(userId); + } else { + copySelectedUsers = copySelectedUsers.filter(id => id !== userId); + } +} \ No newline at end of file diff --git a/server.js b/server.js index 8629584..c8677af 100644 --- a/server.js +++ b/server.js @@ -551,6 +551,214 @@ app.get('/admin', (req, res) => { 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('🚀 Инициализация сервера...'); @@ -612,7 +820,7 @@ async function initializeServer() { app.use(stubRouter); console.log('⚠️ Создана заглушка для админ роутера из-за ошибки'); } - + // 7. Помечаем сервер как готовый serverReady = true;