From 725c62a1794d78253902b75f6e0232882cba0e99 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 23 Jan 2026 08:48:06 +0300 Subject: [PATCH] start tasks module --- app/Config/Routes.php | 1 + app/Config/Twig.php | 3 +- ...026-01-19-100001_CreateTaskBoardsTable.php | 54 +++ ...26-01-19-100002_CreateTaskColumnsTable.php | 60 +++ .../2026-01-19-100003_CreateTasksTable.php | 82 ++++ ...-01-19-100004_CreateTaskAssigneesTable.php | 49 ++ app/Modules/CRM/Services/DealService.php | 59 ++- app/Modules/Tasks/Config/Events.php | 135 ++++++ app/Modules/Tasks/Config/Routes.php | 35 ++ .../Tasks/Controllers/TaskApiController.php | 39 ++ .../Tasks/Controllers/TasksController.php | 437 ++++++++++++++++++ .../Tasks/Models/TaskAssigneeModel.php | 76 +++ app/Modules/Tasks/Models/TaskBoardModel.php | 81 ++++ app/Modules/Tasks/Models/TaskColumnModel.php | 89 ++++ app/Modules/Tasks/Models/TaskModel.php | 140 ++++++ .../Tasks/Services/TaskBoardService.php | 126 +++++ app/Modules/Tasks/Services/TaskService.php | 267 +++++++++++ app/Modules/Tasks/Views/tasks/calendar.twig | 170 +++++++ app/Modules/Tasks/Views/tasks/form.twig | 141 ++++++ app/Modules/Tasks/Views/tasks/index.twig | 72 +++ app/Modules/Tasks/Views/tasks/kanban.twig | 199 ++++++++ app/Modules/Tasks/Views/tasks/show.twig | 180 ++++++++ 22 files changed, 2489 insertions(+), 6 deletions(-) create mode 100644 app/Database/Migrations/2026-01-19-100001_CreateTaskBoardsTable.php create mode 100644 app/Database/Migrations/2026-01-19-100002_CreateTaskColumnsTable.php create mode 100644 app/Database/Migrations/2026-01-19-100003_CreateTasksTable.php create mode 100644 app/Database/Migrations/2026-01-19-100004_CreateTaskAssigneesTable.php create mode 100644 app/Modules/Tasks/Config/Events.php create mode 100644 app/Modules/Tasks/Config/Routes.php create mode 100644 app/Modules/Tasks/Controllers/TaskApiController.php create mode 100644 app/Modules/Tasks/Controllers/TasksController.php create mode 100644 app/Modules/Tasks/Models/TaskAssigneeModel.php create mode 100644 app/Modules/Tasks/Models/TaskBoardModel.php create mode 100644 app/Modules/Tasks/Models/TaskColumnModel.php create mode 100644 app/Modules/Tasks/Models/TaskModel.php create mode 100644 app/Modules/Tasks/Services/TaskBoardService.php create mode 100644 app/Modules/Tasks/Services/TaskService.php create mode 100644 app/Modules/Tasks/Views/tasks/calendar.twig create mode 100644 app/Modules/Tasks/Views/tasks/form.twig create mode 100644 app/Modules/Tasks/Views/tasks/index.twig create mode 100644 app/Modules/Tasks/Views/tasks/kanban.twig create mode 100644 app/Modules/Tasks/Views/tasks/show.twig diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 257394b..ffd274b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -87,6 +87,7 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) { $routes->group('', ['filter' => 'auth'], static function ($routes) { require_once APPPATH . 'Modules/Clients/Config/Routes.php'; require_once APPPATH . 'Modules/CRM/Config/Routes.php'; + require_once APPPATH . 'Modules/Tasks/Config/Routes.php'; }); # ============================================================================= diff --git a/app/Config/Twig.php b/app/Config/Twig.php index c973015..8fbc1a5 100644 --- a/app/Config/Twig.php +++ b/app/Config/Twig.php @@ -45,8 +45,7 @@ class Twig extends \Daycry\Twig\Config\Twig [APPPATH . 'Views/components', 'components'], // Компоненты таблиц [APPPATH . 'Modules/Clients/Views', 'Clients'], // Модуль Клиенты [APPPATH . 'Modules/CRM/Views', 'CRM'], // Модуль CRM (основная папка) - // [APPPATH . 'Modules/CRM/Views/deals', 'CRM/Deals'], // Сделки - // [APPPATH . 'Modules/CRM/Views/contacts', 'CRM/Contacts'], // Контакты + [APPPATH . 'Modules/Tasks/Views', 'Tasks'], // Модуль Задачи ]; /** diff --git a/app/Database/Migrations/2026-01-19-100001_CreateTaskBoardsTable.php b/app/Database/Migrations/2026-01-19-100001_CreateTaskBoardsTable.php new file mode 100644 index 0000000..2023688 --- /dev/null +++ b/app/Database/Migrations/2026-01-19-100001_CreateTaskBoardsTable.php @@ -0,0 +1,54 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_boards'); + } + + public function down() + { + $this->forge->dropTable('task_boards'); + } +} diff --git a/app/Database/Migrations/2026-01-19-100002_CreateTaskColumnsTable.php b/app/Database/Migrations/2026-01-19-100002_CreateTaskColumnsTable.php new file mode 100644 index 0000000..d3d637a --- /dev/null +++ b/app/Database/Migrations/2026-01-19-100002_CreateTaskColumnsTable.php @@ -0,0 +1,60 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'board_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'color' => [ + 'type' => 'VARCHAR', + 'constraint' => 7, + 'default' => '#6B7280', + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('board_id', 'task_boards', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_columns'); + } + + public function down() + { + $this->forge->dropTable('task_columns'); + } +} diff --git a/app/Database/Migrations/2026-01-19-100003_CreateTasksTable.php b/app/Database/Migrations/2026-01-19-100003_CreateTasksTable.php new file mode 100644 index 0000000..567d4ff --- /dev/null +++ b/app/Database/Migrations/2026-01-19-100003_CreateTasksTable.php @@ -0,0 +1,82 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'board_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'column_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'priority' => [ + 'type' => 'ENUM', + 'constraint' => ['low', 'medium', 'high', 'urgent'], + 'default' => 'medium', + ], + 'due_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'completed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + // Все внешние ключи убраны - ссылочная целостность контролируется на уровне приложения + $this->forge->createTable('tasks'); + } + + public function down() + { + $this->forge->dropTable('tasks'); + } +} diff --git a/app/Database/Migrations/2026-01-19-100004_CreateTaskAssigneesTable.php b/app/Database/Migrations/2026-01-19-100004_CreateTaskAssigneesTable.php new file mode 100644 index 0000000..54477c9 --- /dev/null +++ b/app/Database/Migrations/2026-01-19-100004_CreateTaskAssigneesTable.php @@ -0,0 +1,49 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'role' => [ + 'type' => 'ENUM', + 'constraint' => ['assignee', 'watcher'], + 'default' => 'assignee', + ], + 'assigned_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['task_id', 'user_id']); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_assignees'); + } + + public function down() + { + $this->forge->dropTable('task_assignees'); + } +} diff --git a/app/Modules/CRM/Services/DealService.php b/app/Modules/CRM/Services/DealService.php index 5c000b1..e92f63e 100644 --- a/app/Modules/CRM/Services/DealService.php +++ b/app/Modules/CRM/Services/DealService.php @@ -4,6 +4,7 @@ namespace App\Modules\CRM\Services; use App\Modules\CRM\Models\DealModel; use App\Modules\CRM\Models\DealStageModel; +use CodeIgniter\Events\Events; class DealService { @@ -23,7 +24,18 @@ class DealService { $data['created_by'] = $userId; - return $this->dealModel->insert($data); + $dealId = $this->dealModel->insert($data); + + if ($dealId) { + $deal = $this->dealModel->find($dealId); + Events::trigger('deal.created', [ + 'deal_id' => $dealId, + 'deal' => $deal, + 'user_id' => $userId, + ]); + } + + return $dealId; } /** @@ -36,7 +48,20 @@ class DealService return false; } - return $this->dealModel->update($dealId, $data); + $result = $this->dealModel->update($dealId, $data); + + if ($result) { + $newDeal = $this->dealModel->find($dealId); + Events::trigger('deal.updated', [ + 'deal_id' => $dealId, + 'old_deal' => $oldDeal, + 'new_deal' => $newDeal, + 'changes' => $data, + 'user_id' => $userId, + ]); + } + + return $result; } /** @@ -54,7 +79,23 @@ class DealService return false; } - return $this->dealModel->update($dealId, ['stage_id' => $newStageId]); + $oldStageId = $deal['stage_id']; + $result = $this->dealModel->update($dealId, ['stage_id' => $newStageId]); + + if ($result) { + $updatedDeal = $this->dealModel->find($dealId); + Events::trigger('deal.stage_changed', [ + 'deal_id' => $dealId, + 'deal' => $updatedDeal, + 'old_stage_id' => $oldStageId, + 'new_stage_id' => $newStageId, + 'old_stage' => $this->stageModel->find($oldStageId), + 'new_stage' => $newStage, + 'user_id' => $userId, + ]); + } + + return $result; } /** @@ -67,7 +108,17 @@ class DealService return false; } - return $this->dealModel->delete($dealId); + $result = $this->dealModel->delete($dealId); + + if ($result) { + Events::trigger('deal.deleted', [ + 'deal_id' => $dealId, + 'deal' => $deal, + 'user_id' => $userId, + ]); + } + + return $result; } /** diff --git a/app/Modules/Tasks/Config/Events.php b/app/Modules/Tasks/Config/Events.php new file mode 100644 index 0000000..b355c8a --- /dev/null +++ b/app/Modules/Tasks/Config/Events.php @@ -0,0 +1,135 @@ +createTask([ + 'title' => 'Проверить новую сделку: ' . ($data['deal']['name'] ?? 'Без названия'), + 'description' => 'Автоматически созданная задача для проверки новой сделки #' . $data['deal_id'], + 'board_id' => null, // Будет использоваться доска по умолчанию + 'assigned_to' => $data['user_id'], + 'due_date' => date('Y-m-d', strtotime('+1 day')), // Срок - завтра + 'priority' => 'medium', + 'metadata' => json_encode([ + 'source' => 'deal.created', + 'deal_id' => $data['deal_id'], + 'created_at' => date('Y-m-d H:i:s'), + ]), + ]); + }); + + /** + * Обработчик изменения этапа сделки + * Создаёт задачи в зависимости от перехода этапа + */ + Events::on('deal.stage_changed', function (array $data) { + $taskService = service('taskService'); + + $oldStageName = $data['old_stage']['name'] ?? 'Неизвестно'; + $newStageName = $data['new_stage']['name'] ?? 'Неизвестно'; + + // Логика автоматического создания задач на основе перехода этапов + $taskConfig = getAutoTaskConfig($data['old_stage_id'], $data['new_stage_id'], $data['deal']); + + if ($taskConfig) { + $taskService->createTask([ + 'title' => $taskConfig['title'], + 'description' => $taskConfig['description'], + 'board_id' => $taskConfig['board_id'] ?? null, + 'assigned_to' => $taskConfig['assigned_to'] ?? $data['user_id'], + 'due_date' => $taskConfig['due_date'] ?? null, + 'priority' => $taskConfig['priority'] ?? 'medium', + 'metadata' => json_encode([ + 'source' => 'deal.stage_changed', + 'deal_id' => $data['deal_id'], + 'old_stage_id' => $data['old_stage_id'], + 'new_stage_id' => $data['new_stage_id'], + 'transition' => $oldStageName . ' → ' . $newStageName, + 'created_at' => date('Y-m-d H:i:s'), + ]), + ]); + } + }); + + /** + * Обработчик обновления сделки + * Может использоваться для уведомлений или отслеживания изменений + */ + Events::on('deal.updated', function (array $data) { + // Логирование изменений или дополнительные действия при необходимости + log_message('info', 'Deal updated: ' . $data['deal_id'] . ' by user: ' . $data['user_id']); + }); + + /** + * Обработчик удаления сделки + * Может использоваться для очистки связанных задач + */ + Events::on('deal.deleted', function (array $data) { + $taskService = service('taskService'); + + // Находим и удаляем или помечаем задачи связанные с удалённой сделкой + // В реальном приложении здесь была бы логика поиска и обработки связанных задач + log_message('info', 'Deal deleted: ' . $data['deal_id'] . '. Consider cleaning up related tasks.'); + }); +}); + +/** + * Конфигурация автоматического создания задач при переходе этапов + * + * @param int $oldStageId Предыдущий этап + * @param int $newStageId Новый этап + * @param array $dealData Данные сделки + * @return array|null Конфигурация задачи или null если не требуется создание + */ +function getAutoTaskConfig(int $oldStageId, int $newStageId, array $dealData): ?array +{ + // Пример: Переход на этап "Закрыта успешно" + // Можно расширить конфигурацию на основе настроек организации + + $taskConfigs = [ + // ID этапов могут быть разными для каждой организации + // Здесь используются примеры + 'won_stage' => [ // При победе + 'title' => 'Подготовить документы для закрытой сделки', + 'description' => 'Сделка "' . ($dealData['name'] ?? 'Без названия') . '" успешно закрыта. Необходимо подготовить закрывающие документы.', + 'priority' => 'high', + 'due_days' => 3, + ], + 'negotiation' => [ // Переговоры + 'title' => 'Провести переговоры по сделке', + 'description' => 'Сделка переведена на этап переговоров. Требуется связаться с клиентом.', + 'priority' => 'medium', + 'due_days' => 2, + ], + 'contract' => [ // Договор + 'title' => 'Подготовить договор', + 'description' => 'Сделка переведена на этап договора. Необходимо подготовить и отправить договор клиенту.', + 'priority' => 'high', + 'due_days' => 1, + ], + ]; + + // Определяем тип перехода и возвращаем соответствующую конфигурацию + // В реальном приложении эта логика должна быть более гибкой + // и основываться на конфигурации этапов в базе данных + + return null; // По умолчанию не создаём задачу +} diff --git a/app/Modules/Tasks/Config/Routes.php b/app/Modules/Tasks/Config/Routes.php new file mode 100644 index 0000000..8a0b169 --- /dev/null +++ b/app/Modules/Tasks/Config/Routes.php @@ -0,0 +1,35 @@ +group('tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' => 'App\Modules\Tasks\Controllers'], static function ($routes) { + + // Список задач (таблица) + $routes->get('/', 'TasksController::index'); + $routes->get('table', 'TasksController::table'); + + // Канбан + $routes->get('kanban', 'TasksController::kanban'); + + // Календарь + $routes->get('calendar', 'TasksController::calendar'); + + // CRUD задач + $routes->get('new', 'TasksController::create'); + $routes->get('create', 'TasksController::create'); + $routes->post('/', 'TasksController::store'); + $routes->get('(:num)', 'TasksController::show/$1'); + $routes->get('(:num)/edit', 'TasksController::edit/$1'); + $routes->post('(:num)', 'TasksController::update/$1'); + $routes->get('(:num)/delete', 'TasksController::destroy/$1'); + + // API endpoints + $routes->post('move-column', 'TasksController::moveColumn'); + $routes->post('(:num)/complete', 'TasksController::complete/$1'); + $routes->post('(:num)/reopen', 'TasksController::reopen/$1'); +}); + +// API Routes для Tasks +$routes->group('api/tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' => 'App\Modules\Tasks\Controllers'], static function ($routes) { + $routes->get('columns', 'TaskApiController::getColumns'); +}); diff --git a/app/Modules/Tasks/Controllers/TaskApiController.php b/app/Modules/Tasks/Controllers/TaskApiController.php new file mode 100644 index 0000000..f16cebd --- /dev/null +++ b/app/Modules/Tasks/Controllers/TaskApiController.php @@ -0,0 +1,39 @@ +columnModel = new TaskColumnModel(); + } + + /** + * Получить колонки доски + * GET /api/tasks/columns?board_id=1 + */ + public function getColumns() + { + $boardId = $this->request->getGet('board_id'); + + if (!$boardId) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'board_id required', + ]); + } + + $columns = $this->columnModel->getColumnsByBoard((int) $boardId); + + return $this->response->setJSON([ + 'success' => true, + 'columns' => $columns, + ]); + } +} diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php new file mode 100644 index 0000000..3ed643d --- /dev/null +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -0,0 +1,437 @@ +taskService = new TaskService(); + $this->boardService = new TaskBoardService(); + } + + /** + * Главная страница - список задач (использует DataTable) + */ + public function index() + { + $organizationId = $this->requireActiveOrg(); + + return $this->renderTwig('@Tasks/tasks/index', [ + 'title' => 'Задачи', + 'tableHtml' => $this->renderTable($this->getTableConfig()), + 'stats' => $this->taskService->getStats($organizationId), + 'boards' => $this->boardService->getOrganizationBoards($organizationId), + ]); + } + + /** + * AJAX endpoint для таблицы задач + */ + public function table(?array $config = null, ?string $pageUrl = null) + { + return parent::table($this->getTableConfig(), '/tasks'); + } + + /** + * Конфигурация таблицы задач для DataTable + */ + protected function getTableConfig(): array + { + $organizationId = $this->getActiveOrgId(); + + return [ + 'id' => 'tasks-table', + 'url' => '/tasks/table', + 'model' => $this->taskService->getModel(), + 'columns' => [ + 'title' => [ + 'label' => 'Задача', + 'width' => '35%', + ], + 'column_name' => [ + 'label' => 'Статус', + 'width' => '15%', + ], + 'priority' => [ + 'label' => 'Приоритет', + 'width' => '10%', + ], + 'due_date' => [ + 'label' => 'Срок', + 'width' => '10%', + ], + 'created_by_name' => [ + 'label' => 'Автор', + 'width' => '15%', + ], + ], + 'searchable' => ['title', 'column_name', 'created_by_name'], + 'sortable' => ['title', 'priority', 'due_date', 'created_at', 'column_name'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'actions' => ['label' => '', 'width' => '15%'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/tasks/{id}', + 'icon' => 'fa-solid fa-eye', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Просмотр', + ], + [ + 'label' => '', + 'url' => '/tasks/{id}/edit', + 'icon' => 'fa-solid fa-pen', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Редактировать', + 'type' => 'edit', + ], + [ + 'label' => '', + 'url' => '/tasks/{id}/delete', + 'icon' => 'fa-solid fa-trash', + 'class' => 'btn-outline-danger btn-sm', + 'title' => 'Удалить', + 'type' => 'delete', + ], + ], + 'emptyMessage' => 'Задач пока нет', + 'emptyIcon' => 'fa-solid fa-check-square', + 'emptyActionUrl' => '/tasks/new', + 'emptyActionLabel' => 'Создать задачу', + 'emptyActionIcon' => 'fa-solid fa-plus', + 'can_edit' => true, + 'can_delete' => true, + 'fieldMap' => [ + 'column_name' => 'tc.name', + 'created_by_name' => 'u.name', + ], + 'scope' => function($builder) use ($organizationId) { + $builder->from('tasks') + ->select('tasks.id, tasks.title, tasks.description, tasks.priority, tasks.due_date, tasks.completed_at, tasks.created_at, tc.name as column_name, tc.color as column_color, u.name as created_by_name') + ->join('task_columns tc', 'tasks.column_id = tc.id', 'left') + ->join('users u', 'tasks.created_by = u.id', 'left') + ->where('tasks.organization_id', $organizationId); + }, + ]; + } + + /** + * Канбан-доска задач + */ + public function kanban() + { + $organizationId = $this->requireActiveOrg(); + $boardId = (int) ($this->request->getGet('board') ?? 0); + + // Получаем доску + if (!$boardId) { + $boards = $this->boardService->getOrganizationBoards($organizationId); + $boardId = $boards[0]['id'] ?? 0; + } + + $board = $this->boardService->getBoardWithColumns($boardId, $organizationId); + + if (!$board) { + return redirect()->to('/tasks')->with('error', 'Доска не найдена'); + } + + $kanbanData = $this->taskService->getTasksForKanban($boardId); + + // Формируем колонки для компонента + $kanbanColumns = []; + foreach ($board['columns'] as $column) { + $columnTasks = $kanbanData[$column['id']]['tasks'] ?? []; + $kanbanColumns[] = [ + 'id' => $column['id'], + 'name' => $column['name'], + 'color' => $column['color'], + 'items' => $columnTasks, + ]; + } + + return $this->renderTwig('@Tasks/tasks/kanban', [ + 'title' => 'Задачи — Канбан', + 'kanbanColumns' => $kanbanColumns, + 'board' => $board, + 'boards' => $this->boardService->getOrganizationBoards($organizationId), + 'stats' => $this->taskService->getStats($organizationId), + ]); + } + + /** + * Календарь задач + */ + public function calendar() + { + $organizationId = $this->requireActiveOrg(); + $month = $this->request->getGet('month') ?? date('Y-m'); + + $currentTimestamp = strtotime($month . '-01'); + $daysInMonth = date('t', $currentTimestamp); + $firstDayOfWeek = date('N', $currentTimestamp) - 1; + + $tasks = $this->taskService->getTasksForCalendar($organizationId, $month); + $eventsByDate = []; + + foreach ($tasks as $task) { + if ($task['due_date']) { + $dateKey = date('Y-m-d', strtotime($task['due_date'])); + $eventsByDate[$dateKey][] = [ + 'id' => $task['id'], + 'title' => $task['title'], + 'date' => $task['due_date'], + 'column_color' => $task['column_color'] ?? '#6B7280', + 'priority' => $task['priority'], + 'url' => '/tasks/' . $task['id'], + ]; + } + } + + return $this->renderTwig('@Tasks/tasks/calendar', [ + 'title' => 'Задачи — Календарь', + 'eventsByDate' => $eventsByDate, + 'currentMonth' => $month, + 'monthName' => date('F Y', $currentTimestamp), + 'daysInMonth' => $daysInMonth, + 'firstDayOfWeek' => $firstDayOfWeek, + 'prevMonth' => date('Y-m', strtotime('-1 month', $currentTimestamp)), + 'nextMonth' => date('Y-m', strtotime('+1 month', $currentTimestamp)), + 'today' => date('Y-m-d'), + 'stats' => $this->taskService->getStats($organizationId), + ]); + } + + /** + * Страница создания задачи + */ + public function create() + { + $organizationId = $this->requireActiveOrg(); + $boardId = (int) ($this->request->getGet('board') ?? 0); + + // Получаем доски организации, если нет - создаём дефолтную + $boards = $this->boardService->getOrganizationBoards($organizationId); + if (empty($boards)) { + $boardId = $this->boardService->createBoard([ + 'organization_id' => $organizationId, + 'name' => 'Мои задачи', + 'description' => 'Основная доска задач', + ], $this->getCurrentUserId()); + $boards = $this->boardService->getOrganizationBoards($organizationId); + } + + if (!$boardId && !empty($boards)) { + $boardId = $boards[0]['id']; + } + + // Получаем пользователей организации + $orgUserModel = new OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + + return $this->renderTwig('@Tasks/tasks/form', [ + 'title' => 'Новая задача', + 'actionUrl' => '/tasks', + 'boards' => $boards, + 'selectedBoard' => $boardId, + 'users' => $users, + 'currentUserId' => $this->getCurrentUserId(), + 'priorities' => [ + 'low' => 'Низкий', + 'medium' => 'Средний', + 'high' => 'Высокий', + 'urgent' => 'Срочный', + ], + ]); + } + + /** + * Сохранить новую задачу + */ + public function store() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $data = [ + 'organization_id' => $organizationId, + 'board_id' => $this->request->getPost('board_id'), + 'column_id' => $this->request->getPost('column_id'), + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'priority' => $this->request->getPost('priority') ?? 'medium', + 'due_date' => $this->request->getPost('due_date') ?: null, + ]; + + $taskId = $this->taskService->createTask($data, $userId); + + if ($taskId) { + // Добавляем исполнителей + $assignees = $this->request->getPost('assignees') ?? []; + if (!empty($assignees)) { + $this->taskService->updateAssignees($taskId, $assignees); + } + + return redirect()->to('/tasks')->with('success', 'Задача успешно создана'); + } + + return redirect()->back()->with('error', 'Ошибка при создании задачи')->withInput(); + } + + /** + * Просмотр задачи + */ + public function show(int $id) + { + $organizationId = $this->requireActiveOrg(); + $task = $this->taskService->getTask($id, $organizationId); + + if (!$task) { + return redirect()->to('/tasks')->with('error', 'Задача не найдена'); + } + + return $this->renderTwig('@Tasks/tasks/show', [ + 'title' => $task['title'], + 'task' => (object) $task, + ]); + } + + /** + * Страница редактирования задачи + */ + public function edit(int $id) + { + $organizationId = $this->requireActiveOrg(); + $task = $this->taskService->getTask($id, $organizationId); + + if (!$task) { + return redirect()->to('/tasks')->with('error', 'Задача не найдена'); + } + + // Получаем пользователей организации + $orgUserModel = new OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + + // Получаем доски + $boards = $this->boardService->getOrganizationBoards($organizationId); + + return $this->renderTwig('@Tasks/tasks/form', [ + 'title' => 'Редактирование задачи', + 'actionUrl' => "/tasks/{$id}", + 'task' => (object) $task, + 'boards' => $boards, + 'users' => $users, + 'currentUserId' => $this->getCurrentUserId(), + 'priorities' => [ + 'low' => 'Низкий', + 'medium' => 'Средний', + 'high' => 'Высокий', + 'urgent' => 'Срочный', + ], + ]); + } + + /** + * Обновить задачу + */ + public function update(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $data = [ + 'board_id' => $this->request->getPost('board_id'), + 'column_id' => $this->request->getPost('column_id'), + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'priority' => $this->request->getPost('priority') ?? 'medium', + 'due_date' => $this->request->getPost('due_date') ?: null, + ]; + + $result = $this->taskService->updateTask($id, $data, $userId); + + if ($result) { + // Обновляем исполнителей + $assignees = $this->request->getPost('assignees') ?? []; + $this->taskService->updateAssignees($id, $assignees); + + return redirect()->to("/tasks/{$id}")->with('success', 'Задача обновлена'); + } + + return redirect()->back()->with('error', 'Ошибка при обновлении задачи')->withInput(); + } + + /** + * Удалить задачу + */ + public function destroy(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $this->taskService->deleteTask($id, $userId); + + return redirect()->to('/tasks')->with('success', 'Задача удалена'); + } + + /** + * API: перемещение задачи между колонками (drag-and-drop) + */ + public function moveColumn() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $taskId = $this->request->getPost('task_id'); + $newColumnId = $this->request->getPost('column_id'); + + $result = $this->taskService->changeColumn($taskId, $newColumnId, $userId); + + $csrfToken = csrf_hash(); + $csrfHash = csrf_token(); + + return $this->response + ->setHeader('X-CSRF-TOKEN', $csrfToken) + ->setHeader('X-CSRF-HASH', $csrfHash) + ->setJSON(['success' => $result]); + } + + /** + * API: завершить задачу + */ + public function complete(int $id) + { + $userId = $this->getCurrentUserId(); + $result = $this->taskService->completeTask($id, $userId); + + return $this->response->setJSON(['success' => $result]); + } + + /** + * API: возобновить задачу + */ + public function reopen(int $id) + { + $userId = $this->getCurrentUserId(); + $result = $this->taskService->reopenTask($id, $userId); + + return $this->response->setJSON(['success' => $result]); + } +} diff --git a/app/Modules/Tasks/Models/TaskAssigneeModel.php b/app/Modules/Tasks/Models/TaskAssigneeModel.php new file mode 100644 index 0000000..d2d80b5 --- /dev/null +++ b/app/Modules/Tasks/Models/TaskAssigneeModel.php @@ -0,0 +1,76 @@ +select('task_assignees.*, users.name as user_name, users.email as user_email') + ->join('users', 'task_assignees.user_id = users.id', 'left') + ->where('task_id', $taskId) + ->findAll(); + } + + /** + * Получить задачи пользователя + */ + public function getTasksByUser(int $userId, int $organizationId): array + { + return $this->select('tasks.*') + ->join('tasks', 'task_assignees.task_id = tasks.id') + ->where('task_assignees.user_id', $userId) + ->where('tasks.organization_id', $organizationId) + ->findAll(); + } + + /** + * Добавить исполнителя + */ + public function addAssignee(int $taskId, int $userId, string $role = 'assignee'): int + { + return $this->insert([ + 'task_id' => $taskId, + 'user_id' => $userId, + 'role' => $role, + 'assigned_at' => date('Y-m-d H:i:s'), + ]); + } + + /** + * Удалить исполнителя + */ + public function removeAssignee(int $taskId, int $userId): bool + { + return $this->where('task_id', $taskId) + ->where('user_id', $userId) + ->delete() > 0; + } + + /** + * Проверить, является ли пользователь исполнителем + */ + public function isAssignee(int $taskId, int $userId): bool + { + return $this->where('task_id', $taskId) + ->where('user_id', $userId) + ->countAllResults() > 0; + } +} diff --git a/app/Modules/Tasks/Models/TaskBoardModel.php b/app/Modules/Tasks/Models/TaskBoardModel.php new file mode 100644 index 0000000..6ddf086 --- /dev/null +++ b/app/Modules/Tasks/Models/TaskBoardModel.php @@ -0,0 +1,81 @@ +where('organization_id', $organizationId) + ->orderBy('is_default', 'DESC') + ->orderBy('created_at', 'DESC') + ->findAll(); + } + + /** + * Получить доску по ID + */ + public function getBoard(int $boardId, int $organizationId): ?array + { + return $this->where('id', $boardId) + ->where('organization_id', $organizationId) + ->first(); + } + + /** + * Получить дефолтную доску организации + */ + public function getDefaultBoard(int $organizationId): ?array + { + return $this->where('organization_id', $organizationId) + ->where('is_default', 1) + ->first(); + } + + /** + * Создать дефолтную доску для новой организации + */ + public function createDefaultBoard(int $organizationId): int + { + $data = [ + 'organization_id' => $organizationId, + 'name' => 'Мои задачи', + 'description' => 'Основная доска задач', + 'is_default' => 1, + ]; + + $boardId = $this->insert($data); + + if ($boardId) { + // Создаём дефолтные колонки + $columnModel = new TaskColumnModel(); + $columnModel->createDefaultColumns($boardId); + } + + return $boardId; + } +} diff --git a/app/Modules/Tasks/Models/TaskColumnModel.php b/app/Modules/Tasks/Models/TaskColumnModel.php new file mode 100644 index 0000000..234f304 --- /dev/null +++ b/app/Modules/Tasks/Models/TaskColumnModel.php @@ -0,0 +1,89 @@ +where('board_id', $boardId) + ->orderBy('order_index', 'ASC') + ->findAll(); + } + + /** + * Получить следующий порядковый номер для колонки + */ + public function getNextOrderIndex(int $boardId): int + { + $max = $this->selectMax('order_index') + ->where('board_id', $boardId) + ->first(); + + return ($max['order_index'] ?? 0) + 1; + } + + /** + * Создать дефолтные колонки для новой доски + */ + public function createDefaultColumns(int $boardId): bool + { + $defaultColumns = [ + [ + 'board_id' => $boardId, + 'name' => 'К выполнению', + 'color' => '#6B7280', + 'order_index' => 1, + 'is_default' => 0, + ], + [ + 'board_id' => $boardId, + 'name' => 'В работе', + 'color' => '#3B82F6', + 'order_index' => 2, + 'is_default' => 0, + ], + [ + 'board_id' => $boardId, + 'name' => 'На проверке', + 'color' => '#F59E0B', + 'order_index' => 3, + 'is_default' => 0, + ], + [ + 'board_id' => $boardId, + 'name' => 'Завершено', + 'color' => '#10B981', + 'order_index' => 4, + 'is_default' => 0, + ], + ]; + + return $this->insertBatch($defaultColumns); + } +} diff --git a/app/Modules/Tasks/Models/TaskModel.php b/app/Modules/Tasks/Models/TaskModel.php new file mode 100644 index 0000000..88f206a --- /dev/null +++ b/app/Modules/Tasks/Models/TaskModel.php @@ -0,0 +1,140 @@ +select(' + tasks.id, + tasks.title, + tasks.description, + tasks.priority, + tasks.due_date, + tasks.completed_at, + tasks.created_at, + tc.name as column_name, + tc.color as column_color, + u.name as created_by_name + ') + ->join('task_columns tc', 'tasks.column_id = tc.id', 'left') + ->join('users u', 'tasks.created_by = u.id', 'left') + ->where('tasks.organization_id', $organizationId) + ->orderBy('tasks.created_at', 'DESC') + ->findAll(); + } + + /** + * Получить задачи, сгруппированные по колонкам (для Канбана) + */ + public function getTasksGroupedByColumn(int $boardId): array + { + $tasks = $this->select('tasks.*, tc.name as column_name, tc.color as column_color') + ->join('task_columns tc', 'tasks.column_id = tc.id', 'left') + ->where('tasks.board_id', $boardId) + ->orderBy('tc.order_index', 'ASC') + ->orderBy('tasks.order_index', 'ASC') + ->orderBy('tasks.created_at', 'DESC') + ->findAll(); + + $grouped = []; + foreach ($tasks as $task) { + $columnId = $task['column_id'] ?? 0; + if (!isset($grouped[$columnId])) { + $grouped[$columnId] = [ + 'column_name' => $task['column_name'] ?? 'Без колонки', + 'column_color' => $task['column_color'] ?? '#6B7280', + 'tasks' => [], + ]; + } + $grouped[$columnId]['tasks'][] = $task; + } + + return $grouped; + } + + /** + * Получить задачи для календаря + */ + public function getTasksForCalendar(int $organizationId, string $month): array + { + return $this->select('tasks.*, tc.color as column_color, tc.name as column_name') + ->join('task_columns tc', 'tasks.column_id = tc.id', 'left') + ->where('tasks.organization_id', $organizationId) + ->where('tasks.due_date >=', date('Y-m-01', strtotime($month))) + ->where('tasks.due_date <=', date('Y-m-t', strtotime($month))) + ->where('tasks.completed_at', null) + ->orderBy('tasks.due_date', 'ASC') + ->findAll(); + } + + /** + * Получить задачу по ID + */ + public function getTask(int $taskId, int $organizationId): ?array + { + return $this->select('tasks.*, tc.name as column_name, tc.color as column_color') + ->join('task_columns tc', 'tasks.column_id = tc.id', 'left') + ->where('tasks.id', $taskId) + ->where('tasks.organization_id', $organizationId) + ->first(); + } + + /** + * Получить статистику по задачам + */ + public function getTaskStats(int $organizationId): array + { + $total = $this->select('COUNT(*) as count') + ->where('organization_id', $organizationId) + ->countAllResults(); + + $completed = $this->select('COUNT(*) as count') + ->where('organization_id', $organizationId) + ->where('completed_at IS NOT NULL') + ->countAllResults(); + + $overdue = $this->select('COUNT(*) as count') + ->where('organization_id', $organizationId) + ->where('completed_at', null) + ->where('due_date <', date('Y-m-d')) + ->countAllResults(); + + return [ + 'total' => $total, + 'completed' => $completed, + 'overdue' => $overdue, + 'pending' => $total - $completed, + ]; + } +} diff --git a/app/Modules/Tasks/Services/TaskBoardService.php b/app/Modules/Tasks/Services/TaskBoardService.php new file mode 100644 index 0000000..f0013fe --- /dev/null +++ b/app/Modules/Tasks/Services/TaskBoardService.php @@ -0,0 +1,126 @@ +boardModel = new TaskBoardModel(); + $this->columnModel = new TaskColumnModel(); + } + + /** + * Создать новую доску с дефолтными колонками + */ + public function createBoard(array $data, int $userId): int + { + $boardId = $this->boardModel->insert($data); + + if ($boardId) { + // Создаём дефолтные колонки + $this->columnModel->createDefaultColumns($boardId); + } + + return $boardId; + } + + /** + * Обновить доску + */ + public function updateBoard(int $boardId, array $data, int $organizationId): bool + { + $board = $this->boardModel->getBoard($boardId, $organizationId); + if (!$board) { + return false; + } + + return $this->boardModel->update($boardId, $data); + } + + /** + * Удалить доску + */ + public function deleteBoard(int $boardId, int $organizationId): bool + { + $board = $this->boardModel->getBoard($boardId, $organizationId); + if (!$board) { + return false; + } + + return $this->boardModel->delete($boardId); + } + + /** + * Получить доску с колонками + */ + public function getBoardWithColumns(int $boardId, int $organizationId): ?array + { + $board = $this->boardModel->getBoard($boardId, $organizationId); + if (!$board) { + return null; + } + + $board['columns'] = $this->columnModel->getColumnsByBoard($boardId); + + return $board; + } + + /** + * Получить все доски организации + */ + public function getOrganizationBoards(int $organizationId): array + { + return $this->boardModel->getBoardsByOrganization($organizationId); + } + + /** + * Создать колонку + */ + public function createColumn(int $boardId, array $data): int + { + $data['board_id'] = $boardId; + $data['order_index'] = $this->columnModel->getNextOrderIndex($boardId); + + return $this->columnModel->insert($data); + } + + /** + * Обновить колонку + */ + public function updateColumn(int $columnId, array $data): bool + { + return $this->columnModel->update($columnId, $data); + } + + /** + * Удалить колонку + */ + public function deleteColumn(int $columnId, int $boardId): bool + { + $column = $this->columnModel->find($columnId); + if (!$column || $column['board_id'] !== $boardId) { + return false; + } + + return $this->columnModel->delete($columnId); + } + + /** + * Изменить порядок колонок + */ + public function reorderColumns(array $columnOrders): bool + { + foreach ($columnOrders as $index => $columnId) { + $this->columnModel->update($columnId, ['order_index' => $index]); + } + + return true; + } +} diff --git a/app/Modules/Tasks/Services/TaskService.php b/app/Modules/Tasks/Services/TaskService.php new file mode 100644 index 0000000..0005394 --- /dev/null +++ b/app/Modules/Tasks/Services/TaskService.php @@ -0,0 +1,267 @@ +taskModel = new TaskModel(); + $this->assigneeModel = new TaskAssigneeModel(); + $this->columnModel = new TaskColumnModel(); + } + + /** + * Получить модель задач для использования в DataTable + */ + public function getModel(): TaskModel + { + return $this->taskModel; + } + + /** + * Создать новую задачу + */ + public function createTask(array $data, int $userId, array $assigneeIds = []): int + { + $data['created_by'] = $userId; + + $taskId = $this->taskModel->insert($data); + + if ($taskId) { + // Добавляем исполнителей + foreach ($assigneeIds as $userId) { + $this->assigneeModel->addAssignee($taskId, (int)$userId); + } + + // Генерируем событие + Events::trigger('tasks.created', $taskId, $data, $userId); + } + + return $taskId; + } + + /** + * Обновить задачу + */ + public function updateTask(int $taskId, array $data, int $userId): bool + { + $oldTask = $this->taskModel->find($taskId); + if (!$oldTask) { + return false; + } + + $result = $this->taskModel->update($taskId, $data); + + if ($result) { + Events::trigger('tasks.updated', $taskId, $data, $userId); + } + + return $result; + } + + /** + * Изменить колонку задачи (drag-and-drop) + */ + public function changeColumn(int $taskId, int $newColumnId, int $userId): bool + { + $task = $this->taskModel->find($taskId); + if (!$task) { + return false; + } + + $newColumn = $this->columnModel->find($newColumnId); + if (!$newColumn) { + return false; + } + + $oldColumnId = $task['column_id']; + + $data = ['column_id' => $newColumnId]; + + // Если задача завершена (новая колонка не "Завершено"), очищаем completed_at + if ($newColumn['name'] !== 'Завершено') { + $data['completed_at'] = null; + } + + $result = $this->taskModel->update($taskId, $data); + + if ($result) { + Events::trigger('tasks.column_changed', $taskId, $oldColumnId, $newColumnId, $userId); + } + + return $result; + } + + /** + * Завершить задачу + */ + public function completeTask(int $taskId, int $userId): bool + { + $task = $this->taskModel->find($taskId); + if (!$task) { + return false; + } + + $result = $this->taskModel->update($taskId, [ + 'completed_at' => date('Y-m-d H:i:s'), + ]); + + if ($result) { + Events::trigger('tasks.completed', $taskId, $userId); + } + + return $result; + } + + /** + * Возобновить задачу + */ + public function reopenTask(int $taskId, int $userId): bool + { + $task = $this->taskModel->find($taskId); + if (!$task) { + return false; + } + + $result = $this->taskModel->update($taskId, [ + 'completed_at' => null, + ]); + + if ($result) { + Events::trigger('tasks.reopened', $taskId, $userId); + } + + return $result; + } + + /** + * Удалить задачу + */ + public function deleteTask(int $taskId, int $userId): bool + { + $task = $this->taskModel->find($taskId); + if (!$task) { + return false; + } + + $result = $this->taskModel->delete($taskId); + + if ($result) { + Events::trigger('tasks.deleted', $taskId, $userId); + } + + return $result; + } + + /** + * Получить задачу по ID + */ + public function getTask(int $taskId, int $organizationId): ?array + { + $task = $this->taskModel->getTask($taskId, $organizationId); + if (!$task) { + return null; + } + + $task['assignees'] = $this->assigneeModel->getAssigneesByTask($taskId); + + return $task; + } + + /** + * Получить задачи для Канбана + */ + public function getTasksForKanban(int $boardId): array + { + return $this->taskModel->getTasksGroupedByColumn($boardId); + } + + /** + * Получить задачи для календаря + */ + public function getTasksForCalendar(int $organizationId, string $month): array + { + return $this->taskModel->getTasksForCalendar($organizationId, $month); + } + + /** + * Получить статистику + */ + public function getStats(int $organizationId): array + { + return $this->taskModel->getTaskStats($organizationId); + } + + /** + * Обновить исполнителей задачи + */ + public function updateAssignees(int $taskId, array $userIds): bool + { + // Удаляем старых исполнителей + $this->assigneeModel->where('task_id', $taskId)->delete(); + + // Добавляем новых + foreach ($userIds as $userId) { + $this->assigneeModel->addAssignee($taskId, (int)$userId); + } + + return true; + } + + /** + * Создать задачу из события (например, из сделки CRM) + */ + public function createFromEvent(string $eventType, array $eventData, int $organizationId): ?int + { + $taskData = [ + 'organization_id' => $organizationId, + 'board_id' => $this->getDefaultBoardId($organizationId), + 'column_id' => $this->getFirstColumnId($organizationId), + 'title' => $eventData['title'] ?? 'Задача', + 'description' => $eventData['description'] ?? '', + 'priority' => $eventData['priority'] ?? 'medium', + 'due_date' => $eventData['due_date'] ?? null, + ]; + + $assignees = $eventData['assignees'] ?? []; + + return $this->createTask($taskData, $eventData['created_by'] ?? 1, $assignees); + } + + /** + * Получить ID дефолтной доски + */ + protected function getDefaultBoardId(int $organizationId): int + { + $boardModel = new TaskBoardModel(); + $board = $boardModel->getDefaultBoard($organizationId); + + if (!$board) { + // Создаём дефолтную доску + return $boardModel->createDefaultBoard($organizationId); + } + + return $board['id']; + } + + /** + * Получить ID первой колонки + */ + protected function getFirstColumnId(int $organizationId): int + { + $boardId = $this->getDefaultBoardId($organizationId); + $columns = $this->columnModel->getColumnsByBoard($boardId); + + return $columns[0]['id'] ?? 1; + } +} diff --git a/app/Modules/Tasks/Views/tasks/calendar.twig b/app/Modules/Tasks/Views/tasks/calendar.twig new file mode 100644 index 0000000..4055096 --- /dev/null +++ b/app/Modules/Tasks/Views/tasks/calendar.twig @@ -0,0 +1,170 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+ +
+ +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +
+
+ +
{{ monthName }}
+ + Сегодня + +
+
+
+ {# Дни недели #} +
+ {% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %} +
{{ day }}
+ {% endfor %} +
+ + {# Календарная сетка #} +
+ {% set firstDay = firstDayOfWeek %} + {% set daysInMonth = daysInMonth %} + + {# Пустые ячейки до первого дня #} + {% for i in 0..(firstDay - 1) %} +
+ {% endfor %} + + {# Дни месяца #} + {% for day in 1..daysInMonth %} + {% set dateStr = currentMonth ~ '-' ~ (day < 10 ? '0' ~ day : day) %} + {% set isToday = dateStr == today %} + {% set isPast = dateStr < today %} + {% set dayEvents = eventsByDate[dateStr]|default([]) %} + +
+
+ {{ day }} + {% if dayEvents|length > 0 %} + {{ dayEvents|length }} + {% endif %} +
+ +
+ {% for event in dayEvents|slice(0, 3) %} + + + {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} + {% if event.priority == 'urgent' or event.priority == 'high' %} + + {% endif %} + + {% endfor %} + + {% if dayEvents|length > 3 %} +
+ +{{ dayEvents|length - 3 }} ещё +
+ {% endif %} +
+
+ {% endfor %} + + {# Пустые ячейки после последнего дня #} + {% set remaining = 7 - ((firstDay + daysInMonth) % 7) %} + {% if remaining < 7 %} + {% for i in 1..remaining %} +
+ {% endfor %} + {% endif %} +
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/form.twig b/app/Modules/Tasks/Views/tasks/form.twig new file mode 100644 index 0000000..21428ab --- /dev/null +++ b/app/Modules/Tasks/Views/tasks/form.twig @@ -0,0 +1,141 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+
+ +
+
+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Выберите нескольких исполнителей, удерживая Ctrl/Cmd
+
+ +
+ + Отмена +
+
+
+
+
+ +
+
+
+
Информация
+
+
+ {% if task %} +

Создано: {{ task.created_at|date('d.m.Y H:i') }}

+

Автор: {{ task.created_by_name|default('—') }}

+ {% if task.completed_at %} +

Завершено: {{ task.completed_at|date('d.m.Y H:i') }}

+ {% endif %} + {% else %} +

Заполните форму для создания новой задачи

+ {% endif %} +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/index.twig b/app/Modules/Tasks/Views/tasks/index.twig new file mode 100644 index 0000000..35f6b13 --- /dev/null +++ b/app/Modules/Tasks/Views/tasks/index.twig @@ -0,0 +1,72 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+ + Создать задачу + +
+ +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +{# Переключатель видов #} +
+ + Список + + + Канбан + + + Календарь + +
+ +{# Таблица задач #} +
+
+ {{ tableHtml|raw }} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/kanban.twig b/app/Modules/Tasks/Views/tasks/kanban.twig new file mode 100644 index 0000000..f8800df --- /dev/null +++ b/app/Modules/Tasks/Views/tasks/kanban.twig @@ -0,0 +1,199 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+
+ {# Выбор доски #} + + + + + + Добавить задачу + +
+
+ +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +{# Канбан доска #} +
+
+ {% for column in kanbanColumns %} +
+
+
+
{{ column.name }}
+ {{ column.items|length }} +
+
+ + {% for item in column.items %} +
+
+
+
{{ item.title }}
+ {% if item.priority == 'urgent' %} + Срочно + {% elseif item.priority == 'high' %} + Высокий + {% endif %} +
+ + {% if item.description %} +

+ {{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }} +

+ {% endif %} + +
+ {% if item.due_date %} + + + {{ item.due_date|date('d.m') }} + {% if item.due_date < date('Y-m-d') %} + ! + {% endif %} + + {% endif %} + + + +
+
+
+ {% endfor %} + + {# Кнопка добавления #} + + Добавить задачу + +
+
+
+ {% endfor %} +
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/show.twig b/app/Modules/Tasks/Views/tasks/show.twig new file mode 100644 index 0000000..d7306da --- /dev/null +++ b/app/Modules/Tasks/Views/tasks/show.twig @@ -0,0 +1,180 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+ +
+ +
+
+
+
+
+ {% if task.priority == 'urgent' %} + Срочно + {% elseif task.priority == 'high' %} + Высокий + {% elseif task.priority == 'low' %} + Низкий + {% endif %} + {{ task.title }} +
+
+ {% if not task.completed_at %} +
+ {{ csrf_field()|raw }} + +
+ {% else %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} + + Редактировать + +
+
+
+
+
+

Статус:

+ + {{ task.column_name|default('—') }} + +
+
+

Приоритет:

+ + {{ task.priorityLabels[task.priority]|default(task.priority) }} + +
+
+ + {% if task.description %} +
+

Описание:

+

{{ task.description|nl2br }}

+
+ {% endif %} + + {% if task.assignees %} +
+

Исполнители:

+
+ {% for assignee in task.assignees %} + + + {{ assignee.user_name|default(assignee.user_email) }} + {% if assignee.role == 'watcher' %} + (наблюдатель) + {% endif %} + + {% endfor %} +
+
+ {% endif %} +
+
+ + {# Комментарии #} +
+
+
Комментарии
+
+
+

Комментарии будут доступны в следующей версии

+
+
+
+ +
+
+
+
Детали
+
+
+

+ + Срок: + {% if task.due_date %} + {{ task.due_date|date('d.m.Y') }} + {% if task.due_date < date('now') and not task.completed_at %} + (просрочено) + {% endif %} + {% else %} + не указан + {% endif %} +

+

+ + Автор: {{ task.created_by_name|default('—') }} +

+

+ + Создано: {{ task.created_at|date('d.m.Y H:i') }} +

+ {% if task.completed_at %} +

+ + Завершено: {{ task.completed_at|date('d.m.Y H:i') }} +

+ {% endif %} +
+
+ +
+
+
Действия
+
+
+
+ {% if not task.completed_at %} +
+ {{ csrf_field()|raw }} + +
+ {% else %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} + + Редактировать + +
+ {{ csrf_field()|raw }} + +
+
+
+
+
+
+{% endblock %}