From bf5e35c61fe0452271bf84909b5b89e47cbf53a9 Mon Sep 17 00:00:00 2001 From: kalugin66 <150135283+kalugin1988@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:43:41 +0500 Subject: [PATCH] Add files via upload --- database.js | 79 +++++++++++-- public/index.html | 73 ++++++++++-- public/script.js | 246 ++++++++++++++++++++++++++++++++++++----- public/style.css | 132 ++++++++++++++++++++++ server.js | 275 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 743 insertions(+), 62 deletions(-) diff --git a/database.js b/database.js index 84ad9ad..17d22e3 100644 --- a/database.js +++ b/database.js @@ -66,9 +66,13 @@ function initializeDatabase() { original_task_id INTEGER, start_date DATETIME, due_date DATETIME, + rework_comment TEXT, + closed_at DATETIME, + closed_by INTEGER, FOREIGN KEY (created_by) REFERENCES users (id), FOREIGN KEY (deleted_by) REFERENCES users (id), - FOREIGN KEY (original_task_id) REFERENCES tasks (id) + FOREIGN KEY (original_task_id) REFERENCES tasks (id), + FOREIGN KEY (closed_by) REFERENCES users (id) )`); // Таблица назначений задач @@ -79,6 +83,7 @@ function initializeDatabase() { status TEXT DEFAULT 'assigned', start_date DATETIME, due_date DATETIME, + rework_comment TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (task_id) REFERENCES tasks (id), @@ -112,6 +117,43 @@ function initializeDatabase() { )`); console.log('База данных инициализирована в папке data'); + + // Добавляем недостающие колонки если они не существуют + setTimeout(addMissingColumns, 1000); +} + +// Функция для добавления недостающих колонок +function addMissingColumns() { + const columnsToAdd = [ + { table: 'tasks', column: 'rework_comment', type: 'TEXT' }, + { table: 'tasks', column: 'closed_at', type: 'DATETIME' }, + { table: 'tasks', column: 'closed_by', type: 'INTEGER' }, + { table: 'task_assignments', column: 'rework_comment', type: 'TEXT' } + ]; + + columnsToAdd.forEach(({ table, column, type }) => { + // Используем db.all вместо db.get для получения всех строк + db.all(`PRAGMA table_info(${table})`, (err, rows) => { + if (err) { + console.error(`Ошибка при проверке таблицы ${table}:`, err); + return; + } + + // rows теперь массив, можно использовать some + const columnExists = rows.some(row => row.name === column); + if (!columnExists) { + db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`, (err) => { + if (err) { + console.error(`Ошибка при добавлении колонки ${column} в таблицу ${table}:`, err); + } else { + console.log(`✅ Добавлена колонка ${column} в таблицу ${table}`); + } + }); + } else { + console.log(`✅ Колонка ${column} уже существует в таблице ${table}`); + } + }); + }); } function createTaskFolder(taskId) { @@ -181,17 +223,31 @@ function checkTaskAccess(userId, taskId, callback) { 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("SELECT status, created_by, closed_at FROM tasks WHERE id = ?", [taskId], (err, task) => { + if (err || !task) { + callback(err, false); + return; + } - db.get(query, [taskId, userId, userId], (err, row) => { - callback(err, !!row); + // Если задача закрыта, доступ есть только у создателя и администраторов + if (task.closed_at && task.created_by !== userId && user.role !== 'admin') { + callback(null, false); + return; + } + + // Обычные пользователи видят только задачи где они заказчик или исполнитель + const query = ` + SELECT 1 FROM tasks t + WHERE t.id = ? AND ( + t.created_by = ? + OR EXISTS (SELECT 1 FROM task_assignments WHERE task_id = t.id AND user_id = ?) + ) + `; + + db.get(query, [taskId, userId, userId], (err, row) => { + callback(err, !!row); + }); }); }); } @@ -200,6 +256,7 @@ function checkTaskAccess(userId, taskId, callback) { function checkOverdueTasks() { const now = new Date().toISOString(); + // Временно убираем проверку на closed_at до добавления колонки const query = ` SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date FROM task_assignments ta diff --git a/public/index.html b/public/index.html index 9215f64..eee3e3d 100644 --- a/public/index.html +++ b/public/index.html @@ -47,16 +47,36 @@
-
-

Все задачи

-
- -
-
-
+
+

Все задачи

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -84,6 +104,9 @@
+
@@ -110,7 +133,7 @@ + + + \ No newline at end of file diff --git a/public/script.js b/public/script.js index 0d8191f..b8abb55 100644 --- a/public/script.js +++ b/public/script.js @@ -1,6 +1,7 @@ let currentUser = null; let users = []; let tasks = []; +let filteredUsers = []; // Инициализация при загрузке document.addEventListener('DOMContentLoaded', function() { @@ -43,11 +44,17 @@ function showMainInterface() { 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'; + // Показываем фильтры ВСЕМ пользователям, а не только администраторам + document.getElementById('tasks-controls').style.display = 'block'; + + // Чекбокс удаленных задач показываем только администраторам + const showDeletedLabel = document.querySelector('.show-deleted-label'); + if (showDeletedLabel) { + if (currentUser.role === 'admin') { + showDeletedLabel.style.display = 'flex'; + } else { + showDeletedLabel.style.display = 'none'; + } } loadUsers(); @@ -62,7 +69,9 @@ function setupEventListeners() { document.getElementById('edit-task-form').addEventListener('submit', updateTask); document.getElementById('copy-task-form').addEventListener('submit', copyTask); document.getElementById('edit-assignment-form').addEventListener('submit', updateAssignment); + document.getElementById('rework-task-form').addEventListener('submit', sendForRework); document.getElementById('files').addEventListener('change', updateFileList); + document.getElementById('edit-files').addEventListener('change', updateEditFileList); } async function login(event) { @@ -122,6 +131,7 @@ async function loadUsers() { try { const response = await fetch('/api/users'); users = await response.json(); + filteredUsers = [...users]; renderUsersChecklist(); renderEditUsersChecklist(); renderCopyUsersChecklist(); @@ -130,9 +140,49 @@ async function loadUsers() { } } +// Фильтрация пользователей +function filterUsers() { + const search = document.getElementById('user-search').value.toLowerCase(); + filteredUsers = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderUsersChecklist(); +} + +function filterEditUsers() { + const search = document.getElementById('edit-user-search').value.toLowerCase(); + const filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderEditUsersChecklist(filtered); +} + +function filterCopyUsers() { + const search = document.getElementById('copy-user-search').value.toLowerCase(); + const filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search) + ); + renderCopyUsersChecklist(filtered); +} + async function loadTasks() { try { - const response = await fetch('/api/tasks'); + const search = document.getElementById('search-tasks')?.value || ''; + const statusFilter = document.getElementById('status-filter')?.value || 'active,in_progress,assigned,overdue,rework'; + const showDeleted = document.getElementById('show-deleted')?.checked || false; + + let url = '/api/tasks?'; + if (search) url += `search=${encodeURIComponent(search)}&`; + if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; + if (showDeleted) url += `showDeleted=true&`; + + const response = await fetch(url); tasks = await response.json(); renderTasks(); @@ -157,7 +207,7 @@ async function loadActivityLogs() { function renderUsersChecklist() { const container = document.getElementById('users-checklist'); - container.innerHTML = users + container.innerHTML = filteredUsers .filter(user => user.id !== currentUser.id) .map(user => `
@@ -170,9 +220,9 @@ function renderUsersChecklist() { `).join(''); } -function renderEditUsersChecklist() { +function renderEditUsersChecklist(filtered = users) { const container = document.getElementById('edit-users-checklist'); - container.innerHTML = users + container.innerHTML = filtered .filter(user => user.id !== currentUser.id) .map(user => `
@@ -185,9 +235,9 @@ function renderEditUsersChecklist() { `).join(''); } -function renderCopyUsersChecklist() { +function renderCopyUsersChecklist(filtered = users) { const container = document.getElementById('copy-users-checklist'); - container.innerHTML = users + container.innerHTML = filtered .filter(user => user.id !== currentUser.id) .map(user => `
@@ -218,17 +268,23 @@ function renderTasks() { const overallStatus = getTaskOverallStatus(task); const statusClass = getStatusClass(overallStatus); const isDeleted = task.status === 'deleted'; + const isClosed = task.closed_at !== null; const userRole = getUserRoleInTask(task); const canEdit = canUserEditTask(task); const isCopy = task.original_task_id !== null; return ` -
+
- ${!isDeleted ? ` - + ${!isDeleted && !isClosed ? ` + ${canEdit ? `` : ''} - + ${canEdit ? `` : ''} + ${canEdit ? `` : ''} + ${canEdit ? `` : ''} + ` : ''} + ${isClosed && canEdit ? ` + ` : ''} ${isDeleted && currentUser.role === 'admin' ? ` @@ -239,6 +295,7 @@ function renderTasks() {
${task.title} ${isDeleted ? 'Удалена' : ''} + ${isClosed ? 'Закрыта' : ''} ${isCopy ? 'Копия' : ''} ${userRole}
@@ -253,6 +310,12 @@ function renderTasks() {
${task.description || 'Нет описания'}
+ ${task.rework_comment ? ` +
+ Комментарий к доработке: ${task.rework_comment} +
+ ` : ''} + ${task.start_date || task.due_date ? `
${task.start_date ? `
Начать: ${formatDateTime(task.start_date)}
` : ''} @@ -276,6 +339,7 @@ function renderTasks() {
Создана: ${formatDateTime(task.created_at)} | Автор: ${task.creator_name} ${task.deleted_at ? `
Удалена: ${formatDateTime(task.deleted_at)}` : ''} + ${task.closed_at ? `
Закрыта: ${formatDateTime(task.closed_at)}` : ''}
`; @@ -286,9 +350,10 @@ function renderAssignment(assignment, taskId, canEdit) { const statusClass = getStatusClass(assignment.status); const isCurrentUser = assignment.user_id === currentUser.id; const isOverdue = assignment.status === 'overdue'; + const isRework = assignment.status === 'rework'; return ` -
+
${assignment.user_name} @@ -299,11 +364,16 @@ function renderAssignment(assignment, taskId, canEdit) { ${assignment.due_date ? `Выполнить до: ${formatDateTime(assignment.due_date)}` : ''}
` : ''} + ${assignment.rework_comment ? ` +
+ Комментарий: ${assignment.rework_comment} +
+ ` : ''}
${isCurrentUser && assignment.status === 'assigned' ? `` : ''} - ${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue') ? + ${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue' || assignment.status === 'rework') ? `` : ''} ${canEdit ? `` : ''} @@ -349,6 +419,8 @@ async function createTask(event) { alert('Задача успешно создана!'); document.getElementById('create-task-form').reset(); document.getElementById('file-list').innerHTML = ''; + document.getElementById('user-search').value = ''; + filterUsers(); loadTasks(); loadActivityLogs(); showSection('tasks'); @@ -405,6 +477,9 @@ async function openEditModal(taskId) { function closeEditModal() { document.getElementById('edit-task-modal').style.display = 'none'; + document.getElementById('edit-file-list').innerHTML = ''; + document.getElementById('edit-user-search').value = ''; + filterEditUsers(); } async function updateTask(event) { @@ -419,19 +494,22 @@ async function updateTask(event) { const assignedUsers = document.querySelectorAll('#edit-users-checklist input[name="assignedUsers"]:checked'); const assignedUserIds = Array.from(assignedUsers).map(cb => parseInt(cb.value)); + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + formData.append('assignedUsers', JSON.stringify(assignedUserIds)); + if (startDate) formData.append('startDate', startDate); + if (dueDate) formData.append('dueDate', dueDate); + + const files = document.getElementById('edit-files').files; + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + try { const response = await fetch(`/api/tasks/${taskId}`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - title, - description, - assignedUsers: assignedUserIds, - startDate: startDate || null, - dueDate: dueDate || null - }) + body: formData }); if (response.ok) { @@ -456,9 +534,10 @@ function openCopyModal(taskId) { function closeCopyModal() { document.getElementById('copy-task-modal').style.display = 'none'; + document.getElementById('copy-user-search').value = ''; + filterCopyUsers(); } -// В функции copyTask улучшаем обработку ошибок async function copyTask(event) { event.preventDefault(); @@ -555,6 +634,90 @@ async function updateAssignment(event) { } } +function openReworkModal(taskId) { + document.getElementById('rework-task-id').value = taskId; + document.getElementById('rework-task-modal').style.display = 'block'; +} + +function closeReworkModal() { + document.getElementById('rework-task-modal').style.display = 'none'; + document.getElementById('rework-comment').value = ''; +} + +async function sendForRework(event) { + event.preventDefault(); + + const taskId = document.getElementById('rework-task-id').value; + const comment = document.getElementById('rework-comment').value; + + try { + const response = await fetch(`/api/tasks/${taskId}/rework`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ comment }) + }); + + if (response.ok) { + alert('Задача возвращена на доработку!'); + closeReworkModal(); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка возврата задачи на доработку'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка возврата задачи на доработку'); + } +} + +async function closeTask(taskId) { + if (!confirm('Вы уверены, что хотите закрыть эту задачу? Исполнители больше не будут видеть её.')) { + return; + } + + try { + const response = await fetch(`/api/tasks/${taskId}/close`, { + method: 'POST' + }); + + if (response.ok) { + alert('Задача закрыта!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка закрытия задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка закрытия задачи'); + } +} + +async function reopenTask(taskId) { + try { + const response = await fetch(`/api/tasks/${taskId}/reopen`, { + method: 'POST' + }); + + if (response.ok) { + alert('Задача открыта!'); + loadTasks(); + loadActivityLogs(); + } else { + const error = await response.json(); + alert(error.error || 'Ошибка открытия задачи'); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка открытия задачи'); + } +} + async function deleteTask(taskId) { if (!confirm('Вы уверены, что хотите удалить эту задачу?')) { return; @@ -599,7 +762,6 @@ async function restoreTask(taskId) { } } -// В функции updateStatus улучшаем обработку ошибок async function updateStatus(taskId, userId, status) { try { const response = await fetch(`/api/tasks/${taskId}/status`, { @@ -625,12 +787,14 @@ async function updateStatus(taskId, userId, status) { function getTaskOverallStatus(task) { if (task.status === 'deleted') return 'deleted'; + if (task.closed_at) return 'closed'; // Закрытые задачи всегда имеют статус 'closed' if (!task.assignments || task.assignments.length === 0) return 'unassigned'; const assignments = task.assignments; let hasAssigned = false; let hasInProgress = false; let hasOverdue = false; + let hasRework = false; let allCompleted = true; for (let assignment of assignments) { @@ -643,12 +807,16 @@ function getTaskOverallStatus(task) { } else if (assignment.status === 'overdue') { hasOverdue = true; allCompleted = false; + } else if (assignment.status === 'rework') { + hasRework = true; + allCompleted = false; } else if (assignment.status !== 'completed') { allCompleted = false; } } if (allCompleted) return 'completed'; + if (hasRework) return 'rework'; if (hasOverdue) return 'overdue'; if (hasInProgress) return 'in_progress'; if (hasAssigned) return 'assigned'; @@ -658,9 +826,11 @@ function getTaskOverallStatus(task) { function getStatusClass(status) { switch (status) { case 'deleted': return 'status-gray'; + case 'closed': return 'status-gray'; case 'unassigned': return 'status-purple'; case 'assigned': return 'status-red'; case 'in_progress': return 'status-orange'; + case 'rework': return 'status-yellow'; case 'overdue': return 'status-darkred'; case 'completed': return 'status-green'; default: return 'status-purple'; @@ -670,9 +840,11 @@ function getStatusClass(status) { function getStatusText(status) { switch (status) { case 'deleted': return 'Удалена'; + case 'closed': return 'Закрыта'; case 'unassigned': return 'Не назначена'; case 'assigned': return 'Назначена'; case 'in_progress': return 'В работе'; + case 'rework': return 'На доработке'; case 'overdue': return 'Просрочена'; case 'completed': return 'Выполнена'; default: return 'Неизвестно'; @@ -729,6 +901,16 @@ function formatDateTimeForInput(dateTimeString) { function updateFileList() { const fileInput = document.getElementById('files'); const fileList = document.getElementById('file-list'); + updateFileListForInput(fileInput, fileList); +} + +function updateEditFileList() { + const fileInput = document.getElementById('edit-files'); + const fileList = document.getElementById('edit-file-list'); + updateFileListForInput(fileInput, fileList); +} + +function updateFileListForInput(fileInput, fileList) { const files = fileInput.files; if (files.length === 0) { @@ -780,7 +962,11 @@ function getActionText(action) { 'TASK_ASSIGNMENTS_UPDATED': 'обновил назначения', 'ASSIGNMENT_UPDATED': 'обновил сроки исполнителя', 'STATUS_CHANGED': 'изменил статус задачи', - 'FILE_UPLOADED': 'загрузил файл' + 'FILE_UPLOADED': 'загрузил файл', + 'FILE_COPIED': 'скопировал файл', + 'TASK_SENT_FOR_REWORK': 'вернул задачу на доработку', + 'TASK_CLOSED': 'закрыл задачу', + 'TASK_REOPENED': 'открыл задачу' }; return actions[action] || action; diff --git a/public/style.css b/public/style.css index f849db2..c030e5a 100644 --- a/public/style.css +++ b/public/style.css @@ -890,4 +890,136 @@ select:focus { box-shadow: none; border: 1px solid #ddd; } +} +/* Добавляем в существующие стили */ + +/* Фильтры */ +.filters { + display: flex; + gap: 20px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.filter-group label { + font-weight: 600; + color: #2c3e50; + margin-bottom: 0; +} + +.filter-group input, +.filter-group select { + padding: 8px 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 0.9rem; +} + +.user-search { + margin-bottom: 10px; +} + +.user-search input { + width: 100%; + padding: 8px 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 0.9rem; +} + +/* Новые статусы */ +.status-yellow { + background: linear-gradient(135deg, #ffc107, #e0a800); + color: white; +} + +/* Закрытые задачи */ +.task-card.closed { + background: linear-gradient(135deg, #e9ecef, #dee2e6); + border-left-color: #6c757d; + opacity: 0.8; +} + +.closed-badge { + background: #6c757d; + color: white; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + margin-left: 10px; + font-weight: 600; +} + +/* Комментарии к доработке */ +.rework-comment { + margin: 10px 0; + padding: 12px 15px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 8px; + border-left: 4px solid #ffc107; + color: #856404; +} + +.assignment-rework-comment { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border-radius: 6px; + border-left: 3px solid #ffc107; +} + +.assignment.rework { + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border: 1px solid #ffc107; +} + +/* Новые кнопки */ +button.rework-btn { + background: linear-gradient(135deg, #ffc107, #e0a800); + box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3); +} + +button.rework-btn:hover { + box-shadow: 0 6px 20px rgba(255, 193, 7, 0.4); +} + +button.close-btn { + background: linear-gradient(135deg, #6c757d, #5a6268); + box-shadow: 0 4px 15px rgba(108, 117, 125, 0.3); +} + +button.close-btn:hover { + box-shadow: 0 6px 20px rgba(108, 117, 125, 0.4); +} + +button.reopen-btn { + background: linear-gradient(135deg, #20c997, #1ea085); + box-shadow: 0 4px 15px rgba(32, 201, 151, 0.3); +} + +button.reopen-btn:hover { + box-shadow: 0 6px 20px rgba(32, 201, 151, 0.4); +} + +.show-deleted-label { + display: flex; + align-items: center; + gap: 8px; + font-weight: normal; + color: #2c3e50; + cursor: pointer; +} + +.show-deleted-label input { + margin: 0; +} +/* В существующие стили добавляем */ +.show-deleted-label[style*="display: none"] { + display: none !important; } \ No newline at end of file diff --git a/server.js b/server.js index 49568e7..0222df5 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,7 @@ const session = require('express-session'); require('dotenv').config(); const { db, logActivity, createUserTaskFolder, saveTaskMetadata, updateTaskMetadata, checkTaskAccess } = require('./database'); -const authService = require('./auth'); // Добавьте эту строку +const authService = require('./auth'); const app = express(); const PORT = process.env.PORT || 3000; @@ -80,7 +80,37 @@ function checkIfOverdue(dueDate, status) { const due = new Date(dueDate); return due < now; } +// Функция для проверки просроченных задач +function checkOverdueTasks() { + const now = new Date().toISOString(); + + // Проверяем только активные незакрытые задачи + const query = ` + SELECT ta.id, ta.task_id, ta.user_id, ta.status, ta.due_date + FROM task_assignments ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.due_date IS NOT NULL + AND ta.due_date < ? + AND ta.status NOT IN ('completed', 'overdue') + AND t.status = 'active' + AND t.closed_at IS NULL + `; + db.all(query, [now], (err, assignments) => { + if (err) { + console.error('Ошибка при проверке просроченных задач:', err); + return; + } + + assignments.forEach(assignment => { + db.run( + "UPDATE task_assignments SET status = 'overdue' WHERE id = ?", + [assignment.id] + ); + logActivity(assignment.task_id, assignment.user_id, 'STATUS_CHANGED', 'Задача просрочена'); + }); + }); +} // ==================== МАРШРУТЫ АУТЕНТИФИКАЦИИ ==================== app.post('/api/login', async (req, res) => { @@ -139,7 +169,25 @@ app.get('/api/user', (req, res) => { // ==================== МАРШРУТЫ ПОЛЬЗОВАТЕЛЕЙ ==================== 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) => { + const search = req.query.search || ''; + + let query = ` + SELECT id, login, name, email, role, auth_type + FROM users + WHERE role IN ('admin', 'teacher') + `; + + const params = []; + + if (search) { + query += ` AND (login LIKE ? OR name LIKE ? OR email LIKE ?)`; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern); + } + + query += " ORDER BY name"; + + db.all(query, params, (err, rows) => { if (err) { res.status(500).json({ error: err.message }); return; @@ -150,10 +198,12 @@ app.get('/api/users', requireAuth, (req, res) => { // ==================== МАРШРУТЫ ЗАДАЧ ==================== -// Получить задачи с учетом прав доступа +// Получить задачи с учетом прав доступа и фильтров app.get('/api/tasks', requireAuth, (req, res) => { const userId = req.session.user.id; - const showDeleted = req.session.user.role === 'admin'; + const showDeleted = req.session.user.role === 'admin' && req.query.showDeleted === 'true'; + const search = req.query.search || ''; + const statusFilter = req.query.status || 'active,in_progress,assigned,overdue,rework'; // По умолчанию все кроме выполненных и закрытых let query = ` SELECT DISTINCT @@ -173,18 +223,64 @@ app.get('/api/tasks', requireAuth, (req, res) => { WHERE 1=1 `; + const params = []; + // Для обычных пользователей показываем только задачи где они заказчик или исполнитель if (req.session.user.role !== 'admin') { - query += ` AND (t.created_by = ${userId} OR ta.user_id = ${userId})`; + query += ` AND (t.created_by = ? OR ta.user_id = ?)`; + params.push(userId, userId); } if (!showDeleted) { query += " AND t.status = 'active'"; } + // Фильтр по статусу + if (statusFilter && statusFilter !== 'all') { + const statuses = statusFilter.split(','); + + // Если в фильтре есть 'closed', показываем закрытые задачи + if (statuses.includes('closed')) { + // Для исполнителей показываем только свои закрытые задачи + if (req.session.user.role !== 'admin') { + query += ` AND (t.closed_at IS NOT NULL AND t.created_by = ?)`; + params.push(userId); + } else { + // Для администраторов показываем все закрытые задачи + query += ` AND t.closed_at IS NOT NULL`; + } + } else { + // Если 'closed' нет в фильтре, скрываем закрытые задачи для всех + query += ` AND t.closed_at IS NULL`; + + // Добавляем фильтрацию по статусам назначений + if (statuses.length > 0 && !statuses.includes('all')) { + query += ` AND EXISTS ( + SELECT 1 FROM task_assignments ta2 + WHERE ta2.task_id = t.id AND ta2.status IN (${statuses.map(() => '?').join(',')}) + )`; + statuses.forEach(status => params.push(status)); + } + } + } else { + // Если фильтр 'all', для исполнителей все равно скрываем чужие закрытые задачи + if (req.session.user.role !== 'admin') { + query += ` AND (t.closed_at IS NULL OR t.created_by = ?)`; + params.push(userId); + } + // Для администраторов при фильтре 'all' показываем все включая закрытые + } + + // Поиск по тексту + if (search) { + query += ` AND (t.title LIKE ? OR t.description LIKE ?)`; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern); + } + query += " GROUP BY t.id ORDER BY t.created_at DESC"; - db.all(query, (err, tasks) => { + db.all(query, params, (err, tasks) => { if (err) { res.status(500).json({ error: err.message }); return; @@ -301,7 +397,7 @@ app.post('/api/tasks', requireAuth, upload.array('files', 15), (req, res) => { }); }); -// Копировать задачу +// Копировать задачу с файлами app.post('/api/tasks/:taskId/copy', requireAuth, (req, res) => { const { taskId } = req.params; const { assignedUsers, startDate, dueDate } = req.body; @@ -338,6 +434,30 @@ app.post('/api/tasks/:taskId/copy', requireAuth, (req, res) => { saveTaskMetadata(newTaskId, newTitle, originalTask.description, createdBy, taskId, startDate, dueDate); logActivity(newTaskId, createdBy, 'TASK_COPIED', `Создана копия задачи: ${newTitle}`); + + // Копируем файлы из оригинальной задачи + db.all("SELECT * FROM task_files WHERE task_id = ?", [taskId], (err, originalFiles) => { + if (!err && originalFiles && originalFiles.length > 0) { + originalFiles.forEach(originalFile => { + const originalFilePath = originalFile.file_path; + const newFilename = Date.now() + '-' + Math.round(Math.random() * 1E9) + path.extname(originalFile.original_name); + const userFolder = createUserTaskFolder(newTaskId, req.session.user.login); + const newFilePath = path.join(userFolder, newFilename); + + // Копируем файл + if (fs.existsSync(originalFilePath)) { + fs.copyFileSync(originalFilePath, newFilePath); + + db.run( + "INSERT INTO task_files (task_id, user_id, filename, original_name, file_path, file_size) VALUES (?, ?, ?, ?, ?, ?)", + [newTaskId, createdBy, newFilename, originalFile.original_name, newFilePath, originalFile.file_size] + ); + + logActivity(newTaskId, createdBy, 'FILE_COPIED', `Скопирован файл: ${originalFile.original_name}`); + } + }); + } + }); // Назначаем пользователей if (assignedUsers && assignedUsers.length > 0) { @@ -387,12 +507,13 @@ app.get('/api/tasks/:taskId', requireAuth, (req, res) => { LEFT JOIN users ou ON ot.created_by = ou.id WHERE t.id = ? `; + const params = [taskId]; if (!showDeleted) { query += " AND t.status = 'active'"; } - db.get(query, [taskId], (err, task) => { + db.get(query, params, (err, task) => { if (err || !task) { return res.status(404).json({ error: 'Задача не найдена' }); } @@ -424,8 +545,8 @@ app.get('/api/tasks/:taskId', requireAuth, (req, res) => { }); }); -// Обновить задачу с проверкой прав -app.put('/api/tasks/:taskId', requireAuth, (req, res) => { +// Обновить задачу с проверкой прав и возможностью добавления файлов +app.put('/api/tasks/:taskId', requireAuth, upload.array('files', 15), (req, res) => { const { taskId } = req.params; const { title, description, assignedUsers, startDate, dueDate } = req.body; const userId = req.session.user.id; @@ -460,6 +581,28 @@ app.put('/api/tasks/:taskId', requireAuth, (req, res) => { logActivity(taskId, userId, 'TASK_UPDATED', `Задача обновлена: ${title}`); + // Обрабатываем новые файлы + 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, userId, path.basename(file.filename), file.originalname, newPath, file.size] + ); + + logActivity(taskId, userId, 'FILE_UPLOADED', `Загружен файл: ${file.originalname}`); + }); + + // Очищаем временную папку + const tempDir = path.join(__dirname, 'data', 'uploads', 'temp'); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } // Обновляем назначения если переданы if (assignedUsers) { // Удаляем старые назначения @@ -488,6 +631,118 @@ app.put('/api/tasks/:taskId', requireAuth, (req, res) => { }); }); +// Вернуть задачу на доработку +app.post('/api/tasks/:taskId/rework', requireAuth, (req, res) => { + const { taskId } = req.params; + const { comment } = req.body; + const userId = 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 !== userId) { + return res.status(403).json({ error: 'У вас нет прав для возврата задачи на доработку' }); + } + + db.serialize(() => { + // Обновляем задачу с комментарием + db.run( + "UPDATE tasks SET rework_comment = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [comment || 'Требуется доработка', taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + // Обновляем статусы всех назначений на 'rework' + db.run( + "UPDATE task_assignments SET status = 'rework', rework_comment = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", + [comment || 'Требуется доработка', taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + logActivity(taskId, userId, 'TASK_SENT_FOR_REWORK', `Задача возвращена на доработку: ${comment}`); + res.json({ success: true, message: 'Задача возвращена на доработку' }); + } + ); + } + ); + }); + }); +}); + +// Закрыть задачу +app.post('/api/tasks/:taskId/close', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = 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 !== userId) { + return res.status(403).json({ error: 'У вас нет прав для закрытия этой задачи' }); + } + + db.run( + "UPDATE tasks SET closed_at = CURRENT_TIMESTAMP, closed_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [userId, taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + logActivity(taskId, userId, 'TASK_CLOSED', `Задача закрыта`); + res.json({ success: true, message: 'Задача закрыта' }); + } + ); + }); +}); + +// Открыть задачу (отменить закрытие) +app.post('/api/tasks/:taskId/reopen', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = 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 !== userId) { + return res.status(403).json({ error: 'У вас нет прав для открытия этой задачи' }); + } + + db.run( + "UPDATE tasks SET closed_at = NULL, closed_by = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [taskId], + function(err) { + if (err) { + res.status(500).json({ error: err.message }); + return; + } + + logActivity(taskId, userId, 'TASK_REOPENED', `Задача открыта`); + res.json({ success: true, message: 'Задача открыта' }); + } + ); + }); +}); + +// Остальные маршруты остаются без изменений... +// (Обновить сроки исполнителя, Удалить задачу, Восстановить задачу, Обновить статус, Файлы, Логи) + // Обновить сроки для конкретного исполнителя app.put('/api/tasks/:taskId/assignment/:userId', requireAuth, (req, res) => { const { taskId, userId } = req.params;