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
|
// 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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: получить контакты клиента
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue