From 4a67f00aa7640cebaf76e2fc244707a950f591bd Mon Sep 17 00:00:00 2001 From: Vladimir Tomashevskiy Date: Sun, 8 Feb 2026 18:40:41 +0000 Subject: [PATCH] Tasks Module Stage 3: Checklists - Add migration for task_checklists table - Create TaskChecklistModel with CRUD methods - Add TaskService methods for checklist operations - Add API routes for checklist endpoints - Add controller methods for checklist CRUD - Add UI section in task view with AJAX handlers --- ...02-08-110006_CreateTaskChecklistsTable.php | 59 +++++++++ app/Modules/Tasks/Config/Routes.php | 5 + .../Tasks/Controllers/TasksController.php | 104 +++++++++++++++ .../Tasks/Models/TaskChecklistModel.php | 74 +++++++++++ app/Modules/Tasks/Services/TaskService.php | 85 ++++++++++++ app/Modules/Tasks/Views/tasks/show.twig | 123 ++++++++++++++++++ 6 files changed, 450 insertions(+) create mode 100644 app/Database/Migrations/2026-02-08-110006_CreateTaskChecklistsTable.php create mode 100644 app/Modules/Tasks/Models/TaskChecklistModel.php diff --git a/app/Database/Migrations/2026-02-08-110006_CreateTaskChecklistsTable.php b/app/Database/Migrations/2026-02-08-110006_CreateTaskChecklistsTable.php new file mode 100644 index 0000000..ecaafc8 --- /dev/null +++ b/app/Database/Migrations/2026-02-08-110006_CreateTaskChecklistsTable.php @@ -0,0 +1,59 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + '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->addKey('task_id'); + $this->forge->addForeignKey('task_id', 'tasks', 'id', '', 'CASCADE'); + + $this->forge->createTable('task_checklists'); + } + + public function down() + { + $this->forge->dropTable('task_checklists'); + } +} \ No newline at end of file diff --git a/app/Modules/Tasks/Config/Routes.php b/app/Modules/Tasks/Config/Routes.php index 55d31c4..6a66c16 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'); + + // Checklists API + $routes->post('(:num)/checklists', 'TasksController::addChecklistItem/$1'); + $routes->post('(:num)/checklists/(:num)/toggle', 'TasksController::toggleChecklistItem/$1/$2'); + $routes->post('(:num)/checklists/(:num)/delete', 'TasksController::deleteChecklistItem/$1/$2'); }); // API Routes для Tasks diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php index 39af6bc..af68289 100644 --- a/app/Modules/Tasks/Controllers/TasksController.php +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -614,4 +614,108 @@ class TasksController extends BaseController 'success' => $result, ]); } + + // ========== Чек-листы (Checklists) ========== + + /** + * API: добавить элемент чек-листа + */ + public function addChecklistItem(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' => 'Задача не найдена' + ]); + } + + $checklistId = $this->taskService->addChecklistItem($taskId, $title, $userId); + + return $this->response->setJSON([ + 'success' => (bool) $checklistId, + 'checklist_id' => $checklistId, + ]); + } + + /** + * API: переключить статус элемента чек-листа + */ + public function toggleChecklistItem(int $taskId, int $checklistId) + { + 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->toggleChecklistItem($taskId, $checklistId, $userId); + + return $this->response->setJSON([ + 'success' => $result, + ]); + } + + /** + * API: удалить элемент чек-листа + */ + public function deleteChecklistItem(int $taskId, int $checklistId) + { + 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->deleteChecklistItem($taskId, $checklistId, $userId); + + return $this->response->setJSON([ + 'success' => $result, + ]); + } } diff --git a/app/Modules/Tasks/Models/TaskChecklistModel.php b/app/Modules/Tasks/Models/TaskChecklistModel.php new file mode 100644 index 0000000..c1b3f4c --- /dev/null +++ b/app/Modules/Tasks/Models/TaskChecklistModel.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 $checklistId): bool + { + $checklist = $this->find($checklistId); + if (!$checklist) { + return false; + } + + return $this->update($checklistId, [ + 'is_completed' => !$checklist['is_completed'], + ]); + } + + /** + * Получить количество незавершённых элементов чек-листа + */ + public function getIncompleteCount(int $taskId): int + { + return $this->where('task_id', $taskId) + ->where('is_completed', false) + ->countAllResults(); + } +} \ No newline at end of file diff --git a/app/Modules/Tasks/Services/TaskService.php b/app/Modules/Tasks/Services/TaskService.php index 6699429..b5f5c2b 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\TaskChecklistModel; use CodeIgniter\Events\Events; class TaskService @@ -14,6 +15,7 @@ class TaskService protected TaskAssigneeModel $assigneeModel; protected TaskColumnModel $columnModel; protected TaskSubtaskModel $subtaskModel; + protected TaskChecklistModel $checklistModel; public function __construct() { @@ -21,6 +23,7 @@ class TaskService $this->assigneeModel = new TaskAssigneeModel(); $this->columnModel = new TaskColumnModel(); $this->subtaskModel = new TaskSubtaskModel(); + $this->checklistModel = new TaskChecklistModel(); } /** @@ -180,6 +183,9 @@ 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['checklists'] = $this->checklistModel->getByTask($taskId); + $task['checklists_count'] = count($task['checklists']); + $task['checklists_completed'] = count(array_filter($task['checklists'], fn($c) => $c['is_completed'])); return $task; } @@ -349,4 +355,83 @@ class TaskService return $result; } + + // ========== Чек-листы (Checklists) ========== + + /** + * Добавить элемент чек-листа + */ + public function addChecklistItem(int $taskId, string $title, int $userId): int + { + $orderIndex = $this->checklistModel->getNextOrder($taskId); + + $checklistId = $this->checklistModel->insert([ + 'task_id' => $taskId, + 'title' => trim($title), + 'order_index' => $orderIndex, + ]); + + if ($checklistId) { + Events::trigger('task.checklist_item_created', $taskId, $checklistId, $userId); + } + + return $checklistId; + } + + /** + * Переключить статус элемента чек-листа + */ + public function toggleChecklistItem(int $taskId, int $checklistId, int $userId): bool + { + $checklist = $this->checklistModel->find($checklistId); + if (!$checklist || $checklist['task_id'] != $taskId) { + return false; + } + + $result = $this->checklistModel->toggle($checklistId); + + if ($result) { + Events::trigger('task.checklist_item_toggled', $taskId, $checklistId, !$checklist['is_completed'], $userId); + } + + return $result; + } + + /** + * Удалить элемент чек-листа + */ + public function deleteChecklistItem(int $taskId, int $checklistId, int $userId): bool + { + $checklist = $this->checklistModel->find($checklistId); + if (!$checklist || $checklist['task_id'] != $taskId) { + return false; + } + + $result = $this->checklistModel->delete($checklistId); + + if ($result) { + Events::trigger('task.checklist_item_deleted', $taskId, $checklistId, $userId); + } + + return $result; + } + + /** + * Обновить элемент чек-листа + */ + public function updateChecklistItem(int $taskId, int $checklistId, array $data, int $userId): bool + { + $checklist = $this->checklistModel->find($checklistId); + if (!$checklist || $checklist['task_id'] != $taskId) { + return false; + } + + $result = $this->checklistModel->update($checklistId, $data); + + if ($result) { + Events::trigger('task.checklist_item_updated', $taskId, $checklistId, $data, $userId); + } + + return $result; + } } diff --git a/app/Modules/Tasks/Views/tasks/show.twig b/app/Modules/Tasks/Views/tasks/show.twig index d2a16aa..ca9fa08 100644 --- a/app/Modules/Tasks/Views/tasks/show.twig +++ b/app/Modules/Tasks/Views/tasks/show.twig @@ -140,6 +140,51 @@ + {# Чек-лист #} +
+
+
+ Чек-лист + {% if task.checklists_count %} + {{ task.checklists_completed }}/{{ task.checklists_count }} + {% endif %} +
+
+
+ {% if task.checklists %} +
    + {% for checklist in task.checklists %} +
  • + + + {{ checklist.title }} + + +
  • + {% endfor %} +
+ {% else %} +

Чек-лист пуст

+ {% endif %} + +
+ {{ csrf_field()|raw }} + + +
+
+
+ {# Комментарии #}
@@ -314,5 +359,83 @@ function deleteSubtask(taskId, subtaskId) { console.error('Error:', error); }); } + +// ========== Чек-листы ========== + +function addChecklistItem(event, taskId) { + event.preventDefault(); + const form = event.target; + const input = form.querySelector('input[name="title"]'); + const title = input.value.trim(); + + if (!title) return; + + fetch(`/tasks/${taskId}/checklists`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: 'title=' + encodeURIComponent(title) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + location.reload(); + } else { + alert(data.error || 'Ошибка при добавлении пункта чек-листа'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Ошибка при добавлении пункта чек-листа'); + }); +} + +function toggleChecklistItem(taskId, checklistId) { + fetch(`/tasks/${taskId}/checklists/${checklistId}/toggle`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: '' + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + alert(data.error || 'Ошибка'); + } else { + location.reload(); + } + }) + .catch(error => { + console.error('Error:', error); + }); +} + +function deleteChecklistItem(taskId, checklistId) { + if (!confirm('Удалить пункт чек-листа?')) return; + + fetch(`/tasks/${taskId}/checklists/${checklistId}/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: '' + }) + .then(response => response.json()) + .then(data => { + if (!data.success) { + alert(data.error || 'Ошибка'); + } else { + location.reload(); + } + }) + .catch(error => { + console.error('Error:', error); + }); +} {% endblock %}