fix CRM contacts
This commit is contained in:
parent
308c684aa2
commit
82acbd8c82
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: получить контакты клиента
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
|
||||
<i class="fa-solid fa-arrow-left me-2"></i>К сделкам
|
||||
|
|
@ -71,25 +71,28 @@
|
|||
|
||||
{# Список этапов #}
|
||||
<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>
|
||||
<span class="text-muted small">Перетаскивайте строки для изменения порядка</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4" style="width: 60px;">Порядок</th>
|
||||
<th class="ps-4" style="width: 60px;"></th>
|
||||
<th>Этап</th>
|
||||
<th style="width: 120px;">Тип</th>
|
||||
<th style="width: 120px;">Вероятность</th>
|
||||
<th class="text-end pe-4" style="width: 200px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="stages-list">
|
||||
{% for stage in stages %}
|
||||
<tr>
|
||||
<td class="ps-4 text-muted">{{ stage.order_index + 1 }}</td>
|
||||
<tr data-id="{{ stage.id }}" class="stage-row">
|
||||
<td class="ps-4">
|
||||
<i class="fa-solid fa-grip-vertical text-muted cursor-move" style="cursor: grab;"></i>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="rounded"
|
||||
|
|
@ -190,5 +193,135 @@ function openEditModal(id, name, color, type, probability) {
|
|||
|
||||
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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue