diff --git a/app/Database/Migrations/2026-02-08-120006_CreateTaskAttachmentsTable.php b/app/Database/Migrations/2026-02-08-120006_CreateTaskAttachmentsTable.php new file mode 100644 index 0000000..71b560d --- /dev/null +++ b/app/Database/Migrations/2026-02-08-120006_CreateTaskAttachmentsTable.php @@ -0,0 +1,70 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'file_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'file_path' => [ + 'type' => 'VARCHAR', + 'constraint' => 500, + 'null' => false, + ], + 'file_size' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'file_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'default' => '', + ], + 'uploaded_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('task_id'); + $this->forge->addForeignKey('task_id', 'tasks', 'id', '', 'CASCADE'); + + $this->forge->createTable('task_attachments'); + } + + public function down() + { + $this->forge->dropTable('task_attachments'); + } +} \ No newline at end of file diff --git a/app/Modules/Tasks/Config/Routes.php b/app/Modules/Tasks/Config/Routes.php index 55d31c4..b8262c0 100644 --- a/app/Modules/Tasks/Config/Routes.php +++ b/app/Modules/Tasks/Config/Routes.php @@ -32,6 +32,11 @@ $routes->group('tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' $routes->post('(:num)/subtasks', 'TasksController::addSubtask/$1'); $routes->post('(:num)/subtasks/(:num)/toggle', 'TasksController::toggleSubtask/$1/$2'); $routes->post('(:num)/subtasks/(:num)/delete', 'TasksController::deleteSubtask/$1/$2'); + + // Attachments API + $routes->post('(:num)/attachments/upload', 'TasksController::uploadAttachment/$1'); + $routes->post('(:num)/attachments/(:num)/delete', 'TasksController::deleteAttachment/$1/$2'); + $routes->get('(:num)/attachments/(:num)/download', 'TasksController::downloadAttachment/$1/$2'); }); // API Routes для Tasks diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php index 39af6bc..288f5bd 100644 --- a/app/Modules/Tasks/Controllers/TasksController.php +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -614,4 +614,110 @@ class TasksController extends BaseController 'success' => $result, ]); } + + // ========== Вложения (Attachments) ========== + + /** + * API: загрузить вложение + */ + public function uploadAttachment(int $taskId) + { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + // Проверяем что задача принадлежит организации + $task = $this->taskService->getTask($taskId, $organizationId); + if (!$task) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Задача не найдена' + ]); + } + + $file = $this->request->getFile('file'); + if (!$file || !$file->isValid()) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Файл не загружен' + ]); + } + + $attachmentId = $this->taskService->uploadAttachment($taskId, [ + 'name' => $file->getName(), + 'type' => $file->getClientMimeType(), + 'tmp_name' => $file->getTempName(), + 'size' => $file->getSize(), + ], $userId); + + return $this->response->setJSON([ + 'success' => (bool) $attachmentId, + 'attachment_id' => $attachmentId, + ]); + } + + /** + * API: удалить вложение + */ + public function deleteAttachment(int $taskId, int $attachmentId) + { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + // Проверяем что задача принадлежит организации + $task = $this->taskService->getTask($taskId, $organizationId); + if (!$task) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Задача не найдена' + ]); + } + + $result = $this->taskService->deleteAttachment($taskId, $attachmentId, $userId); + + return $this->response->setJSON([ + 'success' => $result, + ]); + } + + /** + * API: скачать вложение + */ + public function downloadAttachment(int $taskId, int $attachmentId) + { + $organizationId = $this->requireActiveOrg(); + + // Проверяем что задача принадлежит организации + $task = $this->taskService->getTask($taskId, $organizationId); + if (!$task) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Задача не найдена'); + } + + $attachmentModel = new \App\Modules\Tasks\Models\TaskAttachmentModel(); + $attachment = $attachmentModel->find($attachmentId); + + if (!$attachment || $attachment['task_id'] != $taskId) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Вложение не найдено'); + } + + if (!file_exists($attachment['file_path'])) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Файл не найден'); + } + + return $this->response->download($attachment['file_path'], null) + ->setFileName($attachment['file_name']); + } } diff --git a/app/Modules/Tasks/Models/TaskAttachmentModel.php b/app/Modules/Tasks/Models/TaskAttachmentModel.php new file mode 100644 index 0000000..e7edf90 --- /dev/null +++ b/app/Modules/Tasks/Models/TaskAttachmentModel.php @@ -0,0 +1,108 @@ +where('task_id', $taskId) + ->orderBy('created_at', 'DESC') + ->findAll(); + } + + /** + * Получить вложение по ID + */ + public function getById(int $attachmentId): ?array + { + return $this->find($attachmentId); + } + + /** + * Удалить вложение + */ + public function deleteAttachment(int $attachmentId, int $userId): bool + { + $attachment = $this->find($attachmentId); + if (!$attachment) { + return false; + } + + // Удаляем файл с диска + $filePath = $attachment['file_path']; + if (file_exists($filePath)) { + unlink($filePath); + } + + return $this->delete($attachmentId); + } + + /** + * Получить размер файла в человекочитаемом формате + */ + public function formatSize(int $bytes): string + { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + + return $bytes . ' B'; + } + + /** + * Получить иконку для типа файла + */ + public function getFileIcon(string $fileType): string + { + $icons = [ + 'image' => 'fa-file-image', + 'pdf' => 'fa-file-pdf', + 'word' => 'fa-file-word', + 'excel' => 'fa-file-excel', + 'powerpoint' => 'fa-file-powerpoint', + 'zip' => 'fa-file-zipper', + 'text' => 'fa-file-lines', + 'video' => 'fa-file-video', + 'audio' => 'fa-file-audio', + ]; + + foreach ($icons as $key => $icon) { + if (stripos($fileType, $key) !== false) { + return $icon; + } + } + + return 'fa-file'; + } +} \ No newline at end of file diff --git a/app/Modules/Tasks/Services/TaskService.php b/app/Modules/Tasks/Services/TaskService.php index 6699429..48b140a 100644 --- a/app/Modules/Tasks/Services/TaskService.php +++ b/app/Modules/Tasks/Services/TaskService.php @@ -6,6 +6,7 @@ use App\Modules\Tasks\Models\TaskModel; use App\Modules\Tasks\Models\TaskAssigneeModel; use App\Modules\Tasks\Models\TaskColumnModel; use App\Modules\Tasks\Models\TaskSubtaskModel; +use App\Modules\Tasks\Models\TaskAttachmentModel; use CodeIgniter\Events\Events; class TaskService @@ -14,6 +15,7 @@ class TaskService protected TaskAssigneeModel $assigneeModel; protected TaskColumnModel $columnModel; protected TaskSubtaskModel $subtaskModel; + protected TaskAttachmentModel $attachmentModel; public function __construct() { @@ -21,6 +23,7 @@ class TaskService $this->assigneeModel = new TaskAssigneeModel(); $this->columnModel = new TaskColumnModel(); $this->subtaskModel = new TaskSubtaskModel(); + $this->attachmentModel = new TaskAttachmentModel(); } /** @@ -180,6 +183,8 @@ class TaskService $task['subtasks'] = $this->subtaskModel->getByTask($taskId); $task['subtasks_count'] = count($task['subtasks']); $task['subtasks_completed'] = count(array_filter($task['subtasks'], fn($s) => $s['is_completed'])); + $task['attachments'] = $this->attachmentModel->getByTask($taskId); + $task['attachments_count'] = count($task['attachments']); return $task; } @@ -349,4 +354,64 @@ class TaskService return $result; } + + // ========== Вложения (Attachments) ========== + + /** + * Загрузить вложение + */ + public function uploadAttachment(int $taskId, array $file, int $userId): ?int + { + $uploadPath = WRITEPATH . 'uploads/tasks/' . $taskId . '/'; + + // Создаём директорию если не существует + if (!is_dir($uploadPath)) { + mkdir($uploadPath, 0755, true); + } + + // Генерируем уникальное имя файла + $originalName = $file['name']; + $extension = pathinfo($originalName, PATHINFO_EXTENSION); + $newFileName = uniqid() . '_' . time() . '.' . $extension; + $filePath = $uploadPath . $newFileName; + + // Перемещаем загруженный файл + if (!move_uploaded_file($file['tmp_name'], $filePath)) { + return null; + } + + $attachmentId = $this->attachmentModel->insert([ + 'task_id' => $taskId, + 'file_name' => $originalName, + 'file_path' => $filePath, + 'file_size' => $file['size'], + 'file_type' => $file['type'] ?? '', + 'uploaded_by' => $userId, + ]); + + if ($attachmentId) { + Events::trigger('task.attachment_uploaded', $taskId, $attachmentId, $userId); + } + + return $attachmentId; + } + + /** + * Удалить вложение + */ + public function deleteAttachment(int $taskId, int $attachmentId, int $userId): bool + { + $attachment = $this->attachmentModel->find($attachmentId); + if (!$attachment || $attachment['task_id'] != $taskId) { + return false; + } + + $result = $this->attachmentModel->deleteAttachment($attachmentId, $userId); + + if ($result) { + Events::trigger('task.attachment_deleted', $taskId, $attachmentId, $userId); + } + + return $result; + } } diff --git a/app/Modules/Tasks/Views/tasks/show.twig b/app/Modules/Tasks/Views/tasks/show.twig index d2a16aa..fbd9440 100644 --- a/app/Modules/Tasks/Views/tasks/show.twig +++ b/app/Modules/Tasks/Views/tasks/show.twig @@ -140,6 +140,53 @@ + {# Вложения #} +
Нет вложений
+ {% endif %} + + +