From 8489bba63bc344ae0328aec1ae4e72ab60176e4c Mon Sep 17 00:00:00 2001 From: kalugin66 <150135283+kalugin1988@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:16:25 +0500 Subject: [PATCH] Add files via upload --- auth.js | 230 ++++++++++++ database.js | 234 ++++++++++++ package.json | 22 ++ public/index.html | 195 ++++++++++ public/script.js | 816 ++++++++++++++++++++++++++++++++++++++++++ public/style.css | 893 ++++++++++++++++++++++++++++++++++++++++++++++ server.js | 769 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 3159 insertions(+) create mode 100644 auth.js create mode 100644 database.js create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/script.js create mode 100644 public/style.css create mode 100644 server.js diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..2ed8ab8 --- /dev/null +++ b/auth.js @@ -0,0 +1,230 @@ +const bcrypt = require('bcryptjs'); +const { db } = require('./database'); +const fetch = require('node-fetch'); + +class AuthService { + constructor() { + this.initUsers(); + } + + async initUsers() { + // Создаем пользователей из .env + const users = [ + { + login: process.env.USER_1_LOGIN, + password: process.env.USER_1_PASSWORD, + name: process.env.USER_1_NAME, + email: process.env.USER_1_EMAIL, + auth_type: 'local' + }, + { + login: process.env.USER_2_LOGIN, + password: process.env.USER_2_PASSWORD, + name: process.env.USER_2_NAME, + email: process.env.USER_2_EMAIL, + auth_type: 'local' + }, + { + login: process.env.USER_3_LOGIN, + password: process.env.USER_3_PASSWORD, + name: process.env.USER_3_NAME, + email: process.env.USER_3_EMAIL, + auth_type: 'local' + } + ]; + + for (const userData of users) { + if (userData.login && userData.password) { + await this.createUserIfNotExists(userData); + } + } + } + + async createUserIfNotExists(userData) { + return new Promise((resolve, reject) => { + db.get("SELECT id FROM users WHERE login = ?", [userData.login], async (err, row) => { + if (err) { + reject(err); + return; + } + + if (!row) { + const hashedPassword = await bcrypt.hash(userData.password, 10); + db.run( + "INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))", + [ + userData.login, + hashedPassword, + userData.name, + userData.email, + 'teacher', + userData.auth_type || 'local' + ], + function(err) { + if (err) { + reject(err); + } else { + console.log(`Создан пользователь: ${userData.name}`); + resolve(this.lastID); + } + } + ); + } else { + resolve(row.id); + } + }); + }); + } + + async authenticateLocal(login, password) { + return new Promise((resolve, reject) => { + db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'local'", [login], async (err, user) => { + if (err) { + reject(err); + return; + } + + if (!user) { + resolve(null); + return; + } + + const isValid = await bcrypt.compare(password, user.password); + if (isValid) { + // Не возвращаем пароль + const { password, ...userWithoutPassword } = user; + resolve(userWithoutPassword); + } else { + resolve(null); + } + }); + }); + } + + async authenticateLDAP(username, password) { + try { + const response = await fetch(process.env.LDAP_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + return this.processLDAPUser(data); + } else { + return null; + } + } catch (error) { + console.error('LDAP authentication error:', error); + return null; + } + } + + async processLDAPUser(ldapData) { + const { username, full_name, groups, description } = ldapData; + + // Определяем роль пользователя на основе групп + const allowedGroups = process.env.ALLOWED_GROUPS ? + process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : []; + + const isAdmin = groups.some(group => + allowedGroups.includes(group) + ); + + const role = isAdmin ? 'admin' : 'teacher'; + + // Сохраняем/обновляем пользователя в базе + return new Promise((resolve, reject) => { + db.get("SELECT * FROM users WHERE login = ? AND auth_type = 'ldap'", [username], async (err, existingUser) => { + if (err) { + reject(err); + return; + } + + const userData = { + login: username, + name: full_name, + email: `${username}@school25.ru`, + role: role, + auth_type: 'ldap', + groups: JSON.stringify(groups), + description: description || '', + last_login: new Date().toISOString() + }; + + if (existingUser) { + // Обновляем существующего пользователя + db.run( + `UPDATE users SET + name = ?, email = ?, role = ?, groups = ?, description = ?, last_login = datetime('now') + WHERE id = ?`, + [userData.name, userData.email, userData.role, userData.groups, userData.description, existingUser.id], + function(err) { + if (err) { + reject(err); + } else { + resolve({ ...existingUser, ...userData }); + } + } + ); + } else { + // Создаем нового пользователя + db.run( + `INSERT INTO users (login, name, email, role, auth_type, groups, description, created_at, last_login) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + [userData.login, userData.name, userData.email, userData.role, userData.auth_type, + userData.groups, userData.description], + function(err) { + if (err) { + reject(err); + } else { + resolve({ + id: this.lastID, + ...userData + }); + } + } + ); + } + }); + }); + } + + async authenticate(login, password) { + // Сначала пробуем локальную авторизацию + const localUser = await this.authenticateLocal(login, password); + if (localUser) { + return localUser; + } + + // Если локальная не сработала, пробуем LDAP + const ldapUser = await this.authenticateLDAP(login, password); + if (ldapUser) { + return ldapUser; + } + + return null; + } + + getUserById(id) { + return new Promise((resolve, reject) => { + db.get("SELECT id, login, name, email, role, auth_type, groups, description FROM users WHERE id = ?", [id], (err, user) => { + if (err) { + reject(err); + } else { + resolve(user); + } + }); + }); + } +} + +module.exports = new AuthService(); \ No newline at end of file diff --git a/database.js b/database.js new file mode 100644 index 0000000..6e5283d --- /dev/null +++ b/database.js @@ -0,0 +1,234 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); +require('dotenv').config(); + +const dbPath = path.join(__dirname, 'school_crm.db'); + +// Создаем папки если нет +const createDirIfNotExists = (dirPath) => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +const uploadsDir = path.join(__dirname, 'uploads'); +const tasksDir = path.join(uploadsDir, 'tasks'); +const logsDir = path.join(__dirname, 'logs'); + +createDirIfNotExists(uploadsDir); +createDirIfNotExists(tasksDir); +createDirIfNotExists(logsDir); + +const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('Ошибка подключения к БД:', err.message); + } else { + console.log('Подключение к SQLite установлено'); + initializeDatabase(); + } +}); + +function initializeDatabase() { + // Обновленная таблица пользователей с поддержкой LDAP + db.run(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + role TEXT DEFAULT 'teacher', + auth_type TEXT DEFAULT 'local', + groups TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + + // Таблица задач + db.run(`CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'active', + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + deleted_by INTEGER, + original_task_id INTEGER, + start_date DATETIME, + due_date DATETIME, + FOREIGN KEY (created_by) REFERENCES users (id), + FOREIGN KEY (deleted_by) REFERENCES users (id), + FOREIGN KEY (original_task_id) REFERENCES tasks (id) + )`); + + // Таблица назначений задач + db.run(`CREATE TABLE IF NOT EXISTS task_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status TEXT DEFAULT 'assigned', + start_date DATETIME, + due_date DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + // Таблица файлов + db.run(`CREATE TABLE IF NOT EXISTS task_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + // Таблица логов + db.run(`CREATE TABLE IF NOT EXISTS activity_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks (id), + FOREIGN KEY (user_id) REFERENCES users (id) + )`); + + console.log('База данных инициализирована'); +} + +function createTaskFolder(taskId) { + const taskFolder = path.join(tasksDir, taskId.toString()); + createDirIfNotExists(taskFolder); + return taskFolder; +} + +function createUserTaskFolder(taskId, userLogin) { + const taskFolder = path.join(tasksDir, taskId.toString()); + const userFolder = path.join(taskFolder, userLogin); + createDirIfNotExists(userFolder); + return userFolder; +} + +function saveTaskMetadata(taskId, title, description, createdBy, originalTaskId = null, startDate = null, dueDate = null) { + const taskFolder = createTaskFolder(taskId); + const metadata = { + id: taskId, + title: title, + description: description, + status: 'active', + created_by: createdBy, + original_task_id: originalTaskId, + start_date: startDate, + due_date: dueDate, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + files: [] + }; + + const metadataPath = path.join(taskFolder, 'task.json'); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +function updateTaskMetadata(taskId, updates) { + const metadataPath = path.join(tasksDir, taskId.toString(), 'task.json'); + if (fs.existsSync(metadataPath)) { + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + const updatedMetadata = { ...metadata, ...updates, updated_at: new Date().toISOString() }; + fs.writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2)); + } +} + +function logActivity(taskId, userId, action, details = '') { + db.run( + "INSERT INTO activity_logs (task_id, user_id, action, details) VALUES (?, ?, ?, ?)", + [taskId, userId, action, details] + ); + + const logEntry = `${new Date().toISOString()} - User ${userId}: ${action} - Task ${taskId} - ${details}\n`; + fs.appendFileSync(path.join(logsDir, 'activity.log'), logEntry); +} + +// Функция для проверки прав доступа к задаче +function checkTaskAccess(userId, taskId, callback) { + // Сначала получаем роль пользователя + db.get("SELECT role FROM users WHERE id = ?", [userId], (err, user) => { + if (err) { + callback(err, false); + return; + } + + // Администраторы имеют доступ ко всем задачам + if (user && user.role === 'admin') { + callback(null, true); + return; + } + + // Обычные пользователи видят только задачи где они заказчик или исполнитель + const query = ` + SELECT 1 FROM tasks t + WHERE t.id = ? AND ( + t.created_by = ? + OR EXISTS (SELECT 1 FROM task_assignments WHERE task_id = t.id AND user_id = ?) + ) + `; + + db.get(query, [taskId, userId, userId], (err, row) => { + callback(err, !!row); + }); + }); +} + +// Функция для проверки просроченных задач +function checkOverdueTasks() { + const now = new Date().toISOString(); + + const query = ` + SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date + FROM task_assignments ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.due_date IS NOT NULL + AND ta.due_date < ? + AND ta.status NOT IN ('completed', 'overdue') + AND t.status = 'active' + `; + + db.all(query, [now], (err, assignments) => { + if (err) { + console.error('Ошибка при проверке просроченных задач:', err); + return; + } + + assignments.forEach(assignment => { + db.run( + "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", + [assignment.id] + ); + logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); + }); + }); +} + +// Запускаем проверку просроченных задач каждую минуту +setInterval(checkOverdueTasks, 60000); + +module.exports = { + db, + logActivity, + createTaskFolder, + createUserTaskFolder, + saveTaskMetadata, + updateTaskMetadata, + checkTaskAccess +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..33afa3f --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "school-crm", + "version": "1.0.0", + "description": "CRM система для школы с управлением задачами", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "~2.4.3", + "dotenv": "~16.3.1", + "express": "^4.21.2", + "express-session": "^1.18.2", + "multer": "^2.0.2", + "node-fetch": "~2.6.7", + "sqlite3": "~5.1.6" + }, + "devDependencies": { + "nodemon": "~3.0.1" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9215f64 --- /dev/null +++ b/public/index.html @@ -0,0 +1,195 @@ + + + + + + School CRM - Управление задачами + + + + + + +
+
+

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

+ + +
+ +
+ +
+

Все задачи

+
+ +
+
+
+ + +
+

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

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

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

+
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..0d8191f --- /dev/null +++ b/public/script.js @@ -0,0 +1,816 @@ +let currentUser = null; +let users = []; +let tasks = []; + +// Инициализация при загрузке +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; + + // Показываем чекбокс удаленных задач только для администраторов + if (currentUser.role === 'admin') { + document.getElementById('tasks-controls').style.display = 'block'; + } else { + document.getElementById('tasks-controls').style.display = 'none'; + } + + loadUsers(); + loadTasks(); + loadActivityLogs(); + 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('files').addEventListener('change', updateFileList); +} + +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(); + } +} + +async function loadUsers() { + try { + const response = await fetch('/api/users'); + users = await response.json(); + renderUsersChecklist(); + renderEditUsersChecklist(); + renderCopyUsersChecklist(); + } catch (error) { + console.error('Ошибка загрузки пользователей:', error); + } +} + +async function loadTasks() { + try { + const response = await fetch('/api/tasks'); + tasks = await response.json(); + renderTasks(); + + // Загружаем файлы для каждой задачи + tasks.forEach(task => { + loadTaskFiles(task.id); + }); + } 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 = users + .filter(user => user.id !== currentUser.id) + .map(user => ` +
+ +
+ `).join(''); +} + +function renderEditUsersChecklist() { + const container = document.getElementById('edit-users-checklist'); + container.innerHTML = users + .filter(user => user.id !== currentUser.id) + .map(user => ` +
+ +
+ `).join(''); +} + +function renderCopyUsersChecklist() { + const container = document.getElementById('copy-users-checklist'); + container.innerHTML = users + .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 overallStatus = getTaskOverallStatus(task); + const statusClass = getStatusClass(overallStatus); + const isDeleted = task.status === 'deleted'; + const userRole = getUserRoleInTask(task); + const canEdit = canUserEditTask(task); + const isCopy = task.original_task_id !== null; + + return ` +
+
+ ${!isDeleted ? ` + + + + ` : ''} + ${isDeleted && currentUser.role === 'admin' ? ` + + ` : ''} +
+ +
+
+ ${task.title} + ${isDeleted ? 'Удалена' : ''} + ${isCopy ? 'Копия' : ''} + ${userRole} +
+
${getStatusText(overallStatus)}
+
+ + ${isCopy && task.original_task_title ? ` +
+ Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name}) +
+ ` : ''} + +
${task.description || 'Нет описания'}
+ + ${task.start_date || task.due_date ? ` +
+ ${task.start_date ? `
Начать: ${formatDateTime(task.start_date)}
` : ''} + ${task.due_date ? `
Выполнить до: ${formatDateTime(task.due_date)}
` : ''} +
+ ` : ''} + +
+ Исполнители: + ${task.assignments && task.assignments.length > 0 ? + task.assignments.map(assignment => renderAssignment(assignment, task.id, canEdit)).join('') : + '
Не назначены
' + } +
+ +
+ Файлы: +
Загрузка...
+
+ +
+ Создана: ${formatDateTime(task.created_at)} | Автор: ${task.creator_name} + ${task.deleted_at ? `
Удалена: ${formatDateTime(task.deleted_at)}` : ''} +
+
+ `; + }).join(''); +} + +function renderAssignment(assignment, taskId, canEdit) { + const statusClass = getStatusClass(assignment.status); + const isCurrentUser = assignment.user_id === currentUser.id; + const isOverdue = assignment.status === 'overdue'; + + return ` +
+ +
+ ${assignment.user_name} + ${isCurrentUser ? '(Вы)' : ''} + ${assignment.start_date || assignment.due_date ? ` +
+ ${assignment.start_date ? `Начать: ${formatDateTime(assignment.start_date)}` : ''} + ${assignment.due_date ? `Выполнить до: ${formatDateTime(assignment.due_date)}` : ''} +
+ ` : ''} +
+
+ ${isCurrentUser && assignment.status === 'assigned' ? + `` : ''} + ${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue') ? + `` : ''} + ${canEdit ? + `` : ''} +
+
+ `; +} + +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 startDate = document.getElementById('start-date').value; + const dueDate = document.getElementById('due-date').value; + if (startDate) formData.append('startDate', startDate); + if (dueDate) formData.append('dueDate', dueDate); + + const assignedUsers = document.querySelectorAll('#users-checklist input[name="assignedUsers"]:checked'); + 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 = ''; + 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-start-date').value = task.start_date ? formatDateTimeForInput(task.start_date) : ''; + 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'; +} + +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 startDate = document.getElementById('edit-start-date').value; + const dueDate = document.getElementById('edit-due-date').value; + + const assignedUsers = document.querySelectorAll('#edit-users-checklist input[name="assignedUsers"]:checked'); + const assignedUserIds = Array.from(assignedUsers).map(cb => parseInt(cb.value)); + + try { + const response = await fetch(`/api/tasks/${taskId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title, + description, + assignedUsers: assignedUserIds, + startDate: startDate || null, + dueDate: dueDate || null + }) + }); + + 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'; +} + +// В функции copyTask улучшаем обработку ошибок +async function copyTask(event) { + event.preventDefault(); + + const taskId = document.getElementById('copy-task-id').value; + const startDate = document.getElementById('copy-start-date').value; + const dueDate = document.getElementById('copy-due-date').value; + 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, + startDate: startDate || null, + dueDate: dueDate || null + }) + }); + + 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-start-date').value = assignment.start_date ? formatDateTimeForInput(assignment.start_date) : ''; + 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 startDate = document.getElementById('edit-assignment-start-date').value; + const dueDate = document.getElementById('edit-assignment-due-date').value; + + try { + const response = await fetch(`/api/tasks/${taskId}/assignment/${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + startDate: startDate || null, + dueDate: dueDate || null + }) + }); + + if (response.ok) { + alert('Сроки исполнителя обновлены!'); + closeEditAssignmentModal(); + 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('Ошибка восстановления задачи'); + } +} + +// В функции updateStatus улучшаем обработку ошибок +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.assignments || task.assignments.length === 0) return 'unassigned'; + + const assignments = task.assignments; + let hasAssigned = false; + let hasInProgress = false; + let hasOverdue = 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 !== 'completed') { + allCompleted = false; + } + } + + if (allCompleted) return 'completed'; + 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 'unassigned': return 'status-purple'; + case 'assigned': return 'status-red'; + case 'in_progress': return 'status-orange'; + case 'overdue': return 'status-darkred'; + case 'completed': return 'status-green'; + default: return 'status-purple'; + } +} + +function getStatusText(status) { + switch (status) { + case 'deleted': return 'Удалена'; + case 'unassigned': return 'Не назначена'; + case 'assigned': return 'Назначена'; + case 'in_progress': 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) 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) return true; // Заказчик + + 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'); + 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': 'загрузил файл' + }; + + 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 => ` +
+ + ${file.original_name} + + (${(file.file_size / 1024 / 1024).toFixed(2)} MB) + - загрузил: ${file.user_name} +
+ `).join('')} + `; + } + } + } catch (error) { + console.error('Ошибка загрузки файлов:', error); + } +} \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..f849db2 --- /dev/null +++ b/public/style.css @@ -0,0 +1,893 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #333; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; + display: none; +} + +/* Header Styles */ +header { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 1.5rem; + margin-bottom: 25px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +header h1 { + margin-bottom: 1.2rem; + text-align: center; + color: #2c3e50; + font-size: 2.2rem; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.user-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.2rem; + padding: 12px 20px; + background: linear-gradient(135deg, #667eea, #764ba2); + border-radius: 10px; + color: white; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.user-info span { + font-weight: 600; + font-size: 1.1rem; +} + +.user-info button { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.user-info button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +/* Navigation */ +nav { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +nav button { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; + border: none; + padding: 12px 24px; + border-radius: 10px; + cursor: pointer; + flex: 1; + min-width: 140px; + font-weight: 600; + font-size: 1rem; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); +} + +nav button:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); +} + +/* Section Styles */ +.section { + display: none; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + padding: 25px; + border-radius: 15px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + margin-bottom: 20px; +} + +.section.active { + display: block; + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Form Styles */ +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #2c3e50; + font-size: 1rem; +} + +input[type="text"], +input[type="password"], +input[type="datetime-local"], +textarea, +select { + width: 100%; + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 10px; + font-size: 1rem; + transition: all 0.3s ease; + background: rgba(255, 255, 255, 0.9); +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="datetime-local"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); + transform: translateY(-2px); +} + +textarea { + resize: vertical; + min-height: 120px; + font-family: inherit; +} + +/* Button Styles */ +button { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; + padding: 12px 30px; + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(39, 174, 96, 0.3); +} + +button:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(39, 174, 96, 0.4); +} + +button.delete-btn { + background: linear-gradient(135deg, #e74c3c, #c0392b); + box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3); +} + +button.delete-btn:hover { + box-shadow: 0 6px 20px rgba(231, 76, 60, 0.4); +} + +button.edit-btn { + background: linear-gradient(135deg, #f39c12, #e67e22); + box-shadow: 0 4px 15px rgba(243, 156, 18, 0.3); +} + +button.edit-btn:hover { + box-shadow: 0 6px 20px rgba(243, 156, 18, 0.4); +} + +button.copy-btn { + background: linear-gradient(135deg, #3498db, #2980b9); + box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3); +} + +button.copy-btn:hover { + box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4); +} + +button.restore-btn { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + box-shadow: 0 4px 15px rgba(155, 89, 182, 0.3); +} + +button.restore-btn:hover { + box-shadow: 0 6px 20px rgba(155, 89, 182, 0.4); +} + +button.edit-date-btn { + background: linear-gradient(135deg, #17a2b8, #138496); + padding: 6px 12px; + font-size: 0.85rem; + box-shadow: 0 2px 8px rgba(23, 162, 184, 0.3); +} + +button.edit-date-btn:hover { + box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4); +} + +/* Tasks Controls */ +#tasks-controls { + margin-bottom: 20px; + padding: 15px 20px; + background: linear-gradient(135deg, #ecf0f1, #bdc3c7); + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +#tasks-controls label { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-weight: normal; + color: #2c3e50; + cursor: pointer; +} + +#tasks-controls input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* Task Cards */ +.task-card { + border: none; + border-radius: 15px; + padding: 20px; + margin-bottom: 20px; + background: rgba(255, 255, 255, 0.9); + position: relative; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); + border-left: 5px solid #3498db; +} + +.task-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.task-card.deleted { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + border-left-color: #e74c3c; + opacity: 0.8; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + gap: 20px; +} + +.task-title { + font-size: 1.4rem; + font-weight: 700; + color: #2c3e50; + flex: 1; + line-height: 1.4; +} + +.task-status { + padding: 8px 16px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Status Colors */ +.status-purple { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + color: white; +} +.status-red { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; +} +.status-orange { + background: linear-gradient(135deg, #f39c12, #e67e22); + color: white; +} +.status-green { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; +} +.status-gray { + background: linear-gradient(135deg, #95a5a6, #7f8c8d); + color: white; +} +.status-darkred { + background: linear-gradient(135deg, #8b0000, #660000); + color: white; +} + +/* Badges */ +.deleted-badge { + background: #e74c3c; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.copy-badge { + background: #3498db; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +.role-badge { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 15px; + margin-left: 8px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.role-admin { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; +} +.role-creator { + background: linear-gradient(135deg, #27ae60, #219a52); + color: white; +} +.role-executor { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; +} + +/* Task Content */ +.task-original { + margin: 10px 0; + padding: 10px 15px; + background: linear-gradient(135deg, #e8f4fd, #d4e6f1); + border-radius: 8px; + border-left: 4px solid #3498db; +} + +.task-original small { + color: #2c3e50; + font-style: italic; + font-size: 0.9rem; +} + +.task-description { + margin: 15px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + line-height: 1.6; + color: #495057; +} + +.task-dates { + margin: 15px 0; + padding: 15px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 10px; + border-left: 4px solid #f39c12; +} + +.task-dates div { + margin: 8px 0; + font-size: 0.95rem; + color: #856404; +} + +.task-dates strong { + color: #e67e22; +} + +/* Assignments */ +.task-assignments { + margin: 20px 0; +} + +.assignment { + display: flex; + align-items: center; + margin: 10px 0; + padding: 12px 15px; + border-radius: 10px; + background: rgba(248, 249, 250, 0.8); + transition: all 0.3s ease; + border: 1px solid #e9ecef; +} + +.assignment:hover { + background: rgba(233, 236, 239, 0.8); + transform: translateX(5px); +} + +.assignment.overdue { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + border: 1px solid #e74c3c; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); } + 70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); } + 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } +} + +.assignment-status { + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 15px; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.status-assigned { + background: linear-gradient(135deg, #e74c3c, #c0392b); +} +.status-in_progress { + background: linear-gradient(135deg, #f39c12, #e67e22); +} +.status-completed { + background: linear-gradient(135deg, #27ae60, #219a52); +} +.status-overdue { + background: linear-gradient(135deg, #8b0000, #660000); +} + +.assignment-dates { + margin-top: 8px; + font-size: 0.85rem; + color: #6c757d; +} + +.assignment-dates small { + display: block; + margin: 3px 0; +} + +.action-buttons { + margin-top: 12px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.action-buttons button { + margin-right: 0; + padding: 8px 16px; + font-size: 0.9rem; + border-radius: 8px; +} + +/* Task Actions - ПРАВЫЙ НИЖНИЙ УГОЛ */ +.task-actions { + position: absolute; + bottom: 20px; + right: 20px; + display: flex; + gap: 8px; +} + +.task-actions button { + padding: 8px 12px; + font-size: 1rem; + border-radius: 8px; + min-width: auto; +} + +/* File List */ +.file-list { + margin-top: 15px; + padding: 15px; + background: #f8f9fa; + border-radius: 10px; +} + +.file-item { + display: flex; + align-items: center; + padding: 10px; + background: white; + margin-bottom: 8px; + border-radius: 8px; + border: 1px solid #e9ecef; + transition: all 0.3s ease; +} + +.file-item:hover { + transform: translateX(5px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.file-item a { + color: #3498db; + text-decoration: none; + margin-right: 15px; + font-weight: 500; + transition: color 0.3s ease; +} + +.file-item a:hover { + color: #2980b9; + text-decoration: underline; +} + +.file-item small { + color: #6c757d; + margin-left: auto; + font-size: 0.85rem; +} + +/* Task Meta */ +.task-meta { + margin-top: 15px; + padding-top: 15px; + border-top: 2px solid #e9ecef; +} + +.task-meta small { + color: #6c757d; + font-size: 0.9rem; +} + +/* Logs */ +.log-entry { + padding: 15px; + border-bottom: 1px solid #e9ecef; + font-size: 0.95rem; + transition: all 0.3s ease; +} + +.log-entry:hover { + background: #f8f9fa; + border-radius: 8px; +} + +.log-time { + color: #6c757d; + font-size: 0.85rem; + margin-bottom: 5px; +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(5px); + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + margin: 8% auto; + padding: 30px; + border-radius: 20px; + width: 90%; + max-width: 600px; + max-height: 85vh; + overflow-y: auto; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.3); + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { transform: translateY(-50px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.close { + color: #6c757d; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + position: absolute; + right: 20px; + top: 15px; + transition: all 0.3s ease; +} + +.close:hover { + color: #e74c3c; + transform: rotate(90deg); +} + +/* Checkbox Groups */ +.checkbox-group { + max-height: 250px; + overflow-y: auto; + border: 2px solid #e9ecef; + padding: 15px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.8); +} + +.checkbox-item { + margin: 8px 0; + padding: 8px 12px; + border-radius: 8px; + transition: all 0.3s ease; +} + +.checkbox-item:hover { + background: rgba(52, 152, 219, 0.1); + transform: translateX(5px); +} + +.checkbox-item label { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + font-weight: normal; + cursor: pointer; + color: #495057; +} + +.checkbox-item input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* Login Modal */ +#login-modal .modal-content { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.85)); + text-align: center; + max-width: 500px; +} + +#login-form .form-group { + display: flex; + flex-direction: column; + align-items: center; +} + +#login-form input[type="text"], +#login-form input[type="password"] { + width: 100%; + max-width: 300px; + margin: 0 auto; + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 10px; + font-size: 1rem; + transition: all 0.3s ease; +} + +#login-form input[type="text"]:focus, +#login-form input[type="password"]:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); + transform: translateY(-2px); +} + +.test-users { + margin-top: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border-radius: 15px; + border-left: 5px solid #3498db; +} + +.test-users h3 { + margin-bottom: 15px; + color: #2c3e50; + font-size: 1.2rem; +} + +.test-users ul { + list-style: none; + padding: 0; +} + +.test-users li { + padding: 8px 0; + border-bottom: 1px solid #dee2e6; + color: #495057; +} + +.test-users li:last-child { + border-bottom: none; +} + +.test-users strong { + color: #2c3e50; +} + +/* Loading States */ +.loading { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-size: 1.1rem; +} + +.error { + background: linear-gradient(135deg, #f8d7da, #f5c6cb); + color: #721c24; + padding: 15px 20px; + border-radius: 10px; + margin: 15px 0; + border: 1px solid #f5c6cb; + border-left: 5px solid #e74c3c; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .task-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .task-actions { + position: static; + margin-top: 15px; + justify-content: center; + width: 100%; + } + + nav { + flex-direction: column; + } + + .modal-content { + width: 95%; + margin: 5% auto; + padding: 20px; + } + + header h1 { + font-size: 1.8rem; + } + + .user-info { + flex-direction: column; + gap: 10px; + text-align: center; + } + + .assignment { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .action-buttons { + width: 100%; + justify-content: flex-start; + } + + #login-form input[type="text"], + #login-form input[type="password"] { + max-width: 100%; + } +} + +@media (max-width: 480px) { + .section { + padding: 15px; + } + + .task-card { + padding: 15px; + } + + .task-title { + font-size: 1.2rem; + } + + .task-status { + font-size: 0.8rem; + padding: 6px 12px; + } + + .task-actions { + gap: 5px; + } + + .task-actions button { + padding: 8px 12px; + font-size: 0.8rem; + } + + nav button { + padding: 10px 15px; + font-size: 0.9rem; + } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, #3498db, #2980b9); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, #2980b9, #1f618d); +} + +/* Focus States */ +button:focus, +input:focus, +textarea:focus, +select:focus { + outline: 2px solid #3498db; + outline-offset: 2px; +} + +/* Print Styles */ +@media print { + .task-actions, + nav, + .user-info button { + display: none !important; + } + + .task-card { + break-inside: avoid; + box-shadow: none; + border: 1px solid #ddd; + } +} \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..6ea8db8 --- /dev/null +++ b/server.js @@ -0,0 +1,769 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const session = require('express-session'); +require('dotenv').config(); + +const { db, logActivity, createUserTaskFolder, saveTaskMetadata, updateTaskMetadata, checkTaskAccess } = require('./database'); +const authService = require('./auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static('public')); +app.use('/uploads', express.static('uploads')); + +// Сессии +app.use(session({ + secret: process.env.SESSION_SECRET || 'fallback_secret', + resave: false, + saveUninitialized: false, + cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } +})); + +// Middleware для проверки аутентификации +const requireAuth = (req, res, next) => { + if (!req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); +}; + +// Настройка Multer +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const taskId = req.body.taskId || req.params.taskId; + const userLogin = req.session.user.login; + + if (taskId) { + const userFolder = createUserTaskFolder(taskId, userLogin); + cb(null, userFolder); + } else { + const tempDir = path.join(__dirname, 'uploads/temp'); + createDirIfNotExists(tempDir); + cb(null, tempDir); + } + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 300 * 1024 * 1024, + files: 15 + } +}); + +// Вспомогательная функция +const createDirIfNotExists = (dirPath) => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +// Вспомогательная функция для проверки просрочки +function checkIfOverdue(dueDate, status) { + if (!dueDate || status === 'completed') return false; + const now = new Date(); + const due = new Date(dueDate); + return due < now; +} + +// ==================== МАРШРУТЫ АУТЕНТИФИКАЦИИ ==================== + +app.post('/api/login', async (req, res) => { + const { login, password } = req.body; + + if (!login || !password) { + return res.status(400).json({ error: 'Логин и пароль обязательны' }); + } + + try { + const user = await authService.authenticate(login, password); + if (user) { + req.session.user = user; + + // Логируем успешный вход + console.log(`Успешная авторизация: ${user.name} (${user.login}) через ${user.auth_type}`); + if (user.groups) { + console.log(`Группы пользователя: ${user.groups}`); + } + + res.json({ + success: true, + user: { + id: user.id, + login: user.login, + name: user.name, + email: user.email, + role: user.role, + auth_type: user.auth_type, + groups: user.groups ? JSON.parse(user.groups) : [] + } + }); + } else { + console.log(`Неудачная попытка входа: ${login}`); + res.status(401).json({ error: 'Неверный логин или пароль' }); + } + } catch (error) { + console.error('Ошибка аутентификации:', error); + res.status(500).json({ error: 'Ошибка сервера при авторизации' }); + } +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy(); + res.json({ success: true }); +}); + +app.get('/api/user', (req, res) => { + if (req.session.user) { + res.json({ user: req.session.user }); + } else { + res.status(401).json({ error: 'Не аутентифицирован' }); + } +}); + +// ==================== МАРШРУТЫ ПОЛЬЗОВАТЕЛЕЙ ==================== + +app.get('/api/users', requireAuth, (req, res) => { + db.all("SELECT id, login, name, email, role, auth_type FROM users WHERE role IN ('admin', 'teacher') ORDER BY name", (err, rows) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(rows); + }); +}); + +// ==================== МАРШРУТЫ ЗАДАЧ ==================== + +// Получить задачи с учетом прав доступа +app.get('/api/tasks', requireAuth, (req, res) => { + const userId = req.session.user.id; + const showDeleted = req.session.user.role === 'admin'; + + let query = ` + SELECT DISTINCT + t.*, + u.name as creator_name, + u.login as creator_login, + ot.title as original_task_title, + ou.name as original_creator_name, + GROUP_CONCAT(DISTINCT ta.user_id) as assigned_user_ids, + GROUP_CONCAT(DISTINCT u2.name) as assigned_user_names + FROM tasks t + LEFT JOIN users u ON t.created_by = u.id + LEFT JOIN tasks ot ON t.original_task_id = ot.id + LEFT JOIN users ou ON ot.created_by = ou.id + LEFT JOIN task_assignments ta ON t.id = ta.task_id + LEFT JOIN users u2 ON ta.user_id = u2.id + WHERE 1=1 + `; + + // Для обычных пользователей показываем только задачи где они заказчик или исполнитель + if (req.session.user.role !== 'admin') { + query += ` AND (t.created_by = ${userId} OR ta.user_id = ${userId})`; + } + + if (!showDeleted) { + query += " AND t.status = 'active'"; + } + + query += " GROUP BY t.id ORDER BY t.created_at DESC"; + + db.all(query, (err, tasks) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + const taskPromises = tasks.map(task => { + return new Promise((resolve) => { + db.all(` + SELECT ta.*, u.name as user_name, u.login as user_login + FROM task_assignments ta + LEFT JOIN users u ON ta.user_id = u.id + WHERE ta.task_id = ? + `, [task.id], (err, assignments) => { + if (err) { + task.assignments = []; + resolve(task); + return; + } + + // Проверяем просрочку для каждого назначения + assignments.forEach(assignment => { + if (checkIfOverdue(assignment.due_date, assignment.status) && assignment.status !== 'completed') { + assignment.status = 'overdue'; + } + }); + + task.assignments = assignments || []; + resolve(task); + }); + }); + }); + + Promise.all(taskPromises).then(completedTasks => { + res.json(completedTasks); + }); + }); +}); + +// Создать задачу +app.post('/api/tasks', requireAuth, upload.array('files', 15), (req, res) => { + const { title, description, assignedUsers, originalTaskId, startDate, dueDate } = req.body; + const createdBy = req.session.user.id; + + if (!title) { + return res.status(400).json({ error: 'Название задачи обязательно' }); + } + + db.serialize(() => { + db.run( + "INSERT INTO tasks (title, description, created_by, original_task_id, start_date, due_date) VALUES (?, ?, ?, ?, ?, ?)", + [title, description, createdBy, originalTaskId || null, startDate || null, dueDate || null], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + const taskId = this.lastID; + + // Создаем папку задачи и сохраняем метаданные + saveTaskMetadata(taskId, title, description, createdBy, originalTaskId, startDate, dueDate); + + const action = originalTaskId ? 'TASK_COPIED' : 'TASK_CREATED'; + const details = originalTaskId ? + `Создана копия задачи: ${title}` : + `Создана задача: ${title}`; + + logActivity(taskId, createdBy, action, details); + + // Обрабатываем файлы + if (req.files && req.files.length > 0) { + const userFolder = createUserTaskFolder(taskId, req.session.user.login); + + req.files.forEach(file => { + const newPath = path.join(userFolder, path.basename(file.filename)); + fs.renameSync(file.path, newPath); + + db.run( + "INSERT INTO task_files (task_id, user_id, filename, original_name, file_path, file_size) VALUES (?, ?, ?, ?, ?, ?)", + [taskId, createdBy, path.basename(file.filename), file.originalname, newPath, file.size] + ); + + logActivity(taskId, createdBy, 'FILE_UPLOADED', `Загружен файл: ${file.originalname}`); + }); + + // Очищаем временную папку + const tempDir = path.join(__dirname, 'uploads/temp'); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + // Назначаем пользователей + if (assignedUsers) { + const userIds = Array.isArray(assignedUsers) ? assignedUsers : [assignedUsers]; + + userIds.forEach(userId => { + db.run( + "INSERT INTO task_assignments (task_id, user_id, start_date, due_date) VALUES (?, ?, ?, ?)", + [taskId, userId, startDate || null, dueDate || null] + ); + + logActivity(taskId, createdBy, 'TASK_ASSIGNED', `Задача назначена пользователю ${userId}`); + }); + } + + res.json({ + success: true, + taskId: taskId, + message: originalTaskId ? 'Копия задачи создана' : 'Задача успешно создана' + }); + } + ); + }); +}); + +// Копировать задачу +app.post('/api/tasks/:taskId/copy', requireAuth, (req, res) => { + const { taskId } = req.params; + const { assignedUsers, startDate, dueDate } = req.body; + const createdBy = req.session.user.id; + + // Проверяем доступ к оригинальной задаче + checkTaskAccess(createdBy, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); + } + + db.serialize(() => { + // Получаем данные оригинальной задачи + db.get("SELECT title, description FROM tasks WHERE id = ?", [taskId], (err, originalTask) => { + if (err || !originalTask) { + return res.status(404).json({ error: 'Оригинальная задача не найдена' }); + } + + // Создаем копию задачи + const newTitle = `Копия: ${originalTask.title}`; + + db.run( + "INSERT INTO tasks (title, description, created_by, original_task_id, start_date, due_date) VALUES (?, ?, ?, ?, ?, ?)", + [newTitle, originalTask.description, createdBy, taskId, startDate || null, dueDate || null], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + const newTaskId = this.lastID; + + // Создаем папку задачи и сохраняем метаданные + saveTaskMetadata(newTaskId, newTitle, originalTask.description, createdBy, taskId, startDate, dueDate); + + logActivity(newTaskId, createdBy, 'TASK_COPIED', `Создана копия задачи: ${newTitle}`); + + // Назначаем пользователей + if (assignedUsers && assignedUsers.length > 0) { + assignedUsers.forEach(userId => { + db.run( + "INSERT INTO task_assignments (task_id, user_id, start_date, due_date) VALUES (?, ?, ?, ?)", + [newTaskId, userId, startDate || null, dueDate || null] + ); + }); + + logActivity(newTaskId, createdBy, 'TASK_ASSIGNED', `Задача назначена пользователям: ${assignedUsers.join(', ')}`); + } + + res.json({ + success: true, + taskId: newTaskId, + message: 'Копия задачи успешно создана' + }); + } + ); + }); + }); + }); +}); + +// Получить задачу по ID с проверкой прав +app.get('/api/tasks/:taskId', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); + } + + const showDeleted = req.session.user.role === 'admin'; + let query = ` + SELECT + t.*, + u.name as creator_name, + u.login as creator_login, + ot.title as original_task_title, + ou.name as original_creator_name + FROM tasks t + LEFT JOIN users u ON t.created_by = u.id + LEFT JOIN tasks ot ON t.original_task_id = ot.id + LEFT JOIN users ou ON ot.created_by = ou.id + WHERE t.id = ? + `; + + if (!showDeleted) { + query += " AND t.status = 'active'"; + } + + db.get(query, [taskId], (err, task) => { + if (err || !task) { + return res.status(404).json({ error: 'Задача не найдена' }); + } + + // Получаем назначения + db.all(` + SELECT ta.*, u.name as user_name, u.login as user_login + FROM task_assignments ta + LEFT JOIN users u ON ta.user_id = u.id + WHERE ta.task_id = ? + `, [taskId], (err, assignments) => { + if (err) { + task.assignments = []; + res.json(task); + return; + } + + // Проверяем просрочку для каждого назначения + assignments.forEach(assignment => { + if (checkIfOverdue(assignment.due_date, assignment.status) && assignment.status !== 'completed') { + assignment.status = 'overdue'; + } + }); + + task.assignments = assignments || []; + res.json(task); + }); + }); + }); +}); + +// Обновить задачу с проверкой прав +app.put('/api/tasks/:taskId', requireAuth, (req, res) => { + const { taskId } = req.params; + const { title, description, assignedUsers, startDate, dueDate } = req.body; + const userId = req.session.user.id; + + if (!title) { + return res.status(400).json({ error: 'Название задачи обязательно' }); + } + + // Проверяем права - только создатель или администратор могут редактировать + db.get("SELECT created_by FROM tasks WHERE id = ? AND status = 'active'", [taskId], (err, task) => { + if (err || !task) { + return res.status(404).json({ error: 'Задача не найдена' }); + } + + if (req.session.user.role !== 'admin' && task.created_by !== userId) { + return res.status(403).json({ error: 'У вас нет прав для редактирования этой задачи' }); + } + + db.serialize(() => { + // Обновляем задачу + db.run( + "UPDATE tasks SET title = ?, description = ?, start_date = ?, due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [title, description, startDate || null, dueDate || null, taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + // Обновляем метаданные + updateTaskMetadata(taskId, { title, description, start_date: startDate, due_date: dueDate }); + + logActivity(taskId, userId, 'TASK_UPDATED', `Задача обновлена: ${title}`); + + // Обновляем назначения если переданы + if (assignedUsers) { + // Удаляем старые назначения + db.run("DELETE FROM task_assignments WHERE task_id = ?", [taskId], (err) => { + if (err) { + console.error('Ошибка удаления старых назначений:', err); + } + + // Добавляем новые назначения + const userIds = Array.isArray(assignedUsers) ? assignedUsers : [assignedUsers]; + userIds.forEach(userId => { + db.run( + "INSERT INTO task_assignments (task_id, user_id, start_date, due_date) VALUES (?, ?, ?, ?)", + [taskId, userId, startDate || null, dueDate || null] + ); + }); + + logActivity(taskId, userId, 'TASK_ASSIGNMENTS_UPDATED', `Назначения обновлены`); + }); + } + + res.json({ success: true, message: 'Задача обновлена' }); + } + ); + }); + }); +}); + +// Обновить сроки для конкретного исполнителя +app.put('/api/tasks/:taskId/assignment/:userId', requireAuth, (req, res) => { + const { taskId, userId } = req.params; + const { startDate, dueDate } = req.body; + const currentUserId = req.session.user.id; + + // Проверяем права - только создатель или администратор могут редактировать сроки + db.get("SELECT created_by FROM tasks WHERE id = ?", [taskId], (err, task) => { + if (err || !task) { + return res.status(404).json({ error: 'Задача не найдена' }); + } + + if (req.session.user.role !== 'admin' && task.created_by !== currentUserId) { + return res.status(403).json({ error: 'У вас нет прав для редактирования сроков' }); + } + + db.run( + "UPDATE task_assignments SET start_date = ?, due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ? AND user_id = ?", + [startDate || null, dueDate || null, taskId, userId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + if (this.changes === 0) { + return res.status(404).json({ error: 'Назначение не найдено' }); + } + + logActivity(taskId, currentUserId, 'ASSIGNMENT_UPDATED', `Обновлены сроки для пользователя ${userId}`); + res.json({ success: true, message: 'Сроки обновлены' }); + } + ); + }); +}); + +// Удалить задачу с проверкой прав +app.delete('/api/tasks/:taskId', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + // Проверяем права - только создатель или администратор могут удалять + db.get("SELECT created_by FROM tasks WHERE id = ? AND status = 'active'", [taskId], (err, task) => { + if (err || !task) { + return res.status(404).json({ error: 'Задача не найдена' }); + } + + if (req.session.user.role !== 'admin' && task.created_by !== userId) { + return res.status(403).json({ error: 'У вас нет прав для удаления этой задачи' }); + } + + // Помечаем задачу как удаленную + db.run( + "UPDATE tasks SET status = 'deleted', deleted_at = CURRENT_TIMESTAMP, deleted_by = ? WHERE id = ?", + [userId, taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + // Обновляем метаданные + updateTaskMetadata(taskId, { + status: 'deleted', + deleted_at: new Date().toISOString(), + deleted_by: userId + }); + + logActivity(taskId, userId, 'TASK_DELETED', `Задача помечена как удаленная`); + + res.json({ success: true, message: 'Задача удалена' }); + } + ); + }); +}); + +// Восстановить задачу +app.post('/api/tasks/:taskId/restore', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + // Только администратор может восстанавливать задачи + if (req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Недостаточно прав' }); + } + + db.run( + "UPDATE tasks SET status = 'active', deleted_at = NULL, deleted_by = NULL WHERE id = ?", + [taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + if (this.changes === 0) { + return res.status(404).json({ error: 'Задача не найдена' }); + } + + updateTaskMetadata(taskId, { + status: 'active', + deleted_at: null, + deleted_by: null + }); + + logActivity(taskId, userId, 'TASK_RESTORED', `Задача восстановлена`); + + res.json({ success: true, message: 'Задача восстановлена' }); + } + ); +}); + +// ==================== МАРШРУТЫ СТАТУСОВ ==================== + +// Обновить статус задачи +app.put('/api/tasks/:taskId/status', requireAuth, (req, res) => { + const { taskId } = req.params; + const { userId: targetUserId, status } = req.body; + const currentUserId = req.session.user.id; + + // Проверяем, что пользователь обновляет свой статус + if (parseInt(targetUserId) !== currentUserId) { + return res.status(403).json({ error: 'Недостаточно прав' }); + } + + if (!targetUserId || !status) { + return res.status(400).json({ error: 'userId и status обязательны' }); + } + + // Проверяем, что пользователь назначен на эту задачу + db.get("SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ?", [taskId, currentUserId], (err, assignment) => { + if (err || !assignment) { + return res.status(403).json({ error: 'Вы не назначены на эту задачу' }); + } + + // Если задача помечается как выполненная и она просрочена, оставляем статус completed + const finalStatus = status === 'completed' ? 'completed' : status; + + db.run( + "UPDATE task_assignments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ? AND user_id = ?", + [finalStatus, taskId, targetUserId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + if (this.changes === 0) { + res.status(404).json({ error: 'Назначение не найдено' }); + return; + } + + logActivity(taskId, targetUserId, 'STATUS_CHANGED', `Статус изменен на: ${finalStatus}`); + res.json({ success: true, message: 'Статус обновлен' }); + } + ); + }); +}); + +// ==================== МАРШРУТЫ ФАЙЛОВ ==================== + +// Добавить файлы к задаче +app.post('/api/tasks/:taskId/files', requireAuth, upload.array('files', 15), (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'Нет файлов для загрузки' }); + } + + // Проверяем доступ к задаче + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); + } + + req.files.forEach(file => { + db.run( + "INSERT INTO task_files (task_id, user_id, filename, original_name, file_path, file_size) VALUES (?, ?, ?, ?, ?, ?)", + [taskId, userId, path.basename(file.filename), file.originalname, file.path, file.size] + ); + + logActivity(taskId, userId, 'FILE_UPLOADED', `Загружен файл: ${file.originalname}`); + }); + + res.json({ success: true, message: 'Файлы успешно загружены' }); + }); +}); + +// Получить файлы задачи +app.get('/api/tasks/:taskId/files', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + // Проверяем доступ к задаче + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Задача не найдена или у вас нет прав доступа' }); + } + + db.all(` + SELECT tf.*, u.name as user_name, u.login as user_login + FROM task_files tf + LEFT JOIN users u ON tf.user_id = u.id + WHERE tf.task_id = ? + ORDER BY tf.uploaded_at DESC + `, [taskId], (err, files) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(files); + }); + }); +}); + +// Скачать файл +app.get('/api/files/:fileId/download', requireAuth, (req, res) => { + const { fileId } = req.params; + const userId = req.session.user.id; + + db.get("SELECT tf.*, t.id as task_id FROM task_files tf JOIN tasks t ON tf.task_id = t.id WHERE tf.id = ?", [fileId], (err, file) => { + if (err || !file) { + return res.status(404).json({ error: 'Файл не найдена' }); + } + + // Проверяем доступ к задаче файла + checkTaskAccess(userId, file.task_id, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(404).json({ error: 'Файл не найден или у вас нет прав доступа' }); + } + + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: 'Файл не найден на сервере' }); + } + + res.download(file.file_path, file.original_name); + }); + }); +}); + +// ==================== МАРШРУТЫ ЛОГОВ ==================== + +app.get('/api/activity-logs', requireAuth, (req, res) => { + const userId = req.session.user.id; + + let query = ` + SELECT al.*, u.name as user_name, t.title as task_title + FROM activity_logs al + LEFT JOIN users u ON al.user_id = u.id + LEFT JOIN tasks t ON al.task_id = t.id + WHERE 1=1 + `; + + // Для обычных пользователей показываем только логи их задач + if (req.session.user.role !== 'admin') { + query += ` AND (t.created_by = ${userId} OR al.task_id IN ( + SELECT task_id FROM task_assignments WHERE user_id = ${userId} + ))`; + } + + query += " ORDER BY al.created_at DESC LIMIT 100"; + + db.all(query, (err, logs) => { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + res.json(logs); + }); +}); + +// Запуск сервера +app.listen(PORT, () => { + console.log(`CRM сервер запущен на порту ${PORT}`); + console.log(`Откройте http://localhost:${PORT} в браузере`); + console.log('Тестовые пользователи:'); + console.log('- Логин: director, Пароль: director123 (Администратор)'); + console.log('- Логин: zavuch, Пароль: zavuch123'); + console.log('- Логин: teacher, Пароль: teacher123'); + console.log('LDAP авторизация доступна для пользователей школы'); + console.log(`Разрешенные группы: ${process.env.ALLOWED_GROUPS}`); +}); \ No newline at end of file