bp/app/Modules/CRM/Controllers/DealsController.php

533 lines
20 KiB
PHP

<?php
namespace App\Modules\CRM\Controllers;
use App\Controllers\BaseController;
use App\Modules\CRM\Models\DealModel;
use App\Modules\CRM\Models\DealStageModel;
use App\Modules\CRM\Services\DealService;
use App\Modules\CRM\Services\DealStageService;
use App\Modules\CRM\Models\ContactModel;
use App\Modules\Clients\Models\ClientModel;
class DealsController extends BaseController
{
protected DealService $dealService;
protected DealStageService $stageService;
protected DealModel $dealModel;
protected DealStageModel $stageModel;
protected ContactModel $contactModel;
protected ClientModel $clientModel;
public function __construct()
{
$this->dealService = new DealService();
$this->stageService = new DealStageService();
$this->dealModel = new DealModel();
$this->stageModel = new DealStageModel();
$this->contactModel = new ContactModel();
$this->clientModel = new ClientModel();
}
/**
* Главная страница - список сделок (использует DataTable)
*/
public function index()
{
$organizationId = $this->requireActiveOrg();
return $this->renderTwig('@CRM/deals/index', [
'title' => 'Сделки',
'tableHtml' => $this->renderTable($this->getTableConfig()),
'stats' => $this->dealService->getStats($organizationId),
]);
}
/**
* AJAX endpoint для таблицы сделок
*/
public function table(?array $config = null, ?string $pageUrl = null)
{
return parent::table($this->getTableConfig(), '/crm/deals');
}
/**
* Конфигурация таблицы сделок для DataTable
*/
protected function getTableConfig(): array
{
$organizationId = $this->getActiveOrgId();
return [
'id' => 'deals-table',
'url' => '/crm/deals/table',
'model' => $this->dealModel,
'columns' => [
'title' => [
'label' => 'Сделка',
'width' => '30%',
],
'stage_name' => [
'label' => 'Этап',
'width' => '15%',
],
'amount' => [
'label' => 'Сумма',
'width' => '15%',
],
'client_name' => [
'label' => 'Клиент',
'width' => '20%',
],
'expected_close_date' => [
'label' => 'Срок',
'width' => '10%',
],
],
'searchable' => ['title', 'stage_name', 'client_name', 'amount'],
'sortable' => ['title', 'amount', 'expected_close_date', 'created_at', 'stage_name'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '10%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/crm/deals/{id}',
'icon' => 'fa-solid fa-eye',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Просмотр',
],
[
'label' => '',
'url' => '/crm/deals/{id}/edit',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Редактировать',
'type' => 'edit',
],
],
'emptyMessage' => 'Сделок пока нет',
'emptyIcon' => 'fa-solid fa-file-contract',
'emptyActionUrl' => '/crm/deals/new',
'emptyActionLabel' => 'Создать сделку',
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => true,
'can_delete' => true,
// Field map for joined fields
'fieldMap' => [
'stage_name' => 'ds.name',
'client_name' => 'oc.name',
'amount' => 'deals.amount',
],
// Custom scope for JOINs and filtering
'scope' => function($builder) use ($organizationId) {
$builder->from('deals')
->select('deals.id, deals.title, deals.amount, deals.currency, deals.expected_close_date, deals.created_at, deals.deleted_at, ds.name as stage_name, ds.color as stage_color, c.name as contact_name, oc.name as client_name, au.name as assigned_user_name, cb.name as created_by_name')
->join('deal_stages ds', 'deals.stage_id = ds.id', 'left')
->join('contacts c', 'deals.contact_id = c.id', 'left')
->join('organizations_clients oc', 'deals.company_id = oc.id', 'left')
->join('users au', 'deals.assigned_user_id = au.id', 'left')
->join('users cb', 'deals.created_by = cb.id', 'left')
->where('deals.organization_id', $organizationId)
->where('deals.deleted_at', null);
},
];
}
/**
* Канбан-доска сделок
*/
public function kanban()
{
$organizationId = $this->requireActiveOrg();
$stages = $this->stageService->getOrganizationStages($organizationId);
$kanbanData = $this->dealService->getDealsForKanban($organizationId);
// Формируем колонки для компонента
$kanbanColumns = [];
foreach ($stages as $stage) {
$stageDeals = $kanbanData[$stage['id']]['deals'] ?? [];
$kanbanColumns[] = [
'id' => $stage['id'],
'name' => $stage['name'],
'color' => $stage['color'],
'items' => $stageDeals,
'total' => $kanbanData[$stage['id']]['total_amount'] ?? 0,
];
}
return $this->renderTwig('@CRM/deals/kanban', [
'title' => 'Сделки — Канбан',
'kanbanColumns' => $kanbanColumns,
'stats' => $this->dealService->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;
$deals = $this->dealService->getDealsForCalendar($organizationId, $month);
$eventsByDate = [];
foreach ($deals as $deal) {
if ($deal['expected_close_date']) {
$dateKey = date('Y-m-d', strtotime($deal['expected_close_date']));
$eventsByDate[$dateKey][] = [
'id' => $deal['id'],
'title' => $deal['title'],
'date' => $deal['expected_close_date'],
'stage_color' => $deal['stage_color'] ?? '#6B7280',
'url' => '/crm/deals/' . $deal['id'],
];
}
}
// Формируем легенду из этапов
$stages = $this->stageService->getOrganizationStages($organizationId);
$calendarLegend = array_map(function ($stage) {
return [
'name' => $stage['name'],
'color' => $stage['color'],
];
}, $stages);
return $this->renderTwig('@CRM/deals/calendar', [
'title' => 'Сделки — Календарь',
'calendarEvents' => array_map(function ($deal) {
return [
'id' => $deal['id'],
'title' => $deal['title'],
'date' => $deal['expected_close_date'],
'stage_color' => $deal['stage_color'] ?? '#6B7280',
];
}, $deals),
'eventsByDate' => $eventsByDate,
'calendarLegend' => $calendarLegend,
'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'),
]);
}
/**
* Страница создания сделки
*/
public function create()
{
$organizationId = $this->requireActiveOrg();
$stageId = $this->request->getGet('stage_id');
// Получаем пользователей организации для поля "Ответственный"
$orgUserModel = new \App\Models\OrganizationUserModel();
$orgUsers = $orgUserModel->getOrganizationUsers($organizationId);
$users = [];
foreach ($orgUsers as $user) {
$users[$user['user_id']] = $user['user_name'] ?: $user['user_email'];
}
return $this->renderTwig('@CRM/deals/form', [
'title' => 'Новая сделка',
'actionUrl' => '/crm/deals',
'stages' => $this->stageService->getOrganizationStages($organizationId),
'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(),
'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(),
'users' => $users,
'stageId' => $stageId,
'currentUserId' => $this->getCurrentUserId(),
]);
}
/**
* Сохранить новую сделку
*/
public function store()
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$data = [
'organization_id' => $organizationId,
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount') ?? 0,
'currency' => $this->request->getPost('currency') ?? 'RUB',
'stage_id' => $this->request->getPost('stage_id'),
'contact_id' => $this->request->getPost('contact_id') ?: null,
'company_id' => $this->request->getPost('company_id') ?: null,
'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null,
'expected_close_date' => $this->request->getPost('expected_close_date') ?: null,
];
$dealId = $this->dealService->createDeal($data, $userId);
if ($dealId) {
return redirect()->to('/crm/deals')->with('success', 'Сделка успешно создана');
}
return redirect()->back()->with('error', 'Ошибка при создании сделки')->withInput();
}
/**
* Просмотр сделки
*/
public function show(int $id)
{
$organizationId = $this->requireActiveOrg();
$deal = $this->dealService->getDealWithJoins($id, $organizationId);
if (!$deal) {
return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена');
}
return $this->renderTwig('@CRM/deals/show', [
'title' => $deal['title'],
'deal' => (object) $deal,
]);
}
/**
* Страница редактирования сделки
*/
public function edit(int $id)
{
$organizationId = $this->requireActiveOrg();
$deal = $this->dealService->getDealWithJoins($id, $organizationId);
if (!$deal) {
return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена');
}
// Получаем пользователей организации для поля "Ответственный"
$orgUserModel = new \App\Models\OrganizationUserModel();
$orgUsers = $orgUserModel->getOrganizationUsers($organizationId);
$users = [];
foreach ($orgUsers as $user) {
$users[$user['user_id']] = $user['user_name'] ?: $user['user_email'];
}
return $this->renderTwig('@CRM/deals/form', [
'title' => 'Редактирование сделки',
'actionUrl' => "/crm/deals/{$id}",
'deal' => (object) $deal,
'stages' => $this->stageService->getOrganizationStages($organizationId),
'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(),
'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(),
'users' => $users,
'currentUserId' => $this->getCurrentUserId(),
]);
}
/**
* Обновить сделку
*/
public function update(int $id)
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$deal = $this->dealService->getDealWithJoins($id, $organizationId);
if (!$deal) {
return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена');
}
$data = [
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'amount' => $this->request->getPost('amount') ?? 0,
'currency' => $this->request->getPost('currency') ?? 'RUB',
'stage_id' => $this->request->getPost('stage_id'),
'contact_id' => $this->request->getPost('contact_id') ?: null,
'company_id' => $this->request->getPost('company_id') ?: null,
'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null,
'expected_close_date' => $this->request->getPost('expected_close_date') ?: null,
];
$result = $this->dealService->updateDeal($id, $data, $userId);
if ($result) {
return redirect()->to("/crm/deals/{$id}")->with('success', 'Сделка обновлена');
}
return redirect()->back()->with('error', 'Ошибка при обновлении сделки')->withInput();
}
/**
* Удалить сделку
*/
public function destroy(int $id)
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$deal = $this->dealService->getDealWithJoins($id, $organizationId);
if (!$deal) {
return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена');
}
$this->dealService->deleteDeal($id, $userId);
return redirect()->to('/crm/deals')->with('success', 'Сделка удалена');
}
/**
* API: перемещение сделки между этапами (drag-and-drop)
*/
public function moveStage()
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$dealId = $this->request->getPost('deal_id');
$newStageId = $this->request->getPost('stage_id');
$deal = $this->dealService->getDealWithJoins($dealId, $organizationId);
if (!$deal) {
return $this->response->setJSON(['success' => false, 'message' => 'Сделка не найдена']);
}
$result = $this->dealService->changeStage($dealId, $newStageId, $userId);
// Получаем новый CSRF токен для клиента
$csrfToken = csrf_hash();
$csrfHash = csrf_token();
return $this->response
->setHeader('X-CSRF-TOKEN', $csrfToken)
->setHeader('X-CSRF-HASH', $csrfHash)
->setJSON(['success' => $result]);
}
/**
* Управление этапами
*/
public function stages()
{
$organizationId = $this->requireActiveOrg();
$stages = $this->stageService->getOrganizationStages($organizationId);
return $this->renderTwig('@CRM/deals/stages', [
'title' => 'Этапы сделок',
'stages' => $stages,
]);
}
/**
* Создать этап
*/
public function storeStage()
{
$organizationId = $this->requireActiveOrg();
$data = [
'organization_id' => $organizationId,
'name' => $this->request->getPost('name'),
'color' => $this->request->getPost('color') ?? '#6B7280',
'type' => $this->request->getPost('type') ?? 'progress',
'probability' => $this->request->getPost('probability') ?? 0,
];
$stageId = $this->stageService->createStage($data);
if ($stageId) {
return redirect()->to('/crm/deals/stages')->with('success', 'Этап создан');
}
return redirect()->back()->with('error', 'Ошибка при создании этапа')->withInput();
}
/**
* Обновить этап
*/
public function updateStage(int $id)
{
$organizationId = $this->requireActiveOrg();
$stage = $this->stageService->getStage($id);
if (!$stage || $stage['organization_id'] !== $organizationId) {
return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден');
}
$data = [
'name' => $this->request->getPost('name'),
'color' => $this->request->getPost('color'),
'type' => $this->request->getPost('type'),
'probability' => $this->request->getPost('probability'),
];
$this->stageService->updateStage($id, $data);
return redirect()->to('/crm/deals/stages')->with('success', 'Этап обновлён');
}
/**
* Удалить этап
*/
public function destroyStage(int $id)
{
$organizationId = $this->requireActiveOrg();
$stage = $this->stageService->getStage($id);
if (!$stage || $stage['organization_id'] !== $organizationId) {
return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден');
}
if (!$this->stageService->canDeleteStage($id)) {
return redirect()->to('/crm/deals/stages')->with('error', 'Нельзя удалить этап, на котором есть сделки');
}
$this->stageService->deleteStage($id);
return redirect()->to('/crm/deals/stages')->with('success', 'Этап удалён');
}
/**
* API: получить контакты клиента
*/
public function getContactsByClient()
{
$organizationId = $this->requireActiveOrg();
$clientId = $this->request->getGet('client_id');
if (!$clientId) {
return $this->response->setJSON(['success' => true, 'contacts' => []]);
}
// Проверяем что клиент принадлежит организации
$client = $this->clientModel->where('organization_id', $organizationId)->find($clientId);
if (!$client) {
return $this->response->setJSON(['success' => false, 'message' => 'Клиент не найден']);
}
$contacts = $this->contactModel
->where('organization_id', $organizationId)
->where('customer_id', $clientId)
->findAll();
return $this->response->setJSON([
'success' => true,
'contacts' => array_map(function($contact) {
return [
'id' => $contact->id,
'name' => $contact->name,
'email' => $contact->email,
'phone' => $contact->phone,
];
}, $contacts)
]);
}
}