start tasks module
This commit is contained in:
parent
283b9132a0
commit
725c62a179
|
|
@ -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';
|
||||
});
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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'], // Модуль Задачи
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateTaskBoardsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateTaskColumnsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateTasksTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateTaskAssigneesTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Tasks Module Events Bootstrap
|
||||
*
|
||||
* Регистрация обработчиков событий для модуля Tasks.
|
||||
* Слушает события из CRM модуля для автоматизации создания задач.
|
||||
*/
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Modules\Tasks\Services\TaskService;
|
||||
|
||||
// Выполняется после инициализации всех модулей
|
||||
Events::on('post_system', function () {
|
||||
|
||||
/**
|
||||
* Обработчик создания сделки
|
||||
* Создаёт задачу "Проверить новую сделку" при создании сделки
|
||||
*/
|
||||
Events::on('deal.created', function (array $data) {
|
||||
$taskService = service('taskService');
|
||||
|
||||
// Создаём задачу для ответственного менеджера
|
||||
$taskService->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; // По умолчанию не создаём задачу
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
// Tasks Module Routes
|
||||
|
||||
$routes->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');
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Controllers;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Modules\Tasks\Models\TaskColumnModel;
|
||||
|
||||
class TaskApiController extends BaseController
|
||||
{
|
||||
protected TaskColumnModel $columnModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Controllers;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Modules\Tasks\Services\TaskService;
|
||||
use App\Modules\Tasks\Services\TaskBoardService;
|
||||
use App\Models\OrganizationUserModel;
|
||||
|
||||
class TasksController extends BaseController
|
||||
{
|
||||
protected TaskService $taskService;
|
||||
protected TaskBoardService $boardService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class TaskAssigneeModel extends Model
|
||||
{
|
||||
protected $table = 'task_assignees';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'task_id',
|
||||
'user_id',
|
||||
'role',
|
||||
'assigned_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Получить всех исполнителей задачи
|
||||
*/
|
||||
public function getAssigneesByTask(int $taskId): array
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
use App\Models\Traits\TenantScopedModel;
|
||||
|
||||
class TaskBoardModel extends Model
|
||||
{
|
||||
use TenantScopedModel;
|
||||
|
||||
protected $table = 'task_boards';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $tenantField = 'organization_id';
|
||||
protected $allowedFields = [
|
||||
'organization_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_default',
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
/**
|
||||
* Получить все доски организации
|
||||
*/
|
||||
public function getBoardsByOrganization(int $organizationId): array
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
use App\Models\Traits\TenantScopedModel;
|
||||
|
||||
class TaskColumnModel extends Model
|
||||
{
|
||||
use TenantScopedModel;
|
||||
|
||||
protected $table = 'task_columns';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $tenantField = 'organization_id';
|
||||
protected $allowedFields = [
|
||||
'board_id',
|
||||
'name',
|
||||
'color',
|
||||
'order_index',
|
||||
'is_default',
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
/**
|
||||
* Получить колонки доски
|
||||
*/
|
||||
public function getColumnsByBoard(int $boardId): array
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
use App\Models\Traits\TenantScopedModel;
|
||||
|
||||
class TaskModel extends Model
|
||||
{
|
||||
use TenantScopedModel;
|
||||
|
||||
protected $table = 'tasks';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $returnType = 'array';
|
||||
protected $tenantField = 'organization_id';
|
||||
protected $allowedFields = [
|
||||
'organization_id',
|
||||
'board_id',
|
||||
'column_id',
|
||||
'title',
|
||||
'description',
|
||||
'priority',
|
||||
'due_date',
|
||||
'completed_at',
|
||||
'order_index',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* Получить задачи с JOIN-ами для таблицы
|
||||
*/
|
||||
public function getForTable(int $organizationId): array
|
||||
{
|
||||
return $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Services;
|
||||
|
||||
use App\Modules\Tasks\Models\TaskBoardModel;
|
||||
use App\Modules\Tasks\Models\TaskColumnModel;
|
||||
|
||||
class TaskBoardService
|
||||
{
|
||||
protected TaskBoardModel $boardModel;
|
||||
protected TaskColumnModel $columnModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Tasks\Services;
|
||||
|
||||
use App\Modules\Tasks\Models\TaskModel;
|
||||
use App\Modules\Tasks\Models\TaskAssigneeModel;
|
||||
use App\Modules\Tasks\Models\TaskColumnModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
|
||||
class TaskService
|
||||
{
|
||||
protected TaskModel $taskModel;
|
||||
protected TaskAssigneeModel $assigneeModel;
|
||||
protected TaskColumnModel $columnModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<div class="btn-group">
|
||||
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fa-solid fa-list me-2"></i>Список
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-table-columns me-2"></i>Канбан
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Статистика #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Всего</h5>
|
||||
<h2 class="mb-0">{{ stats.total }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Выполнено</h5>
|
||||
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">В ожидании</h5>
|
||||
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Просрочено</h5>
|
||||
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group">
|
||||
<a href="{{ base_url('/tasks/calendar?month=' ~ prevMonth) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/calendar?month=' ~ nextMonth) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<h5 class="mb-0">{{ monthName }}</h5>
|
||||
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-secondary btn-sm">
|
||||
Сегодня
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="calendar-container">
|
||||
{# Дни недели #}
|
||||
<div class="calendar-weekdays mb-2">
|
||||
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
|
||||
<div class="calendar-weekday text-center text-muted fw-bold" style="flex: 1;">{{ day }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Календарная сетка #}
|
||||
<div class="calendar-grid">
|
||||
{% set firstDay = firstDayOfWeek %}
|
||||
{% set daysInMonth = daysInMonth %}
|
||||
|
||||
{# Пустые ячейки до первого дня #}
|
||||
{% for i in 0..(firstDay - 1) %}
|
||||
<div class="calendar-day p-2 border bg-light"></div>
|
||||
{% 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([]) %}
|
||||
|
||||
<div class="calendar-day p-2 border {% if isToday %}bg-primary bg-opacity-10{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="calendar-day-number fw-bold {% if isToday %}text-primary{% endif %}">{{ day }}</span>
|
||||
{% if dayEvents|length > 0 %}
|
||||
<span class="badge bg-primary" style="font-size: 0.6rem;">{{ dayEvents|length }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="calendar-events">
|
||||
{% for event in dayEvents|slice(0, 3) %}
|
||||
<a href="{{ base_url(event.url) }}"
|
||||
class="calendar-event d-block text-decoration-none mb-1 px-1 py-1 rounded small"
|
||||
style="font-size: 0.75rem; background-color: {{ event.column_color }}20; border-left: 3px solid {{ event.column_color }}; color: #333;"
|
||||
title="{{ event.title }}">
|
||||
<i class="fa-solid fa-circle me-1" style="font-size: 0.5rem; color: {{ event.column_color }};"></i>
|
||||
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
|
||||
{% if event.priority == 'urgent' or event.priority == 'high' %}
|
||||
<i class="fa-solid fa-flag text-danger ms-1" style="font-size: 0.5rem;"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if dayEvents|length > 3 %}
|
||||
<div class="text-muted small text-center">
|
||||
+{{ dayEvents|length - 3 }} ещё
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Пустые ячейки после последнего дня #}
|
||||
{% set remaining = 7 - ((firstDay + daysInMonth) % 7) %}
|
||||
{% if remaining < 7 %}
|
||||
{% for i in 1..remaining %}
|
||||
<div class="calendar-day p-2 border bg-light"></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: #dee2e6;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 100px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.calendar-day.bg-light {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.calendar-event:hover {
|
||||
background-color: {{ event.column_color }}40 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Навигация по месяцам при клике на заголовок
|
||||
document.querySelector('h5.mb-0').style.cursor = 'pointer';
|
||||
document.querySelector('h5.mb-0').addEventListener('click', function() {
|
||||
window.location.href = '{{ base_url('/tasks/calendar') }}';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form action="{{ actionUrl }}" method="post" id="task-form">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название *</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required
|
||||
value="{{ task.title|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4">{{ task.description|default('') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="board_id" class="form-label">Доска</label>
|
||||
<select class="form-select" id="board_id" name="board_id" required>
|
||||
{% for board in boards %}
|
||||
<option value="{{ board.id }}" {{ (task.board_id|default(selectedBoard) == board.id) ? 'selected' : '' }}>
|
||||
{{ board.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="column_id" class="form-label">Статус</label>
|
||||
<select class="form-select" id="column_id" name="column_id" required>
|
||||
{# Заполняется через JS #}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="priority" class="form-label">Приоритет</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
{% for value, label in priorities %}
|
||||
<option value="{{ value }}" {{ (task.priority|default('medium') == value) ? 'selected' : '' }}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="due_date" class="form-label">Срок выполнения</label>
|
||||
<input type="date" class="form-control" id="due_date" name="due_date"
|
||||
value="{{ task.due_date|default('') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Исполнители</label>
|
||||
<select class="form-select" id="assignees" name="assignees[]" multiple>
|
||||
{% for userId, userName in users %}
|
||||
<option value="{{ userId }}" {% if task.assignees and userId in task.assignees|map(a => a.user_id) %}selected{% endif %}>
|
||||
{{ userName }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Выберите нескольких исполнителей, удерживая Ctrl/Cmd</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa-solid fa-check me-2"></i>Сохранить
|
||||
</button>
|
||||
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if task %}
|
||||
<p class="mb-1"><strong>Создано:</strong> {{ task.created_at|date('d.m.Y H:i') }}</p>
|
||||
<p class="mb-1"><strong>Автор:</strong> {{ task.created_by_name|default('—') }}</p>
|
||||
{% if task.completed_at %}
|
||||
<p class="mb-1 text-success"><strong>Завершено:</strong> {{ task.completed_at|date('d.m.Y H:i') }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Заполните форму для создания новой задачи</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Загрузка колонок при изменении доски
|
||||
const boardSelect = document.getElementById('board_id');
|
||||
const columnSelect = document.getElementById('column_id');
|
||||
|
||||
function loadColumns(boardId, selectedColumnId) {
|
||||
fetch(`/api/tasks/columns?board_id=${boardId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
columnSelect.innerHTML = '';
|
||||
data.columns.forEach(column => {
|
||||
const option = document.createElement('option');
|
||||
option.value = column.id;
|
||||
option.textContent = column.name;
|
||||
if (column.id == selectedColumnId) {
|
||||
option.selected = true;
|
||||
}
|
||||
columnSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const currentColumnId = {{ task.column_id|default(0) }};
|
||||
loadColumns(boardSelect.value, currentColumnId || null);
|
||||
|
||||
boardSelect.addEventListener('change', function() {
|
||||
loadColumns(this.value, null);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<a href="{{ base_url('/tasks/new') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Создать задачу
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Статистика #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Всего</h5>
|
||||
<h2 class="mb-0">{{ stats.total }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Выполнено</h5>
|
||||
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">В ожидании</h5>
|
||||
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Просрочено</h5>
|
||||
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Переключатель видов #}
|
||||
<div class="btn-group mb-4" role="group">
|
||||
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-primary active">
|
||||
<i class="fa-solid fa-list me-2"></i>Список
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-table-columns me-2"></i>Канбан
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Таблица задач #}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{{ tableHtml|raw }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ base_url('assets/js/modules/DataTable.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
{# Выбор доски #}
|
||||
<select class="form-select" style="width: 200px" onchange="window.location.href = '{{ base_url('/tasks/kanban?board=') }}' + this.value">
|
||||
{% for b in boards %}
|
||||
<option value="{{ b.id }}" {{ board.id == b.id ? 'selected' : '' }}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="btn-group">
|
||||
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fa-solid fa-list me-2"></i>Список
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{{ base_url('/tasks/new?board=' ~ board.id) }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить задачу
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Статистика #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Всего</h5>
|
||||
<h2 class="mb-0">{{ stats.total }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Выполнено</h5>
|
||||
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">В ожидании</h5>
|
||||
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted mb-0">Просрочено</h5>
|
||||
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Канбан доска #}
|
||||
<div class="kanban-container" style="overflow-x: auto; padding-bottom: 1rem;">
|
||||
<div class="d-flex gap-3" style="min-width: max-content;">
|
||||
{% for column in kanbanColumns %}
|
||||
<div class="kanban-column" style="width: 320px; min-width: 320px;">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: {{ column.color }}; color: white;">
|
||||
<h6 class="mb-0 fw-bold">{{ column.name }}</h6>
|
||||
<span class="badge bg-light text-dark">{{ column.items|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-2 kanban-items" data-column-id="{{ column.id }}"
|
||||
style="min-height: 400px; max-height: 600px; overflow-y: auto;">
|
||||
|
||||
{% for item in column.items %}
|
||||
<div class="card mb-2 kanban-item" data-task-id="{{ item.id }}" style="cursor: grab;">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="card-title mb-0">{{ item.title }}</h6>
|
||||
{% if item.priority == 'urgent' %}
|
||||
<span class="badge bg-danger" style="font-size: 0.6rem;">Срочно</span>
|
||||
{% elseif item.priority == 'high' %}
|
||||
<span class="badge bg-warning text-dark" style="font-size: 0.6rem;">Высокий</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.description %}
|
||||
<p class="card-text text-muted small mb-2">
|
||||
{{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
{% if item.due_date %}
|
||||
<small class="text-muted">
|
||||
<i class="fa-regular fa-calendar me-1"></i>
|
||||
{{ item.due_date|date('d.m') }}
|
||||
{% if item.due_date < date('Y-m-d') %}
|
||||
<span class="text-danger">!</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
<a href="{{ base_url('/tasks/' ~ item.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Кнопка добавления #}
|
||||
<a href="{{ base_url('/tasks/new?board=' ~ board.id ~ '&column=' ~ column.id) }}" class="btn btn-outline-secondary btn-sm w-100 mt-2">
|
||||
<i class="fa-solid fa-plus me-1"></i>Добавить задачу
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="move-task-form" action="{{ base_url('/tasks/move-column') }}" method="post" style="display: none;">
|
||||
{{ csrf_field|raw }}
|
||||
<input type="hidden" name="task_id" id="move-task-id">
|
||||
<input type="hidden" name="column_id" id="move-column-id">
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Drag and drop для Канбана
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const columns = document.querySelectorAll('.kanban-items');
|
||||
let draggedItem = null;
|
||||
|
||||
columns.forEach(column => {
|
||||
column.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.backgroundColor = '#f8f9fa';
|
||||
});
|
||||
|
||||
column.addEventListener('dragleave', function() {
|
||||
this.style.backgroundColor = '';
|
||||
});
|
||||
|
||||
column.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.backgroundColor = '';
|
||||
|
||||
if (draggedItem && draggedItem.dataset.taskId) {
|
||||
const taskId = draggedItem.dataset.taskId;
|
||||
const newColumnId = this.dataset.columnId;
|
||||
|
||||
// Отправляем запрос на перемещение
|
||||
document.getElementById('move-task-id').value = taskId;
|
||||
document.getElementById('move-column-id').value = newColumnId;
|
||||
|
||||
fetch('{{ base_url('/tasks/move-column') }}', {
|
||||
method: 'POST',
|
||||
body: new FormData(document.getElementById('move-task-form'))
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Перезагружаем страницу для обновления
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка при перемещении задачи');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка при перемещении задачи');
|
||||
});
|
||||
}
|
||||
|
||||
draggedItem = null;
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.kanban-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function() {
|
||||
draggedItem = this;
|
||||
this.style.opacity = '0.5';
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', function() {
|
||||
this.style.opacity = '1';
|
||||
draggedItem = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<div class="btn-group">
|
||||
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fa-solid fa-list me-2"></i>Список
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-table-columns me-2"></i>Канбан
|
||||
</a>
|
||||
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
{% if task.priority == 'urgent' %}
|
||||
<span class="badge bg-danger me-2">Срочно</span>
|
||||
{% elseif task.priority == 'high' %}
|
||||
<span class="badge bg-warning text-dark me-2">Высокий</span>
|
||||
{% elseif task.priority == 'low' %}
|
||||
<span class="badge bg-secondary me-2">Низкий</span>
|
||||
{% endif %}
|
||||
{{ task.title }}
|
||||
</h5>
|
||||
<div>
|
||||
{% if not task.completed_at %}
|
||||
<form action="{{ base_url('/tasks/' ~ task.id ~ '/complete') }}" method="post" class="d-inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
<i class="fa-solid fa-check me-1"></i>Завершить
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="{{ base_url('/tasks/' ~ task.id ~ '/reopen') }}" method="post" class="d-inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fa-solid fa-rotate-left me-1"></i>Вернуть в работу
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ base_url('/tasks/' ~ task.id ~ '/edit') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fa-solid fa-pen me-1"></i>Редактировать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<p class="mb-1"><strong>Статус:</strong></p>
|
||||
<span class="badge" style="background-color: {{ task.column_color|default('#6B7280') }}">
|
||||
{{ task.column_name|default('—') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="mb-1"><strong>Приоритет:</strong></p>
|
||||
<span class="badge {% if task.priority == 'urgent' %}bg-danger{% elseif task.priority == 'high' %}bg-warning text-dark{% elseif task.priority == 'low' %}bg-secondary{% else %}bg-info{% endif %}">
|
||||
{{ task.priorityLabels[task.priority]|default(task.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.description %}
|
||||
<div class="mb-3">
|
||||
<p class="mb-1"><strong>Описание:</strong></p>
|
||||
<p class="text-muted">{{ task.description|nl2br }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.assignees %}
|
||||
<div class="mb-3">
|
||||
<p class="mb-1"><strong>Исполнители:</strong></p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for assignee in task.assignees %}
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="fa-solid fa-user me-1"></i>
|
||||
{{ assignee.user_name|default(assignee.user_email) }}
|
||||
{% if assignee.role == 'watcher' %}
|
||||
(наблюдатель)
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Комментарии #}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Комментарии</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted text-center">Комментарии будут доступны в следующей версии</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Детали</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<i class="fa-regular fa-calendar me-2 text-muted"></i>
|
||||
<strong>Срок:</strong>
|
||||
{% if task.due_date %}
|
||||
{{ task.due_date|date('d.m.Y') }}
|
||||
{% if task.due_date < date('now') and not task.completed_at %}
|
||||
<span class="text-danger">(просрочено)</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
не указан
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="fa-regular fa-user me-2 text-muted"></i>
|
||||
<strong>Автор:</strong> {{ task.created_by_name|default('—') }}
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="fa-regular fa-clock me-2 text-muted"></i>
|
||||
<strong>Создано:</strong> {{ task.created_at|date('d.m.Y H:i') }}
|
||||
</p>
|
||||
{% if task.completed_at %}
|
||||
<p class="mb-0 text-success">
|
||||
<i class="fa-solid fa-check me-2"></i>
|
||||
<strong>Завершено:</strong> {{ task.completed_at|date('d.m.Y H:i') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">Действия</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% if not task.completed_at %}
|
||||
<form action="{{ base_url('/tasks/' ~ task.id ~ '/complete') }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="fa-solid fa-check me-2"></i>Отметить как выполненное
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="{{ base_url('/tasks/' ~ task.id ~ '/reopen') }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="btn btn-outline-secondary w-100">
|
||||
<i class="fa-solid fa-rotate-left me-2"></i>Вернуть в работу
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ base_url('/tasks/' ~ task.id ~ '/edit') }}" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-pen me-2"></i>Редактировать
|
||||
</a>
|
||||
<form action="{{ base_url('/tasks/' ~ task.id ~ '/delete') }}" method="post"
|
||||
onsubmit="return confirm('Вы уверены, что хотите удалить задачу?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="btn btn-outline-danger w-100">
|
||||
<i class="fa-solid fa-trash me-2"></i>Удалить задачу
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue