From cee6c636ad8523ee0958c0eed1837e42986035e5 Mon Sep 17 00:00:00 2001 From: Vladimir Tomashevskiy Date: Sun, 8 Feb 2026 14:55:45 +0000 Subject: [PATCH] Tasks Module Stage 2: Subtasks - Create task_subtasks table migration - Create TaskSubtaskModel with CRUD operations - Add subtask API methods: addSubtask, toggleSubtask, deleteSubtask - Update TaskService to include subtasks in getTask() - Add Routes for subtasks API - Update show.twig with subtasks UI and JavaScript --- ...6-02-08-100005_CreateTaskSubtasksTable.php | 54 +++++++++ app/Modules/Tasks/Config/Routes.php | 5 + .../Tasks/Controllers/TasksController.php | 104 ++++++++++++++++++ app/Modules/Tasks/Models/TaskSubtaskModel.php | 74 +++++++++++++ app/Modules/Tasks/Services/TaskService.php | 85 ++++++++++++++ app/Modules/Tasks/Views/tasks/show.twig | 95 ++++++++++++++++ 6 files changed, 417 insertions(+) create mode 100644 app/Database/Migrations/2026-02-08-100005_CreateTaskSubtasksTable.php create mode 100644 app/Modules/Tasks/Models/TaskSubtaskModel.php diff --git a/app/Database/Migrations/2026-02-08-100005_CreateTaskSubtasksTable.php b/app/Database/Migrations/2026-02-08-100005_CreateTaskSubtasksTable.php new file mode 100644 index 0000000..8c69194 --- /dev/null +++ b/app/Database/Migrations/2026-02-08-100005_CreateTaskSubtasksTable.php @@ -0,0 +1,54 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'is_completed' => [ + 'type' => 'BOOLEAN', + 'default' => false, + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_subtasks'); + } + + public function down() + { + $this->forge->dropTable('task_subtasks'); + } +} diff --git a/app/Modules/Tasks/Config/Routes.php b/app/Modules/Tasks/Config/Routes.php index 8a0b169..55d31c4 100644 --- a/app/Modules/Tasks/Config/Routes.php +++ b/app/Modules/Tasks/Config/Routes.php @@ -27,6 +27,11 @@ $routes->group('tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' $routes->post('move-column', 'TasksController::moveColumn'); $routes->post('(:num)/complete', 'TasksController::complete/$1'); $routes->post('(:num)/reopen', 'TasksController::reopen/$1'); + + // Subtasks API + $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'); }); // API Routes для Tasks diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php index ba7a80c..39af6bc 100644 --- a/app/Modules/Tasks/Controllers/TasksController.php +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -510,4 +510,108 @@ class TasksController extends BaseController return $this->response->setJSON(['success' => $result]); } + + // ========== Подзадачи (Subtasks) ========== + + /** + * API: добавить подзадачу + */ + public function addSubtask(int $taskId) + { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $title = trim($this->request->getPost('title')); + if (empty($title)) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Название подзадачи обязательно' + ]); + } + + // Проверяем что задача принадлежит организации + $task = $this->taskService->getTask($taskId, $organizationId); + if (!$task) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Задача не найдена' + ]); + } + + $subtaskId = $this->taskService->addSubtask($taskId, $title, $userId); + + return $this->response->setJSON([ + 'success' => (bool) $subtaskId, + 'subtask_id' => $subtaskId, + ]); + } + + /** + * API: переключить статус подзадачи + */ + public function toggleSubtask(int $taskId, int $subtaskId) + { + 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->toggleSubtask($taskId, $subtaskId, $userId); + + return $this->response->setJSON([ + 'success' => $result, + ]); + } + + /** + * API: удалить подзадачу + */ + public function deleteSubtask(int $taskId, int $subtaskId) + { + 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->deleteSubtask($taskId, $subtaskId, $userId); + + return $this->response->setJSON([ + 'success' => $result, + ]); + } } diff --git a/app/Modules/Tasks/Models/TaskSubtaskModel.php b/app/Modules/Tasks/Models/TaskSubtaskModel.php new file mode 100644 index 0000000..ce10dd2 --- /dev/null +++ b/app/Modules/Tasks/Models/TaskSubtaskModel.php @@ -0,0 +1,74 @@ +where('task_id', $taskId) + ->orderBy('order_index', 'ASC') + ->findAll(); + } + + /** + * Получить следующий порядковый номер для подзадачи + */ + public function getNextOrder(int $taskId): int + { + $max = $this->selectMax('order_index') + ->where('task_id', $taskId) + ->first(); + + return ($max['order_index'] ?? 0) + 1; + } + + /** + * Переключить статус подзадачи + */ + public function toggle(int $subtaskId): bool + { + $subtask = $this->find($subtaskId); + if (!$subtask) { + return false; + } + + return $this->update($subtaskId, [ + 'is_completed' => !$subtask['is_completed'], + ]); + } + + /** + * Получить количество незавершённых подзадач + */ + public function getIncompleteCount(int $taskId): int + { + return $this->where('task_id', $taskId) + ->where('is_completed', false) + ->countAllResults(); + } +} diff --git a/app/Modules/Tasks/Services/TaskService.php b/app/Modules/Tasks/Services/TaskService.php index d9949bd..6699429 100644 --- a/app/Modules/Tasks/Services/TaskService.php +++ b/app/Modules/Tasks/Services/TaskService.php @@ -5,6 +5,7 @@ namespace App\Modules\Tasks\Services; 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 CodeIgniter\Events\Events; class TaskService @@ -12,12 +13,14 @@ class TaskService protected TaskModel $taskModel; protected TaskAssigneeModel $assigneeModel; protected TaskColumnModel $columnModel; + protected TaskSubtaskModel $subtaskModel; public function __construct() { $this->taskModel = new TaskModel(); $this->assigneeModel = new TaskAssigneeModel(); $this->columnModel = new TaskColumnModel(); + $this->subtaskModel = new TaskSubtaskModel(); } /** @@ -174,6 +177,9 @@ class TaskService } $task['assignees'] = $this->assigneeModel->getAssigneesByTask($taskId); + $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'])); return $task; } @@ -264,4 +270,83 @@ class TaskService return $columns[0]['id'] ?? 1; } + + // ========== Подзадачи (Subtasks) ========== + + /** + * Добавить подзадачу + */ + public function addSubtask(int $taskId, string $title, int $userId): int + { + $orderIndex = $this->subtaskModel->getNextOrder($taskId); + + $subtaskId = $this->subtaskModel->insert([ + 'task_id' => $taskId, + 'title' => trim($title), + 'order_index' => $orderIndex, + ]); + + if ($subtaskId) { + Events::trigger('task.subtask_created', $taskId, $subtaskId, $userId); + } + + return $subtaskId; + } + + /** + * Переключить статус подзадачи + */ + public function toggleSubtask(int $taskId, int $subtaskId, int $userId): bool + { + $subtask = $this->subtaskModel->find($subtaskId); + if (!$subtask || $subtask['task_id'] != $taskId) { + return false; + } + + $result = $this->subtaskModel->toggle($subtaskId); + + if ($result) { + Events::trigger('task.subtask_toggled', $taskId, $subtaskId, !$subtask['is_completed'], $userId); + } + + return $result; + } + + /** + * Удалить подзадачу + */ + public function deleteSubtask(int $taskId, int $subtaskId, int $userId): bool + { + $subtask = $this->subtaskModel->find($subtaskId); + if (!$subtask || $subtask['task_id'] != $taskId) { + return false; + } + + $result = $this->subtaskModel->delete($subtaskId); + + if ($result) { + Events::trigger('task.subtask_deleted', $taskId, $subtaskId, $userId); + } + + return $result; + } + + /** + * Обновить подзадачу + */ + public function updateSubtask(int $taskId, int $subtaskId, array $data, int $userId): bool + { + $subtask = $this->subtaskModel->find($subtaskId); + if (!$subtask || $subtask['task_id'] != $taskId) { + return false; + } + + $result = $this->subtaskModel->update($subtaskId, $data); + + if ($result) { + Events::trigger('task.subtask_updated', $taskId, $subtaskId, $data, $userId); + } + + return $result; + } } diff --git a/app/Modules/Tasks/Views/tasks/show.twig b/app/Modules/Tasks/Views/tasks/show.twig index d7306da..5607d61 100644 --- a/app/Modules/Tasks/Views/tasks/show.twig +++ b/app/Modules/Tasks/Views/tasks/show.twig @@ -95,6 +95,50 @@ + {# Подзадачи #} +
+
+
+ Подзадачи + {% if task.subtasks_count %} + {{ task.subtasks_completed }}/{{ task.subtasks_count }} + {% endif %} +
+
+
+ {% if task.subtasks %} +
    + {% for subtask in task.subtasks %} +
  • + + + {{ subtask.title }} + + +
  • + {% endfor %} +
+ {% else %} +

Подзадач пока нет

+ {% endif %} + +
+ {{ csrf_field()|raw }} + + +
+
+
+ {# Комментарии #}
@@ -178,3 +222,54 @@
{% endblock %} + +{% block scripts %} +{{ parent() }} + +{% endblock %}