diff --git a/app/Modules/CRM/Config/Routes.php b/app/Modules/CRM/Config/Routes.php index e39068c..f9283bc 100644 --- a/app/Modules/CRM/Config/Routes.php +++ b/app/Modules/CRM/Config/Routes.php @@ -43,6 +43,7 @@ $routes->group('crm', ['filter' => ['org', 'subscription:crm'], 'namespace' => ' // Stages $routes->get('stages', 'DealsController::stages'); $routes->post('stages', 'DealsController::storeStage'); + $routes->post('stages/reorder', 'DealsController::reorderStages'); $routes->post('stages/(:num)', 'DealsController::updateStage/$1'); $routes->get('stages/(:num)/delete', 'DealsController::destroyStage/$1'); }); diff --git a/app/Modules/CRM/Controllers/DealsController.php b/app/Modules/CRM/Controllers/DealsController.php index b30c132..813ad0d 100644 --- a/app/Modules/CRM/Controllers/DealsController.php +++ b/app/Modules/CRM/Controllers/DealsController.php @@ -494,6 +494,59 @@ class DealsController extends BaseController 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: получить контакты клиента */ diff --git a/app/Modules/CRM/Views/deals/stages.twig b/app/Modules/CRM/Views/deals/stages.twig index 1d50500..9dadc65 100644 --- a/app/Modules/CRM/Views/deals/stages.twig +++ b/app/Modules/CRM/Views/deals/stages.twig @@ -6,7 +6,7 @@

{{ title }}

-

Настройка воронки продаж

+

Настройка воронки продаж. Перетаскивайте этажи для изменения порядка.

К сделкам @@ -71,25 +71,28 @@ {# Список этапов #}
-
+
Этапы
+ Перетаскивайте строки для изменения порядка
- + - + {% for stage in stages %} - - + +
Порядок Этап Тип Вероятность Действия
{{ stage.order_index + 1 }}
+ +
{ + row.draggable = true; + row.style.cursor = 'grab'; + + // Стиль при захвате + row.addEventListener('dragstart', function(e) { + draggedItem = this; + this.classList.add('opacity-50'); + this.style.cursor = 'grabbing'; + e.dataTransfer.effectAllowed = 'move'; + }); + + row.addEventListener('dragend', function() { + this.classList.remove('opacity-50'); + this.style.cursor = 'grab'; + draggedItem = null; + }); + + row.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }); + + row.addEventListener('dragenter', function() { + if (this !== draggedItem) { + this.classList.add('bg-light'); + } + }); + + row.addEventListener('dragleave', function() { + this.classList.remove('bg-light'); + }); + + row.addEventListener('drop', function(e) { + e.preventDefault(); + this.classList.remove('bg-light'); + + if (this !== draggedItem && draggedItem) { + // Перемещаем строку + list.insertBefore(draggedItem, this); + + // Сохраняем новый порядок на сервере + saveStageOrder(); + } + }); + }); + + // Функция сохранения порядка этапов + function saveStageOrder() { + const stageIds = []; + const rows = list.querySelectorAll('.stage-row'); + + rows.forEach(row => { + stageIds.push(parseInt(row.dataset.id)); + }); + + // Отправляем как form-urlencoded (как в канбане) + const formData = new URLSearchParams(); + stageIds.forEach((id) => { + formData.append('stages[]', id); + }); + + fetch('{{ site_url('/crm/deals/stages/reorder') }}', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData.toString() + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Обновляем CSRF токен из ответа + if (data.csrf_token && document.body) { + document.body.dataset.csrfToken = data.csrf_token; + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) meta.setAttribute('content', data.csrf_token); + } + showToast('Порядок сохранён', 'success'); + } else { + showToast(data.message || 'Ошибка сохранения', 'danger'); + } + }) + .catch(error => { + console.error('Ошибка сохранения порядка:', error); + showToast('Ошибка соединения', 'danger'); + }); + } + + // Показать toast уведомление + function showToast(message, type) { + const toastContainer = document.createElement('div'); + toastContainer.className = 'position-fixed bottom-0 end-0 p-3'; + toastContainer.style.zIndex = '9999'; + + const toastId = 'toast-' + Date.now(); + toastContainer.innerHTML = ` + + `; + + document.body.appendChild(toastContainer); + + const toast = new bootstrap.Toast(document.getElementById(toastId), { + autohide: true, + delay: 2000 + }); + toast.show(); + + toastContainer.addEventListener('hidden.bs.toast', () => { + toastContainer.remove(); + }); + } +}); {% endblock %}