Many fixes
This commit is contained in:
@@ -2,18 +2,25 @@
|
||||
|
||||
// CRM Module Routes
|
||||
|
||||
$routes->group('crm', ['filter' => 'org', 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) {
|
||||
$routes->group('crm', ['filter' => ['org', 'subscription:crm'], 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) {
|
||||
|
||||
// Dashboard
|
||||
$routes->get('/', 'DashboardController::index');
|
||||
|
||||
// Contacts
|
||||
$routes->get('contacts', 'ContactsController::index');
|
||||
$routes->get('contacts/table', 'ContactsController::contactsTable');
|
||||
$routes->get('contacts/create', 'ContactsController::create');
|
||||
$routes->post('contacts', 'ContactsController::store');
|
||||
$routes->get('contacts/(:num)/edit', 'ContactsController::edit/$1');
|
||||
$routes->post('contacts/(:num)', 'ContactsController::update/$1');
|
||||
$routes->get('contacts/(:num)/delete', 'ContactsController::destroy/$1');
|
||||
|
||||
// Contacts AJAX API (for inline editing in Clients module)
|
||||
$routes->post('contacts/list/(:num)', 'ContactsController::ajaxList/$1');
|
||||
$routes->post('contacts/store', 'ContactsController::ajaxStore');
|
||||
$routes->post('contacts/update/(:num)', 'ContactsController::ajaxUpdate/$1');
|
||||
$routes->post('contacts/delete/(:num)', 'ContactsController::ajaxDelete/$1');
|
||||
|
||||
// Deals
|
||||
$routes->group('deals', static function ($routes) {
|
||||
|
||||
@@ -17,24 +17,84 @@ class ContactsController extends BaseController
|
||||
$this->clientModel = new ClientModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы контактов
|
||||
*/
|
||||
protected function getContactsTableConfig(): array
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
return [
|
||||
'id' => 'contacts-table',
|
||||
'url' => '/crm/contacts/table',
|
||||
'model' => $this->contactModel,
|
||||
'columns' => [
|
||||
'id' => ['label' => 'ID', 'width' => '60px'],
|
||||
'name' => ['label' => 'Имя'],
|
||||
'email' => ['label' => 'Email', 'width' => '180px'],
|
||||
'phone' => ['label' => 'Телефон', 'width' => '140px'],
|
||||
'position' => ['label' => 'Должность', 'width' => '150px'],
|
||||
'customer_name' => ['label' => 'Клиент'],
|
||||
'created_at' => ['label' => 'Дата', 'width' => '100px'],
|
||||
],
|
||||
'searchable' => ['name', 'email', 'phone', 'position', 'customer_name'],
|
||||
'sortable' => ['id', 'name', 'created_at'],
|
||||
'defaultSort' => 'created_at',
|
||||
'order' => 'desc',
|
||||
'fieldMap' => [
|
||||
'customer_name' => 'customers.name',
|
||||
'name' => 'contacts.name',
|
||||
'email' => 'contacts.email',
|
||||
'phone' => 'contacts.phone',
|
||||
'position' => 'contacts.position',
|
||||
'created_at' => 'contacts.created_at',
|
||||
'id' => 'contacts.id',
|
||||
],
|
||||
'scope' => function($builder) use ($organizationId) {
|
||||
$builder->from('contacts')
|
||||
->select('contacts.id, contacts.name, contacts.email, contacts.phone, contacts.position, contacts.created_at, contacts.deleted_at, customers.name as customer_name')
|
||||
->join('organizations_clients customers', 'customers.id = contacts.customer_id', 'left')
|
||||
->where('contacts.organization_id', $organizationId)
|
||||
->where('contacts.deleted_at', null);
|
||||
},
|
||||
'actions' => ['label' => 'Действия', 'width' => '120px'],
|
||||
'actionsConfig' => [
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/crm/contacts/{id}/edit',
|
||||
'icon' => 'fa-solid fa-pen',
|
||||
'class' => 'btn-outline-primary',
|
||||
'title' => 'Редактировать',
|
||||
],
|
||||
],
|
||||
'emptyMessage' => 'Контактов пока нет',
|
||||
'emptyIcon' => 'fa-solid fa-users',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Список контактов
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
$contacts = $this->contactModel
|
||||
->where('organization_id', $organizationId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
$config = $this->getContactsTableConfig();
|
||||
$tableHtml = $this->renderTable($config);
|
||||
|
||||
return $this->renderTwig('@CRM/contacts/index', [
|
||||
'title' => 'Контакты',
|
||||
'contacts' => $contacts,
|
||||
'tableHtml' => $tableHtml,
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX таблица контактов
|
||||
*/
|
||||
public function contactsTable()
|
||||
{
|
||||
return parent::table($this->getContactsTableConfig(), '/crm/contacts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма создания контакта
|
||||
*/
|
||||
@@ -151,4 +211,190 @@ class ContactsController extends BaseController
|
||||
|
||||
return redirect()->to('/crm/contacts')->with('success', 'Контакт удалён');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AJAX API для inline-редактирования в модуле Clients
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Получить список контактов клиента (AJAX)
|
||||
* GET /crm/contacts/list/{clientId}
|
||||
*/
|
||||
public function ajaxList(int $clientId)
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
// Проверяем что клиент принадлежит организации
|
||||
$client = $this->clientModel->forCurrentOrg()->find($clientId);
|
||||
if (!$client) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Клиент не найден',
|
||||
]);
|
||||
}
|
||||
|
||||
$contacts = $this->contactModel
|
||||
->where('organization_id', $organizationId)
|
||||
->where('customer_id', $clientId)
|
||||
->orderBy('name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$items = array_map(function ($contact) {
|
||||
return [
|
||||
'id' => $contact->id,
|
||||
'name' => $contact->name,
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'position' => $contact->position,
|
||||
];
|
||||
}, $contacts);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'items' => $items,
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать контакт (AJAX)
|
||||
* POST /crm/contacts/store
|
||||
*/
|
||||
public function ajaxStore()
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
$customerId = $this->request->getPost('customer_id');
|
||||
|
||||
// Проверяем клиента если указан
|
||||
if ($customerId) {
|
||||
$client = $this->clientModel->forCurrentOrg()->find($customerId);
|
||||
if (!$client) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Клиент не найден',
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'organization_id' => $organizationId,
|
||||
'customer_id' => $customerId ?: null,
|
||||
'name' => $this->request->getPost('name'),
|
||||
'email' => $this->request->getPost('email') ?: null,
|
||||
'phone' => $this->request->getPost('phone') ?: null,
|
||||
'position' => $this->request->getPost('position') ?: null,
|
||||
];
|
||||
|
||||
// Валидация
|
||||
if (empty($data['name'])) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Имя контакта обязательно',
|
||||
'errors' => ['name' => 'Имя контакта обязательно'],
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$contactId = $this->contactModel->insert($data);
|
||||
|
||||
if (!$contactId) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Ошибка при создании контакта',
|
||||
'errors' => $this->contactModel->errors(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Контакт создан',
|
||||
'item' => [
|
||||
'id' => $contactId,
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'],
|
||||
'position' => $data['position'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить контакт (AJAX)
|
||||
* POST /crm/contacts/update/{id}
|
||||
*/
|
||||
public function ajaxUpdate(int $id)
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
$contact = $this->contactModel->find($id);
|
||||
|
||||
if (!$contact || $contact->organization_id !== $organizationId) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Контакт не найден',
|
||||
])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'email' => $this->request->getPost('email') ?: null,
|
||||
'phone' => $this->request->getPost('phone') ?: null,
|
||||
'position' => $this->request->getPost('position') ?: null,
|
||||
];
|
||||
|
||||
// Валидация
|
||||
if (empty($data['name'])) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Имя контакта обязательно',
|
||||
'errors' => ['name' => 'Имя контакта обязательно'],
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$result = $this->contactModel->update($id, $data);
|
||||
|
||||
if (!$result) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Ошибка при обновлении контакта',
|
||||
'errors' => $this->contactModel->errors(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Контакт обновлён',
|
||||
'item' => [
|
||||
'id' => $id,
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'],
|
||||
'position' => $data['position'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить контакт (AJAX)
|
||||
* POST /crm/contacts/delete/{id}
|
||||
*/
|
||||
public function ajaxDelete(int $id)
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
$contact = $this->contactModel->find($id);
|
||||
|
||||
if (!$contact || $contact->organization_id !== $organizationId) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Контакт не найден',
|
||||
])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$this->contactModel->delete($id);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Контакт удалён',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class DealsController extends BaseController
|
||||
'amount' => [
|
||||
'label' => 'Сумма',
|
||||
'width' => '15%',
|
||||
'align' => 'text-end',
|
||||
|
||||
],
|
||||
'client_name' => [
|
||||
'label' => 'Клиент',
|
||||
@@ -401,7 +401,14 @@ class DealsController extends BaseController
|
||||
|
||||
$result = $this->dealService->changeStage($dealId, $newStageId, $userId);
|
||||
|
||||
return $this->response->setJSON(['success' => $result]);
|
||||
// Получаем новый CSRF токен для клиента
|
||||
$csrfToken = csrf_hash();
|
||||
$csrfHash = csrf_token();
|
||||
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-TOKEN', $csrfToken)
|
||||
->setHeader('X-CSRF-HASH', $csrfHash)
|
||||
->setJSON(['success' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,10 +29,6 @@
|
||||
<form method="POST" action="{{ actionUrl }}">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
{% if contact is defined %}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-bold">Имя *</label>
|
||||
<input type="text"
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<h1 class="h3 mb-0"><i class="fa-solid fa-users text-info me-2"></i> {{ title }}</h1>
|
||||
</div>
|
||||
<a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить контакт
|
||||
@@ -13,77 +17,50 @@
|
||||
</div>
|
||||
|
||||
{# Сообщения #}
|
||||
{% if success is defined %}
|
||||
{% if session.success %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ success }}
|
||||
{{ session.success }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session.error }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Должность</th>
|
||||
<th>Клиент</th>
|
||||
<th class="text-end" style="width: 120px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 32px; height: 32px; min-width: 32px;">
|
||||
<span class="text-white small">{{ contact.name|slice(0, 2)|upper }}</span>
|
||||
</div>
|
||||
<span class="fw-medium">{{ contact.name }}</span>
|
||||
{% if contact.is_primary %}
|
||||
<span class="badge bg-success" style="font-size: 0.7rem;">Основной</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ contact.email ?: '—' }}</td>
|
||||
<td>{{ contact.phone ?: '—' }}</td>
|
||||
<td>{{ contact.position ?: '—' }}</td>
|
||||
<td>
|
||||
{% if contact.customer_id %}
|
||||
<span class="text-muted">{{ contact.customer_id }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ site_url('/crm/contacts/' ~ contact.id ~ '/edit') }}"
|
||||
class="btn btn-outline-primary btn-sm" title="Редактировать">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</a>
|
||||
<form action="{{ site_url('/crm/contacts/' ~ contact.id) }}" method="POST" class="d-inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('Удалить контакт?')" title="Удалить">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4 text-muted">
|
||||
Контактов пока нет
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ tableHtml|raw }}
|
||||
{# CSRF токен для AJAX запросов #}
|
||||
{{ csrf_field()|raw }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ site_url('/assets/js/modules/DataTable.js') }}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.data-table').forEach(function(container) {
|
||||
const id = container.id;
|
||||
const url = container.dataset.url;
|
||||
const perPage = parseInt(container.dataset.perPage) || 10;
|
||||
|
||||
if (window.dataTables && window.dataTables[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new DataTable(id, {
|
||||
url: url,
|
||||
perPage: perPage
|
||||
});
|
||||
|
||||
window.dataTables = window.dataTables || {};
|
||||
window.dataTables[id] = table;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">CRM</h1>
|
||||
<h1 class="h3 mb-0"><i class="fa-solid fa-chart-line text-primary me-2"></i>CRM</h1>
|
||||
<p class="text-muted mb-0">Управление продажами и клиентами</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<a href="{{ site_url('/crm/clients') }}" class="card shadow-sm text-decoration-none h-100">
|
||||
<a href="{{ site_url('/clients') }}" class="card shadow-sm text-decoration-none h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bg-success bg-opacity-10 rounded p-3">
|
||||
|
||||
@@ -45,31 +45,13 @@
|
||||
showNavigation: true,
|
||||
showLegend: true,
|
||||
legend: calendarLegend,
|
||||
eventComponent: '@Deals/calendar_event.twig'
|
||||
eventComponent: '@CRM/deals/calendar_event.twig'
|
||||
}) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<style>
|
||||
.calendar-event {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background-color: #f3f4f6;
|
||||
border-left: 3px solid #6b7280;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.calendar-events-more {
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@
|
||||
<form method="POST" action="{{ actionUrl }}">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
{% if deal is defined %}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label fw-bold">Название сделки *</label>
|
||||
<input type="text"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<h1 class="h3 mb-0"><i class="fa-solid fa-file-contract text-primary me-2"></i>{{ title }}</h1>
|
||||
<p class="text-muted mb-0">
|
||||
Всего: {{ items|length }} |
|
||||
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{ csrf_field()|raw }}
|
||||
{# Канбан доска #}
|
||||
{{ include('@components/kanban/kanban.twig', {
|
||||
columns: kanbanColumns,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">{{ 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>
|
||||
</div>
|
||||
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
|
||||
@@ -137,7 +137,6 @@
|
||||
<div class="modal-content">
|
||||
<form id="editForm" method="POST">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Редактировать этап</h5>
|
||||
|
||||
Reference in New Issue
Block a user