586 lines
22 KiB
PHP
586 lines
22 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: Изменить порядок этапов (drag-n-drop)
|
||
* POST /crm/deals/stages/reorder
|
||
*/
|
||
public function reorderStages()
|
||
{
|
||
$organizationId = $this->requireActiveOrg();
|
||
|
||
// Получаем массив stages[] из form-urlencoded
|
||
$stageOrders = $this->request->getPost('stages');
|
||
|
||
if (empty($stageOrders) || !is_array($stageOrders)) {
|
||
return $this->response
|
||
->setHeader('X-CSRF-TOKEN', csrf_hash())
|
||
->setHeader('X-CSRF-HASH', csrf_token())
|
||
->setJSON([
|
||
'success' => false,
|
||
'message' => 'Не передан список этапов',
|
||
])->setStatusCode(422);
|
||
}
|
||
|
||
// Приводим к int (приходят как строки из form-urlencoded)
|
||
$stageOrders = array_map('intval', $stageOrders);
|
||
|
||
// Проверяем что все этапы принадлежат организации
|
||
foreach ($stageOrders as $stageId) {
|
||
$stage = $this->stageService->getStage($stageId);
|
||
// Все поля из БД возвращаются как строки, поэтому сравниваем через intval
|
||
if (!$stage || intval($stage['organization_id'] ?? 0) !== intval($organizationId)) {
|
||
return $this->response
|
||
->setJSON([
|
||
'success' => false,
|
||
'message' => 'Этап не найден или принадлежит другой организации',
|
||
'debug' => [
|
||
'stageId' => $stageId,
|
||
'stage' => $stage,
|
||
'organizationId' => $organizationId,
|
||
],
|
||
])->setStatusCode(422);
|
||
}
|
||
}
|
||
|
||
$this->stageService->reorderStages($organizationId, $stageOrders);
|
||
|
||
return $this->response
|
||
->setJSON([
|
||
'success' => true,
|
||
'message' => 'Порядок этапов обновлён',
|
||
'csrf_token' => csrf_hash(),
|
||
'csrf_hash' => csrf_token(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
]);
|
||
}
|
||
}
|