533 lines
20 KiB
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)
|
|
]);
|
|
}
|
|
}
|