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
+
+
+
Тестовый пользователь:
+
+
+
+
+
+
+
+ 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' ? `
+
+ ` : ''}
+
+
+
+
+ ${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 = '';
+ let totalSize = 0;
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ totalSize += file.size;
+ html += `- ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)
`;
+ }
+
+ 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 => `
+
+ `).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