diff --git a/backend/api/comment.php b/backend/api/comment.php new file mode 100644 index 0000000..c5c15b9 --- /dev/null +++ b/backend/api/comment.php @@ -0,0 +1,81 @@ +id_task = $data['id_task'] ?? null; + $comment->id_accounts = $current_user_id; + $comment->id_answer = $data['id_answer'] ?? null; // Ответ на комментарий + $comment->text = $data['text'] ?? ''; + + $result = $comment->create(); + RestApi::response($result); + } + + // Обновление комментария + if ($action === 'update') { + $comment->id = $data['id'] ?? null; + $comment->id_accounts = $current_user_id; + $comment->text = $data['text'] ?? ''; + + $result = $comment->update(); + RestApi::response($result); + } + + // Удаление комментария + if ($action === 'delete') { + $id = $data['id'] ?? null; + $result = Comment::delete($id, $current_user_id); + RestApi::response($result); + } + + // Загрузка файла к комментарию (только автор) + if ($action === 'upload_image') { + $comment_id = $data['comment_id'] ?? null; + $file_base64 = $data['file_data'] ?? null; + $file_name = $data['file_name'] ?? null; + + $result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id); + RestApi::response($result); + } + + // Удаление файлов комментария (автор или админ проекта) + if ($action === 'delete_image') { + $comment_id = $data['comment_id'] ?? null; + $file_names = $data['file_names'] ?? $data['file_name'] ?? null; + + $result = Comment::deleteFile($comment_id, $file_names, $current_user_id); + RestApi::response($result); + } + + // Метод не указан + if (!$action) { + RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400); + } +} + +if ($method === 'GET') { + // Получение комментариев задачи + // ?id_task=X (обязательный) + $id_task = $_GET['id_task'] ?? null; + + if (!$id_task) { + RestApi::response(['success' => false, 'errors' => ['id_task' => 'Задача не указана']], 400); + } + + $comment = new Comment(); + $comments = $comment->getByTask($id_task); + + RestApi::response(['success' => true, 'data' => $comments]); +} + +?> diff --git a/backend/api/server.php b/backend/api/server.php new file mode 100644 index 0000000..4d85208 --- /dev/null +++ b/backend/api/server.php @@ -0,0 +1,25 @@ + true, + 'data' => [ + 'timezone' => $timezone, // Europe/Moscow + 'timezone_offset' => $offset, // +03:00 + 'server_time' => date('c') // 2026-01-15T18:30:00+03:00 + ] + ]); + } +} + +?> diff --git a/backend/api/task.php b/backend/api/task.php index eedde7b..ecdad80 100644 --- a/backend/api/task.php +++ b/backend/api/task.php @@ -13,7 +13,7 @@ if ($method === 'POST') { $file_base64 = $data['file_data'] ?? null; $file_name = $data['file_name'] ?? null; - $result = TaskImage::upload($task_id, $file_base64, $file_name); + $result = Task::uploadFile($task_id, $file_base64, $file_name); RestApi::response($result); } @@ -22,7 +22,7 @@ if ($method === 'POST') { $task_id = $data['task_id'] ?? null; $file_names = $data['file_names'] ?? $data['file_name'] ?? null; - $result = TaskImage::delete($task_id, $file_names); + $result = Task::deleteFile($task_id, $file_names); RestApi::response($result); } diff --git a/backend/app/class/enity/class_comment.php b/backend/app/class/enity/class_comment.php new file mode 100644 index 0000000..cdd6ce7 --- /dev/null +++ b/backend/app/class/enity/class_comment.php @@ -0,0 +1,246 @@ +id_task) { + $this->addError('id_task', 'Задача не указана'); + } + if (!$this->id_accounts) { + $this->addError('id_accounts', 'Пользователь не указан'); + } + if (!$this->text || trim($this->text) === '') { + $this->addError('text', 'Текст комментария не может быть пустым'); + } + if ($errors = $this->getErrors()) { + return $errors; + } + + // Проверяем что задача существует + Task::check_task($this->id_task); + + // Если это ответ — проверяем что родительский комментарий существует + if ($this->id_answer) { + self::checkComment($this->id_answer); + } + + // Вставляем в базу + Database::insert($this->db_name, [ + 'id_task' => $this->id_task, + 'id_accounts' => $this->id_accounts, + 'id_answer' => $this->id_answer ?: null, + 'text' => $this->text, + 'date_create' => date('Y-m-d H:i:s'), + 'file_img' => '[]' + ]); + + $this->id = Database::id(); + + // Возвращаем созданный комментарий с данными пользователя + return [ + 'success' => true, + 'comment' => $this->getById($this->id) + ]; + } + + // Обновление комментария + public function update() { + static::$error_message = []; + + // Валидация + if (!$this->id) { + $this->addError('id', 'ID комментария не указан'); + } + if (!$this->text || trim($this->text) === '') { + $this->addError('text', 'Текст комментария не может быть пустым'); + } + if ($errors = $this->getErrors()) { + return $errors; + } + + // Проверяем что комментарий существует + $comment = self::checkComment($this->id); + + // Проверяем что пользователь — автор комментария + if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) { + $this->addError('access', 'Вы можете редактировать только свои комментарии'); + return $this->getErrors(); + } + + // Обновляем в БД + Database::update($this->db_name, [ + 'text' => $this->text + ], [ + 'id' => $this->id + ]); + + return [ + 'success' => true, + 'comment' => $this->getById($this->id) + ]; + } + + // Удаление комментария (с дочерними) + public static function delete($id, $id_accounts) { + // Проверяем что комментарий существует + $comment = self::checkComment($id); + + // Получаем задачу для проверки админа проекта + $task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]); + + // Проверяем права: автор комментария ИЛИ админ проекта + $isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts; + $isProjectAdmin = $task && Project::isAdmin($task['id_project'], $id_accounts); + + if (!$isAuthor && !$isProjectAdmin) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Нет прав на удаление комментария'] + ], 403); + } + + // Рекурсивно удаляем все дочерние комментарии + self::deleteWithChildren($id); + + return ['success' => true]; + } + + // Рекурсивное удаление комментария и всех его дочерних + private static function deleteWithChildren($id) { + // Находим все дочерние комментарии + $children = Database::select('comments', ['id'], ['id_answer' => $id]); + + // Рекурсивно удаляем дочерние + if ($children) { + foreach ($children as $child) { + self::deleteWithChildren($child['id']); + } + } + + // Удаляем папку с файлами комментария + FileUpload::deleteFolder('comment', $id); + + // Удаляем сам комментарий + Database::delete('comments', ['id' => $id]); + } + + // === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ === + + // Загрузка файла к комментарию (только автор может загружать) + public static function uploadFile($comment_id, $file_base64, $file_name, $user_id) { + // Проверка что комментарий существует + $comment = self::checkComment($comment_id); + + // Проверка что пользователь — автор комментария + if ((int)$comment['id_accounts'] !== (int)$user_id) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Вы можете загружать файлы только к своим комментариям'] + ], 403); + } + + return FileUpload::upload('comment', $comment_id, $file_base64, $file_name); + } + + // Удаление файлов комментария (автор или админ проекта) + public static function deleteFile($comment_id, $file_names, $user_id) { + // Проверка что комментарий существует + $comment = self::checkComment($comment_id); + + // Получаем задачу для проверки админа проекта + $task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]); + + // Проверка прав: автор комментария ИЛИ админ проекта + $isAuthor = (int)$comment['id_accounts'] === (int)$user_id; + $isProjectAdmin = $task && Project::isAdmin($task['id_project'], $user_id); + + if (!$isAuthor && !$isProjectAdmin) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Нет прав на удаление файлов'] + ], 403); + } + + return FileUpload::delete('comment', $comment_id, $file_names); + } + + // Получение комментария по ID (с данными пользователя) + public function getById($id) { + $comment = Database::get($this->db_name, [ + '[>]accounts' => ['id_accounts' => 'id'] + ], [ + 'comments.id', + 'comments.id_task', + 'comments.id_accounts', + 'comments.id_answer', + 'comments.text', + 'comments.date_create', + 'comments.file_img', + 'accounts.name(author_name)', + 'accounts.avatar_url(author_avatar)' + ], [ + 'comments.id' => $id + ]); + + // Декодируем JSON файлов + if ($comment) { + $comment['file_img'] = $comment['file_img'] ? json_decode($comment['file_img'], true) : []; + } + + return $comment; + } + + // Получение всех комментариев задачи + public function getByTask($id_task) { + // Проверяем что задача существует + Task::check_task($id_task); + + $comments = Database::select($this->db_name, [ + '[>]accounts' => ['id_accounts' => 'id'] + ], [ + 'comments.id', + 'comments.id_task', + 'comments.id_accounts', + 'comments.id_answer', + 'comments.text', + 'comments.date_create', + 'comments.file_img', + 'accounts.name(author_name)', + 'accounts.avatar_url(author_avatar)' + ], [ + 'comments.id_task' => $id_task, + 'ORDER' => ['comments.date_create' => 'ASC'] + ]); + + // Декодируем JSON файлов для каждого комментария + return array_map(function($comment) { + $comment['file_img'] = $comment['file_img'] ? json_decode($comment['file_img'], true) : []; + return $comment; + }, $comments ?: []); + } + + // Проверка и получение комментария (при ошибке — сразу ответ и exit) + public static function checkComment($id) { + $comment = Database::get('comments', '*', ['id' => $id]); + if (!$id || !$comment) { + RestApi::response(['success' => false, 'errors' => ['comment' => 'Комментарий не найден']], 404); + } + return $comment; + } +} + +?> diff --git a/backend/app/class/enity/class_fileUpload.php b/backend/app/class/enity/class_fileUpload.php new file mode 100644 index 0000000..10d88e3 --- /dev/null +++ b/backend/app/class/enity/class_fileUpload.php @@ -0,0 +1,223 @@ + [ + 'table' => 'cards_task', + 'folder' => 'task', + 'field' => 'file_img', + 'allowed_ext' => ['png', 'jpg', 'jpeg', 'zip', 'rar'], + 'archive_ext' => ['zip', 'rar'], + 'max_size' => 10 * 1024 * 1024 // 10 MB + ], + 'comment' => [ + 'table' => 'comments', + 'folder' => 'comment', + 'field' => 'file_img', + 'allowed_ext' => ['png', 'jpg', 'jpeg', 'zip', 'rar'], + 'archive_ext' => ['zip', 'rar'], + 'max_size' => 10 * 1024 * 1024 // 10 MB + ] + ]; + + // Получение конфигурации сущности + protected static function getConfig($entity_type) { + if (!isset(self::$entities[$entity_type])) { + return null; + } + return self::$entities[$entity_type]; + } + + // Получение данных сущности из БД + protected static function getEntityData($config, $entity_id) { + return Database::get($config['table'], '*', ['id' => $entity_id]); + } + + // Обновление файлов сущности в БД + protected static function updateEntityFiles($config, $entity_id, $files) { + Database::update($config['table'], [ + $config['field'] => json_encode($files, JSON_UNESCAPED_UNICODE) + ], [ + 'id' => $entity_id + ]); + } + + // Генерация уникального имени файла + protected static function getUniqueName($upload_dir, $file_name) { + $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); + $base_name = pathinfo($file_name, PATHINFO_FILENAME); + $final_name = $file_name; + $counter = 1; + + while (file_exists($upload_dir . '/' . $final_name)) { + $final_name = $base_name . '_' . $counter . '.' . $ext; + $counter++; + } + + return $final_name; + } + + // Форматирование размера файла для ошибки + protected static function formatSize($bytes) { + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1) . ' МБ'; + } + return round($bytes / 1024, 1) . ' КБ'; + } + + // === ЗАГРУЗКА ФАЙЛА === + public static function upload($entity_type, $entity_id, $file_base64, $file_name) { + // Получаем конфигурацию + $config = self::getConfig($entity_type); + if (!$config) { + return ['success' => false, 'errors' => ['entity' => 'Неизвестный тип сущности']]; + } + + // Проверяем что сущность существует + $entity = self::getEntityData($config, $entity_id); + if (!$entity) { + return ['success' => false, 'errors' => ['entity' => 'Сущность не найдена']]; + } + + // Декодируем base64 + $file_data = base64_decode(preg_replace('/^data:[^;]+;base64,/', '', $file_base64)); + if (!$file_data) { + return ['success' => false, 'errors' => ['file' => 'Ошибка декодирования файла']]; + } + + // Проверка расширения + $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); + if (!in_array($ext, $config['allowed_ext'])) { + $allowed = strtoupper(implode(', ', $config['allowed_ext'])); + return ['success' => false, 'errors' => ['file' => "Разрешены только: $allowed"]]; + } + + // Проверка размера + $file_size = strlen($file_data); + if ($file_size > $config['max_size']) { + $max_formatted = self::formatSize($config['max_size']); + return ['success' => false, 'errors' => ['file' => "Файл слишком большой. Максимум $max_formatted"]]; + } + + // Путь к папке + $upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id; + if (!is_dir($upload_dir)) { + mkdir($upload_dir, 0755, true); + } + + // Уникальное имя + $final_name = self::getUniqueName($upload_dir, $file_name); + + // Сохранение файла + $file_path = $upload_dir . '/' . $final_name; + if (!file_put_contents($file_path, $file_data)) { + return ['success' => false, 'errors' => ['file' => 'Ошибка сохранения файла']]; + } + + // Формируем URL + $file_url = self::$base_url . $config['folder'] . '/' . $entity_id . '/' . $final_name; + + // Обновляем file_img в базе + $current_files = $entity[$config['field']] ? json_decode($entity[$config['field']], true) : []; + $current_files[] = [ + 'url' => $file_url, + 'name' => $final_name, + 'size' => $file_size + ]; + + self::updateEntityFiles($config, $entity_id, $current_files); + + return [ + 'success' => true, + 'file' => [ + 'url' => $file_url, + 'name' => $final_name, + 'size' => $file_size + ] + ]; + } + + // === УДАЛЕНИЕ ФАЙЛОВ === + public static function delete($entity_type, $entity_id, $file_names) { + // Получаем конфигурацию + $config = self::getConfig($entity_type); + if (!$config) { + return ['success' => false, 'errors' => ['entity' => 'Неизвестный тип сущности']]; + } + + // Проверяем что сущность существует + $entity = self::getEntityData($config, $entity_id); + if (!$entity) { + return ['success' => false, 'errors' => ['entity' => 'Сущность не найдена']]; + } + + // Приводим к массиву если передана строка + if (!is_array($file_names)) { + $file_names = [$file_names]; + } + + // Получаем текущие файлы + $current_files = $entity[$config['field']] ? json_decode($entity[$config['field']], true) : []; + $upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id; + $deleted = []; + + // Удаляем каждый файл + foreach ($file_names as $file_name) { + foreach ($current_files as $index => $file) { + if ($file['name'] === $file_name) { + // Удаляем файл с диска + $file_path = $upload_dir . '/' . $file_name; + if (file_exists($file_path)) { + unlink($file_path); + } + // Удаляем из массива + array_splice($current_files, $index, 1); + $deleted[] = $file_name; + break; + } + } + } + + // Обновляем в базе + self::updateEntityFiles($config, $entity_id, $current_files); + + // Удаляем папку если она пустая + if (is_dir($upload_dir) && count(scandir($upload_dir)) === 2) { + rmdir($upload_dir); + } + + return ['success' => true, 'deleted' => $deleted]; + } + + // === УДАЛЕНИЕ ПАПКИ СУЩНОСТИ (при удалении самой сущности) === + public static function deleteFolder($entity_type, $entity_id) { + // Получаем конфигурацию + $config = self::getConfig($entity_type); + if (!$config) { + return false; + } + + $upload_dir = self::$base_path . $config['folder'] . '/' . $entity_id; + + if (is_dir($upload_dir)) { + // Удаляем все файлы в папке + $files = glob($upload_dir . '/*'); + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + // Удаляем папку + rmdir($upload_dir); + } + + return true; + } +} + +?> diff --git a/backend/app/class/enity/class_project.php b/backend/app/class/enity/class_project.php index 04a0989..c89c2d8 100644 --- a/backend/app/class/enity/class_project.php +++ b/backend/app/class/enity/class_project.php @@ -6,24 +6,56 @@ class Project extends BaseEntity { // Получение всех проектов public function getAll() { - return Database::select($this->db_name, [ + $current_user_id = RestApi::getCurrentUserId(); + + $projects = Database::select($this->db_name, [ 'id', 'id_order', 'name', - 'id_ready' + 'id_ready', + 'id_admin' ], [ 'ORDER' => ['id_order' => 'ASC'] ]); + + // Обрабатываем id_admin для каждого проекта + return array_map(function($project) use ($current_user_id) { + $admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : []; + + if ($current_user_id && in_array((int)$current_user_id, $admins, true)) { + $project['id_admin'] = true; + } else { + unset($project['id_admin']); + } + + return $project; + }, $projects); } // Получение одного проекта - public static function get($id) { - return Database::get('project', [ + // $current_user_id — ID текущего пользователя для проверки админства + public static function get($id, $current_user_id = null) { + $project = Database::get('project', [ 'id', 'id_order', 'name', - 'id_ready' + 'id_ready', + 'id_admin' ], ['id' => $id]); + + if ($project) { + $admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : []; + + // Если передан user_id — проверяем админство + if ($current_user_id && in_array((int)$current_user_id, $admins, true)) { + $project['id_admin'] = true; + } else { + // Не админ — убираем поле + unset($project['id_admin']); + } + } + + return $project; } // Получение id_ready (ID колонки "Готово") по ID проекта @@ -58,7 +90,10 @@ class Project extends BaseEntity { // Получение всех данных проекта (проект + колонки + отделы + метки) public static function getProjectData($project_id) { - $project = self::get($project_id); + // Получаем ID текущего пользователя для проверки админства + $current_user_id = RestApi::getCurrentUserId(); + + $project = self::get($project_id, $current_user_id); if (!$project) { return null; } @@ -78,6 +113,17 @@ class Project extends BaseEntity { 'labels' => $labels ]; } + + // Проверка является ли пользователь админом проекта + public static function isAdmin($project_id, $user_id): bool { + $project = Database::get('project', ['id_admin'], ['id' => $project_id]); + if (!$project || !$project['id_admin']) { + return false; + } + + $admins = json_decode($project['id_admin'], true) ?: []; + return in_array((int)$user_id, $admins, true); + } } ?> diff --git a/backend/app/class/enity/class_task.php b/backend/app/class/enity/class_task.php index af95ab6..6d35688 100644 --- a/backend/app/class/enity/class_task.php +++ b/backend/app/class/enity/class_task.php @@ -84,7 +84,7 @@ class Task extends BaseEntity { $uploaded_files = []; if (!empty($files)) { foreach ($files as $file) { - $result = TaskImage::upload($this->id, $file['data'], $file['name']); + $result = FileUpload::upload('task', $this->id, $file['data'], $file['name']); if ($result['success']) { $uploaded_files[] = $result['file']; } @@ -154,24 +154,40 @@ class Task extends BaseEntity { // Проверка что задача существует self::check_task($id); - // Удаляем папку с файлами если есть - $upload_dir = __DIR__ . '/../../../public/task/' . $id; - if (is_dir($upload_dir)) { - $files = glob($upload_dir . '/*'); - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } + // Удаляем папку с файлами задачи + FileUpload::deleteFolder('task', $id); + + // Удаляем все комментарии задачи и их файлы + $comments = Database::select('comments', ['id'], ['id_task' => $id]); + if ($comments) { + foreach ($comments as $comment) { + FileUpload::deleteFolder('comment', $comment['id']); } - rmdir($upload_dir); + Database::delete('comments', ['id_task' => $id]); } - // Удаляем из базы + // Удаляем задачу из базы Database::delete('cards_task', ['id' => $id]); return ['success' => true]; } + // === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ === + + // Загрузка файла к задаче + public static function uploadFile($task_id, $file_base64, $file_name) { + // Проверка что задача существует + self::check_task($task_id); + return FileUpload::upload('task', $task_id, $file_base64, $file_name); + } + + // Удаление файлов задачи + public static function deleteFile($task_id, $file_names) { + // Проверка что задача существует + self::check_task($task_id); + return FileUpload::delete('task', $task_id, $file_names); + } + // Изменение порядка и колонки задачи (с пересчётом order) public static function updateOrder($id, $column_id, $to_index) { @@ -251,8 +267,20 @@ class Task extends BaseEntity { 'archive' ], $where); + // Получаем количество комментариев для всех задач одним запросом + $task_ids = array_column($tasks, 'id'); + $comments_counts = []; + if (!empty($task_ids)) { + $counts = Database::query( + "SELECT id_task, COUNT(*) as cnt FROM comments WHERE id_task IN (" . implode(',', $task_ids) . ") GROUP BY id_task" + )->fetchAll(\PDO::FETCH_ASSOC); + foreach ($counts as $row) { + $comments_counts[$row['id_task']] = (int)$row['cnt']; + } + } + // Декодируем JSON и получаем avatar_url из accounts - return array_map(function($task) { + return array_map(function($task) use ($comments_counts) { $task['file_img'] = $task['file_img'] ? json_decode($task['file_img'], true) : []; // Получаем avatar_url из accounts по id_account @@ -263,6 +291,9 @@ class Task extends BaseEntity { $task['avatar_img'] = null; } + // Количество комментариев + $task['comments_count'] = $comments_counts[$task['id']] ?? 0; + return $task; }, $tasks); } diff --git a/backend/app/class/enity/class_taskImage.php b/backend/app/class/enity/class_taskImage.php deleted file mode 100644 index d634b8d..0000000 --- a/backend/app/class/enity/class_taskImage.php +++ /dev/null @@ -1,156 +0,0 @@ - false, 'errors' => ['file' => 'Ошибка декодирования файла']]; - } - - // Проверка расширения - $allowed_ext = ['png', 'jpg', 'jpeg', 'zip', 'rar']; - $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); - if (!in_array($ext, $allowed_ext)) { - return ['success' => false, 'errors' => ['file' => 'Разрешены только PNG, JPG, JPEG, ZIP, RAR']]; - } - - // Проверка размера (10 МБ) - $max_size = 10 * 1024 * 1024; - if (strlen($file_data) > $max_size) { - return ['success' => false, 'errors' => ['file' => 'Файл слишком большой. Максимум 10 МБ']]; - } - - // Всё ок — возвращаем данные - return [ - 'task' => $task, - 'file_data' => $file_data, - 'is_archive' => in_array($ext, ['zip', 'rar']) - ]; - } - - // Генерация уникального имени файла - protected static function getUniqueName($upload_dir, $file_name) { - $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); - $base_name = pathinfo($file_name, PATHINFO_FILENAME); - $final_name = $file_name; - $counter = 1; - - while (file_exists($upload_dir . '/' . $final_name)) { - $final_name = $base_name . '_' . $counter . '.' . $ext; - $counter++; - } - - return $final_name; - } - - // Загрузка изображения - public static function upload($task_id, $file_base64, $file_name) { - // Валидация - $validation = self::validate($task_id, $file_base64, $file_name); - if (isset($validation['success'])) return $validation; - - $task = $validation['task']; - $file_data = $validation['file_data']; - - // Путь к папке - $upload_dir = __DIR__ . '/../../../public/task/' . $task_id; - if (!is_dir($upload_dir)) { - mkdir($upload_dir, 0755, true); - } - - // Уникальное имя - $final_name = self::getUniqueName($upload_dir, $file_name); - - // Сохранение файла - $file_path = $upload_dir . '/' . $final_name; - if (!file_put_contents($file_path, $file_data)) { - return ['success' => false, 'errors' => ['file' => 'Ошибка сохранения файла']]; - } - - // Формируем URL - $file_url = self::$upload_path . $task_id . '/' . $final_name; - $file_size = strlen($file_data); - - // Обновляем file_img в базе - $current_files = $task['file_img'] ? json_decode($task['file_img'], true) : []; - $current_files[] = [ - 'url' => $file_url, - 'name' => $final_name, - 'size' => $file_size - ]; - - Database::update(self::$db_name, [ - 'file_img' => json_encode($current_files, JSON_UNESCAPED_UNICODE) - ], [ - 'id' => $task_id - ]); - - return [ - 'success' => true, - 'file' => [ - 'url' => $file_url, - 'name' => $final_name, - 'size' => $file_size - ] - ]; - } - - // Удаление изображений (принимает строку или массив имён файлов) - public static function delete($task_id, $file_names) { - // Проверка и получение задачи - $task = Task::check_task($task_id); - - // Приводим к массиву если передана строка - if (!is_array($file_names)) { - $file_names = [$file_names]; - } - - // Получаем текущие файлы - $current_files = $task['file_img'] ? json_decode($task['file_img'], true) : []; - $upload_dir = __DIR__ . '/../../../public/task/' . $task_id; - $deleted = []; - - // Удаляем каждый файл - foreach ($file_names as $file_name) { - // Ищем файл в массиве - foreach ($current_files as $index => $file) { - if ($file['name'] === $file_name) { - // Удаляем файл с диска - $file_path = $upload_dir . '/' . $file_name; - if (file_exists($file_path)) { - unlink($file_path); - } - // Удаляем из массива - array_splice($current_files, $index, 1); - $deleted[] = $file_name; - break; - } - } - } - - // Обновляем в базе - Database::update(self::$db_name, [ - 'file_img' => json_encode($current_files, JSON_UNESCAPED_UNICODE) - ], [ - 'id' => $task_id - ]); - - // Удаляем папку если она пустая - if (is_dir($upload_dir) && count(scandir($upload_dir)) === 2) { - rmdir($upload_dir); - } - - return ['success' => true, 'deleted' => $deleted]; - } -} - -?> diff --git a/backend/app/config.php b/backend/app/config.php index 4b601c3..8415775 100644 --- a/backend/app/config.php +++ b/backend/app/config.php @@ -1,5 +1,8 @@ __DIR__ . '/../api/user.php', '/api/task' => __DIR__ . '/../api/task.php', '/api/project' => __DIR__ . '/../api/project.php', + '/api/comment' => __DIR__ . '/../api/comment.php', + '/api/server' => __DIR__ . '/../api/server.php', ]; - $publicActions = ['auth_login', 'check_session']; + $publicActions = ['auth_login', 'check_session', 'get_settings']; ?> \ No newline at end of file diff --git a/backend/app/restAPI/class_restApi.php b/backend/app/restAPI/class_restApi.php index 2c175ec..139360c 100644 --- a/backend/app/restAPI/class_restApi.php +++ b/backend/app/restAPI/class_restApi.php @@ -18,6 +18,21 @@ class RestApi { exit; } + // Получить ID текущего авторизованного пользователя + public static function getCurrentUserId(): ?int { + $session = $_COOKIE['session'] ?? null; + if (!$session) { + return null; + } + + $sessionData = Database::get('accounts_session', ['id_accounts'], [ + 'keycookies' => $session, + 'data_closed[>]' => date('Y-m-d H:i:s') + ]); + + return $sessionData ? (int)$sessionData['id_accounts'] : null; + } + } ?> diff --git a/front_vue/src/api.js b/front_vue/src/api.js index 239efba..51ff06e 100644 --- a/front_vue/src/api.js +++ b/front_vue/src/api.js @@ -113,7 +113,94 @@ export const taskImageApi = { }) } +// ==================== COMMENT IMAGES ==================== +export const commentImageApi = { + upload: (comment_id, file_data, file_name) => request('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'upload_image', comment_id, file_data, file_name }) + }), + // Принимает строку (один файл) или массив (несколько файлов) + delete: (comment_id, file_names) => request('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete_image', comment_id, file_names }) + }) +} + // ==================== USERS ==================== export const usersApi = { getAll: () => request('/api/user', { credentials: 'include' }) } + +// ==================== SERVER ==================== +export const serverApi = { + // Получение настроек сервера (timezone и т.д.) — публичный action + getSettings: () => request('/api/server', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'get_settings' }) + }) +} + +// Хранилище серверных настроек +export const serverSettings = { + timezoneOffset: '+03:00', // дефолт, обновится при загрузке + + // Инициализация — вызвать один раз при старте приложения + async init() { + try { + const result = await serverApi.getSettings() + if (result.success) { + this.timezoneOffset = result.data.timezone_offset + } + } catch (e) { + console.warn('Не удалось получить настройки сервера:', e) + } + }, + + // Парсинг даты с сервера с учётом таймзоны + parseDate(dateStr) { + if (!dateStr) return null + // Добавляем таймзону сервера для корректного парсинга + const normalized = dateStr.replace(' ', 'T') + // Если уже есть таймзона — не добавляем + if (normalized.includes('+') || normalized.includes('Z')) { + return new Date(normalized) + } + return new Date(normalized + this.timezoneOffset) + } +} + +// ==================== COMMENTS ==================== +export const commentsApi = { + // Получение комментариев задачи + getByTask: (id_task) => request(`/api/comment?id_task=${id_task}`, { credentials: 'include' }), + + // Создание комментария (id_answer — опционально, для ответа на комментарий) + create: (id_task, text, id_answer = null) => request('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'create', id_task, text, id_answer }) + }), + + // Обновление комментария + update: (id, text) => request('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'update', id, text }) + }), + + // Удаление комментария + delete: (id) => request('/api/comment', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete', id }) + }) +} \ No newline at end of file diff --git a/front_vue/src/components/Board.vue b/front_vue/src/components/Board.vue index 556ad69..61e96b7 100644 --- a/front_vue/src/components/Board.vue +++ b/front_vue/src/components/Board.vue @@ -87,6 +87,7 @@ const columnsWithCards = computed(() => { dueDate: card.date, dateCreate: card.date_create, dateClosed: card.date_closed, + comments_count: card.comments_count || 0, files: card.files || (card.file_img || []).map(f => ({ name: f.name, url: f.url, diff --git a/front_vue/src/components/TaskPanel.vue b/front_vue/src/components/TaskPanel.vue deleted file mode 100644 index ae44439..0000000 --- a/front_vue/src/components/TaskPanel.vue +++ /dev/null @@ -1,723 +0,0 @@ - - - - - diff --git a/front_vue/src/components/TaskPanel/CommentForm.vue b/front_vue/src/components/TaskPanel/CommentForm.vue new file mode 100644 index 0000000..a2120f9 --- /dev/null +++ b/front_vue/src/components/TaskPanel/CommentForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel/CommentItem.vue b/front_vue/src/components/TaskPanel/CommentItem.vue new file mode 100644 index 0000000..64ac030 --- /dev/null +++ b/front_vue/src/components/TaskPanel/CommentItem.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel/TaskCommentsTab.vue b/front_vue/src/components/TaskPanel/TaskCommentsTab.vue new file mode 100644 index 0000000..8ed93a0 --- /dev/null +++ b/front_vue/src/components/TaskPanel/TaskCommentsTab.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel/TaskEditTab.vue b/front_vue/src/components/TaskPanel/TaskEditTab.vue new file mode 100644 index 0000000..a31bda8 --- /dev/null +++ b/front_vue/src/components/TaskPanel/TaskEditTab.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel/TaskPanel.vue b/front_vue/src/components/TaskPanel/TaskPanel.vue new file mode 100644 index 0000000..1ed49a7 --- /dev/null +++ b/front_vue/src/components/TaskPanel/TaskPanel.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel/index.js b/front_vue/src/components/TaskPanel/index.js new file mode 100644 index 0000000..4a2f406 --- /dev/null +++ b/front_vue/src/components/TaskPanel/index.js @@ -0,0 +1,8 @@ +export { default as TaskPanel } from './TaskPanel.vue' +export { default as TaskEditTab } from './TaskEditTab.vue' +export { default as TaskCommentsTab } from './TaskCommentsTab.vue' +export { default as CommentItem } from './CommentItem.vue' +export { default as CommentForm } from './CommentForm.vue' + +// Default export +export { default } from './TaskPanel.vue' diff --git a/front_vue/src/components/ui/IconButton.vue b/front_vue/src/components/ui/IconButton.vue new file mode 100644 index 0000000..0f2f508 --- /dev/null +++ b/front_vue/src/components/ui/IconButton.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/front_vue/src/components/ui/TabsPanel.vue b/front_vue/src/components/ui/TabsPanel.vue new file mode 100644 index 0000000..18203dd --- /dev/null +++ b/front_vue/src/components/ui/TabsPanel.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/front_vue/src/main.js b/front_vue/src/main.js index 7b5cd15..0b20ebb 100644 --- a/front_vue/src/main.js +++ b/front_vue/src/main.js @@ -2,6 +2,10 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' +import { serverSettings } from './api' + +// Инициализация серверных настроек (timezone и т.д.) +serverSettings.init() const app = createApp(App) app.use(createPinia()) diff --git a/front_vue/src/stores/projects.js b/front_vue/src/stores/projects.js index 97a2406..a7dc086 100644 --- a/front_vue/src/stores/projects.js +++ b/front_vue/src/stores/projects.js @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { projectsApi, usersApi } from '../api' +import { projectsApi, usersApi, authApi } from '../api' export const useProjectsStore = defineStore('projects', () => { // ==================== СОСТОЯНИЕ ==================== @@ -11,6 +11,7 @@ export const useProjectsStore = defineStore('projects', () => { const users = ref([]) const loading = ref(false) const initialized = ref(false) + const currentUser = ref(null) // Текущий авторизованный пользователь // Текущий проект (из localStorage) const savedProjectId = localStorage.getItem('currentProjectId') @@ -30,6 +31,16 @@ export const useProjectsStore = defineStore('projects', () => { return project ? Number(project.id_ready) : null }) + // ID текущего пользователя + const currentUserId = computed(() => currentUser.value?.id || null) + + // Является ли текущий пользователь админом проекта + // Сервер возвращает id_admin: true только если текущий пользователь — админ + const isProjectAdmin = computed(() => { + const project = projects.value.find(p => p.id === currentProjectId.value) + return project?.id_admin === true + }) + // ==================== ДЕЙСТВИЯ ==================== // Инициализация (загрузка проектов + данных активного) const init = async () => { @@ -74,6 +85,14 @@ export const useProjectsStore = defineStore('projects', () => { const usersData = await usersApi.getAll() if (usersData.success) users.value = usersData.data + // Загружаем текущего пользователя + const authData = await authApi.check() + if (authData.success && authData.user) { + // Находим полные данные пользователя (с id) из списка users + const fullUser = users.value.find(u => u.username === authData.user.username) + currentUser.value = fullUser || authData.user + } + initialized.value = true } catch (error) { console.error('Ошибка инициализации:', error) @@ -108,6 +127,12 @@ export const useProjectsStore = defineStore('projects', () => { columns.value = projectData.data.columns departments.value = projectData.data.departments labels.value = projectData.data.labels + + // Обновляем id_admin в списке проектов (сервер возвращает true если текущий пользователь админ) + const project = projects.value.find(p => p.id === currentProjectId.value) + if (project && projectData.data.project?.id_admin === true) { + project.id_admin = true + } } } catch (error) { console.error('Ошибка загрузки данных проекта:', error) @@ -122,6 +147,7 @@ export const useProjectsStore = defineStore('projects', () => { columns.value = [] users.value = [] currentProjectId.value = null + currentUser.value = null initialized.value = false localStorage.removeItem('currentProjectId') localStorage.removeItem('currentProjectName') @@ -137,9 +163,12 @@ export const useProjectsStore = defineStore('projects', () => { loading, initialized, currentProjectId, + currentUser, // Геттеры currentProject, doneColumnId, + currentUserId, + isProjectAdmin, // Действия init, selectProject, diff --git a/front_vue/src/views/ArchivePage.vue b/front_vue/src/views/ArchivePage.vue index d256ddd..d9c21dd 100644 --- a/front_vue/src/views/ArchivePage.vue +++ b/front_vue/src/views/ArchivePage.vue @@ -82,6 +82,8 @@ :departments="store.departments" :labels="store.labels" :users="store.users" + :current-user-id="store.currentUserId" + :is-project-admin="store.isProjectAdmin" @close="closePanel" @save="handleSaveTask" @delete="handleDeleteTask" @@ -105,7 +107,7 @@ import { ref, computed, watch, onMounted } from 'vue' import Sidebar from '../components/Sidebar.vue' import Header from '../components/Header.vue' import ArchiveCard from '../components/ArchiveCard.vue' -import TaskPanel from '../components/TaskPanel.vue' +import TaskPanel from '../components/TaskPanel' import ConfirmDialog from '../components/ConfirmDialog.vue' import ProjectSelector from '../components/ProjectSelector.vue' import { useProjectsStore } from '../stores/projects' @@ -156,6 +158,7 @@ const fetchCards = async () => { dateClosed: card.date_closed, columnId: card.column_id, order: card.order ?? 0, + comments_count: card.comments_count || 0, files: card.files || (card.file_img || []).map(f => ({ name: f.name, url: f.url, diff --git a/front_vue/src/views/MainApp.vue b/front_vue/src/views/MainApp.vue index ea421d2..180e4a2 100644 --- a/front_vue/src/views/MainApp.vue +++ b/front_vue/src/views/MainApp.vue @@ -79,6 +79,8 @@ :departments="store.departments" :labels="store.labels" :users="store.users" + :current-user-id="store.currentUserId" + :is-project-admin="store.isProjectAdmin" @close="closePanel" @save="handleSaveTask" @delete="handleDeleteTask" @@ -92,7 +94,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue' import Sidebar from '../components/Sidebar.vue' import Header from '../components/Header.vue' import Board from '../components/Board.vue' -import TaskPanel from '../components/TaskPanel.vue' +import TaskPanel from '../components/TaskPanel' import ProjectSelector from '../components/ProjectSelector.vue' import { useProjectsStore } from '../stores/projects' import { cardsApi } from '../api'