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

586 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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)
]);
}
}