fix CRM contacts

This commit is contained in:
root 2026-01-18 20:11:53 +03:00
parent 308c684aa2
commit 82acbd8c82
3 changed files with 193 additions and 6 deletions

View File

@ -43,6 +43,7 @@ $routes->group('crm', ['filter' => ['org', 'subscription:crm'], 'namespace' => '
// Stages // Stages
$routes->get('stages', 'DealsController::stages'); $routes->get('stages', 'DealsController::stages');
$routes->post('stages', 'DealsController::storeStage'); $routes->post('stages', 'DealsController::storeStage');
$routes->post('stages/reorder', 'DealsController::reorderStages');
$routes->post('stages/(:num)', 'DealsController::updateStage/$1'); $routes->post('stages/(:num)', 'DealsController::updateStage/$1');
$routes->get('stages/(:num)/delete', 'DealsController::destroyStage/$1'); $routes->get('stages/(:num)/delete', 'DealsController::destroyStage/$1');
}); });

View File

@ -494,6 +494,59 @@ class DealsController extends BaseController
return redirect()->to('/crm/deals/stages')->with('success', 'Этап удалён'); 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: получить контакты клиента * API: получить контакты клиента
*/ */

View File

@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0"><i class="fa-solid fa-list-check text-warning me-2"></i> {{ title }}</h1> <h1 class="h3 mb-0"><i class="fa-solid fa-list-check text-warning me-2"></i> {{ title }}</h1>
<p class="text-muted mb-0">Настройка воронки продаж</p> <p class="text-muted mb-0">Настройка воронки продаж. Перетаскивайте этажи для изменения порядка.</p>
</div> </div>
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary"> <a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left me-2"></i>К сделкам <i class="fa-solid fa-arrow-left me-2"></i>К сделкам
@ -71,25 +71,28 @@
{# Список этапов #} {# Список этапов #}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Этапы</h5> <h5 class="mb-0">Этапы</h5>
<span class="text-muted small">Перетаскивайте строки для изменения порядка</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="bg-light"> <thead class="bg-light">
<tr> <tr>
<th class="ps-4" style="width: 60px;">Порядок</th> <th class="ps-4" style="width: 60px;"></th>
<th>Этап</th> <th>Этап</th>
<th style="width: 120px;">Тип</th> <th style="width: 120px;">Тип</th>
<th style="width: 120px;">Вероятность</th> <th style="width: 120px;">Вероятность</th>
<th class="text-end pe-4" style="width: 200px;">Действия</th> <th class="text-end pe-4" style="width: 200px;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="stages-list">
{% for stage in stages %} {% for stage in stages %}
<tr> <tr data-id="{{ stage.id }}" class="stage-row">
<td class="ps-4 text-muted">{{ stage.order_index + 1 }}</td> <td class="ps-4">
<i class="fa-solid fa-grip-vertical text-muted cursor-move" style="cursor: grab;"></i>
</td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="rounded" <span class="rounded"
@ -190,5 +193,135 @@ function openEditModal(id, name, color, type, probability) {
modal.show(); modal.show();
} }
// Drag and Drop для сортировки этапов
document.addEventListener('DOMContentLoaded', function() {
const list = document.getElementById('stages-list');
let draggedItem = null;
if (!list) return;
// Делаем строки draggable
const rows = list.querySelectorAll('.stage-row');
rows.forEach(row => {
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 = `
<div id="${toastId}" class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
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();
});
}
});
</script> </script>
{% endblock %} {% endblock %}