Add files via upload
This commit is contained in:
230
auth.js
Normal file
230
auth.js
Normal file
@@ -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();
|
||||||
234
database.js
Normal file
234
database.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
195
public/index.html
Normal file
195
public/index.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>School CRM - Управление задачами</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Форма логина -->
|
||||||
|
<div id="login-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Вход в School CRM</h2>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login">Логин:</label>
|
||||||
|
<input type="text" id="login" name="login" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль:</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div class="test-users">
|
||||||
|
<h3>Тестовый пользователь:</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>teacher</strong> / teacher123</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>School CRM - Управление задачами</h1>
|
||||||
|
<div class="user-info">
|
||||||
|
<span id="current-user"></span>
|
||||||
|
<button onclick="logout()">Выйти</button>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<button onclick="showSection('tasks')">Задачи</button>
|
||||||
|
<button onclick="showSection('create-task')">Создать задачу</button>
|
||||||
|
<button onclick="showSection('logs')">Лог активности</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Секция списка задач -->
|
||||||
|
<section id="tasks-section" class="section">
|
||||||
|
<h2>Все задачи</h2>
|
||||||
|
<div id="tasks-controls">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="show-deleted" onchange="loadTasks()">
|
||||||
|
Показать удаленные задачи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="tasks-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Секция создания задачи -->
|
||||||
|
<section id="create-task-section" class="section">
|
||||||
|
<h2>Создать новую задачу</h2>
|
||||||
|
<form id="create-task-form" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Название задачи:</label>
|
||||||
|
<input type="text" id="title" name="title" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Описание:</label>
|
||||||
|
<textarea id="description" name="description" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-date">Дата и время начала (необязательно):</label>
|
||||||
|
<input type="datetime-local" id="start-date" name="startDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="due-date">Дата и время выполнения (необязательно):</label>
|
||||||
|
<input type="datetime-local" id="due-date" name="dueDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Исполнители:</label>
|
||||||
|
<div id="users-checklist" class="checkbox-group"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="files">Прикрепить файлы (до 15 файлов, максимум 300MB):</label>
|
||||||
|
<input type="file" id="files" name="files" multiple>
|
||||||
|
<div id="file-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Создать задачу</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Секция логов -->
|
||||||
|
<section id="logs-section" class="section">
|
||||||
|
<h2>Лог активности</h2>
|
||||||
|
<div id="logs-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для редактирования задачи -->
|
||||||
|
<div id="edit-task-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" onclick="closeEditModal()">×</span>
|
||||||
|
<h3>Редактировать задачу</h3>
|
||||||
|
<form id="edit-task-form">
|
||||||
|
<input type="hidden" id="edit-task-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-title">Название задачи:</label>
|
||||||
|
<input type="text" id="edit-title" name="title" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-description">Описание:</label>
|
||||||
|
<textarea id="edit-description" name="description" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-start-date">Дата и время начала:</label>
|
||||||
|
<input type="datetime-local" id="edit-start-date" name="startDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-due-date">Дата и время выполнения:</label>
|
||||||
|
<input type="datetime-local" id="edit-due-date" name="dueDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Исполнители:</label>
|
||||||
|
<div id="edit-users-checklist" class="checkbox-group"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Сохранить изменения</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для копирования задачи -->
|
||||||
|
<div id="copy-task-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" onclick="closeCopyModal()">×</span>
|
||||||
|
<h3>Создать копию задачи</h3>
|
||||||
|
<form id="copy-task-form">
|
||||||
|
<input type="hidden" id="copy-task-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="copy-start-date">Дата и время начала для копии:</label>
|
||||||
|
<input type="datetime-local" id="copy-start-date" name="startDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="copy-due-date">Дата и время выполнения для копии:</label>
|
||||||
|
<input type="datetime-local" id="copy-due-date" name="dueDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Назначить исполнителей для копии:</label>
|
||||||
|
<div id="copy-users-checklist" class="checkbox-group"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Создать копию</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для редактирования сроков исполнителя -->
|
||||||
|
<div id="edit-assignment-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" onclick="closeEditAssignmentModal()">×</span>
|
||||||
|
<h3>Редактировать сроки исполнителя</h3>
|
||||||
|
<form id="edit-assignment-form">
|
||||||
|
<input type="hidden" id="edit-assignment-task-id">
|
||||||
|
<input type="hidden" id="edit-assignment-user-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-assignment-start-date">Дата и время начала:</label>
|
||||||
|
<input type="datetime-local" id="edit-assignment-start-date" name="startDate">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-assignment-due-date">Дата и время выполнения:</label>
|
||||||
|
<input type="datetime-local" id="edit-assignment-due-date" name="dueDate">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Сохранить сроки</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
816
public/script.js
Normal file
816
public/script.js
Normal file
@@ -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 => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}">
|
||||||
|
${user.name} (${user.email})
|
||||||
|
${user.auth_type === 'ldap' ? '<small style="color: #666;"> - LDAP</small>' : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditUsersChecklist() {
|
||||||
|
const container = document.getElementById('edit-users-checklist');
|
||||||
|
container.innerHTML = users
|
||||||
|
.filter(user => user.id !== currentUser.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}">
|
||||||
|
${user.name} (${user.email})
|
||||||
|
${user.auth_type === 'ldap' ? '<small style="color: #666;"> - LDAP</small>' : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCopyUsersChecklist() {
|
||||||
|
const container = document.getElementById('copy-users-checklist');
|
||||||
|
container.innerHTML = users
|
||||||
|
.filter(user => user.id !== currentUser.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}">
|
||||||
|
${user.name} (${user.email})
|
||||||
|
${user.auth_type === 'ldap' ? '<small style="color: #666;"> - LDAP</small>' : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).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 = '<div class="loading">Задачи не найдены</div>';
|
||||||
|
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 `
|
||||||
|
<div class="task-card ${isDeleted ? 'deleted' : ''}">
|
||||||
|
<div class="task-actions">
|
||||||
|
${!isDeleted ? `
|
||||||
|
<button class="edit-btn" onclick="openEditModal(${task.id})" title="Редактировать">✏️</button>
|
||||||
|
<button class="copy-btn" onclick="openCopyModal(${task.id})" title="Создать копию">📋</button>
|
||||||
|
<button class="delete-btn" onclick="deleteTask(${task.id})" title="Удалить">🗑️</button>
|
||||||
|
` : ''}
|
||||||
|
${isDeleted && currentUser.role === 'admin' ? `
|
||||||
|
<button class="restore-btn" onclick="restoreTask(${task.id})" title="Восстановить">↶</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-title">
|
||||||
|
${task.title}
|
||||||
|
${isDeleted ? '<span class="deleted-badge">Удалена</span>' : ''}
|
||||||
|
${isCopy ? '<span class="copy-badge">Копия</span>' : ''}
|
||||||
|
<span class="role-badge ${getRoleBadgeClass(userRole)}">${userRole}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-status ${statusClass}">${getStatusText(overallStatus)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isCopy && task.original_task_title ? `
|
||||||
|
<div class="task-original">
|
||||||
|
<small>Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name})</small>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="task-description">${task.description || 'Нет описания'}</div>
|
||||||
|
|
||||||
|
${task.start_date || task.due_date ? `
|
||||||
|
<div class="task-dates">
|
||||||
|
${task.start_date ? `<div><strong>Начать:</strong> ${formatDateTime(task.start_date)}</div>` : ''}
|
||||||
|
${task.due_date ? `<div><strong>Выполнить до:</strong> ${formatDateTime(task.due_date)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="task-assignments">
|
||||||
|
<strong>Исполнители:</strong>
|
||||||
|
${task.assignments && task.assignments.length > 0 ?
|
||||||
|
task.assignments.map(assignment => renderAssignment(assignment, task.id, canEdit)).join('') :
|
||||||
|
'<div>Не назначены</div>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-list" id="files-${task.id}">
|
||||||
|
<strong>Файлы:</strong>
|
||||||
|
<div class="loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-meta">
|
||||||
|
<small>Создана: ${formatDateTime(task.created_at)} | Автор: ${task.creator_name}</small>
|
||||||
|
${task.deleted_at ? `<br><small>Удалена: ${formatDateTime(task.deleted_at)}</small>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssignment(assignment, taskId, canEdit) {
|
||||||
|
const statusClass = getStatusClass(assignment.status);
|
||||||
|
const isCurrentUser = assignment.user_id === currentUser.id;
|
||||||
|
const isOverdue = assignment.status === 'overdue';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="assignment ${isOverdue ? 'overdue' : ''}">
|
||||||
|
<span class="assignment-status ${statusClass}"></span>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<strong>${assignment.user_name}</strong>
|
||||||
|
${isCurrentUser ? '<small>(Вы)</small>' : ''}
|
||||||
|
${assignment.start_date || assignment.due_date ? `
|
||||||
|
<div class="assignment-dates">
|
||||||
|
${assignment.start_date ? `<small>Начать: ${formatDateTime(assignment.start_date)}</small>` : ''}
|
||||||
|
${assignment.due_date ? `<small>Выполнить до: ${formatDateTime(assignment.due_date)}</small>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
${isCurrentUser && assignment.status === 'assigned' ?
|
||||||
|
`<button onclick="updateStatus(${taskId}, ${assignment.user_id}, 'in_progress')">Приступить</button>` : ''}
|
||||||
|
${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue') ?
|
||||||
|
`<button onclick="updateStatus(${taskId}, ${assignment.user_id}, 'completed')">Выполнено</button>` : ''}
|
||||||
|
${canEdit ?
|
||||||
|
`<button class="edit-date-btn" onclick="openEditAssignmentModal(${taskId}, ${assignment.user_id})" title="Редактировать сроки">📅</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<ul>';
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
totalSize += file.size;
|
||||||
|
html += `<li>${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</ul>';
|
||||||
|
html += `<p><strong>Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB</strong></p>`;
|
||||||
|
|
||||||
|
fileList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogs(logs) {
|
||||||
|
const container = document.getElementById('logs-list');
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading">Логи не найдены</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = logs.map(log => `
|
||||||
|
<div class="log-entry">
|
||||||
|
<div class="log-time">${formatDateTime(log.created_at)}</div>
|
||||||
|
<div><strong>${log.user_name}</strong> - ${getActionText(log.action)}</div>
|
||||||
|
<div>Задача: "${log.task_title}"</div>
|
||||||
|
${log.details ? `<div>Детали: ${log.details}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`).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 = '<strong>Файлы:</strong> Нет файлов';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `
|
||||||
|
<strong>Файлы:</strong>
|
||||||
|
${files.map(file => `
|
||||||
|
<div class="file-item">
|
||||||
|
<a href="/api/files/${file.id}/download" download="${file.original_name}">
|
||||||
|
${file.original_name}
|
||||||
|
</a>
|
||||||
|
(${(file.file_size / 1024 / 1024).toFixed(2)} MB)
|
||||||
|
<small> - загрузил: ${file.user_name}</small>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки файлов:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
893
public/style.css
Normal file
893
public/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
769
server.js
Normal file
769
server.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user