change ratelimiter
This commit is contained in:
@@ -4,9 +4,15 @@
|
||||
$routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) {
|
||||
$routes->get('/', 'Clients::index');
|
||||
$routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы
|
||||
$routes->get('view/(:num)', 'Clients::view/$1'); // API: данные клиента
|
||||
$routes->get('new', 'Clients::new');
|
||||
$routes->post('create', 'Clients::create');
|
||||
$routes->get('edit/(:num)', 'Clients::edit/$1');
|
||||
$routes->post('update/(:num)', 'Clients::update/$1');
|
||||
$routes->get('delete/(:num)', 'Clients::delete/$1');
|
||||
});
|
||||
|
||||
// Экспорт и импорт
|
||||
$routes->get('export', 'Clients::export');
|
||||
$routes->get('import', 'Clients::importPage');
|
||||
$routes->post('import', 'Clients::import');
|
||||
});
|
||||
@@ -70,6 +70,7 @@ class Clients extends BaseController
|
||||
'type' => 'delete',
|
||||
]
|
||||
],
|
||||
'onRowClick' => 'viewClient', // Функция для открытия карточки клиента
|
||||
'emptyMessage' => 'Клиентов пока нет',
|
||||
'emptyIcon' => 'fa-solid fa-users',
|
||||
'emptyActionUrl' => base_url('/clients/new'),
|
||||
@@ -237,4 +238,245 @@ class Clients extends BaseController
|
||||
session()->setFlashdata('error', $message);
|
||||
return redirect()->to('/');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API: Просмотр, Экспорт, Импорт
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API: Получение данных клиента для модального окна
|
||||
*/
|
||||
public function view($id)
|
||||
{
|
||||
if (!$this->access->canView('clients')) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Доступ запрещён'
|
||||
])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$client = $this->clientModel->forCurrentOrg()->find($id);
|
||||
|
||||
if (!$client) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Клиент не найден'
|
||||
])->setStatusCode(404);
|
||||
}
|
||||
|
||||
// Формируем данные для ответа
|
||||
$data = [
|
||||
'id' => $client['id'],
|
||||
'name' => $client['name'],
|
||||
'email' => $client['email'] ?? '',
|
||||
'phone' => $client['phone'] ?? '',
|
||||
'notes' => $client['notes'] ?? '',
|
||||
'status' => $client['status'] ?? 'active',
|
||||
'created_at' => $client['created_at'] ? date('d.m.Y H:i', strtotime($client['created_at'])) : '',
|
||||
'updated_at' => $client['updated_at'] ? date('d.m.Y H:i', strtotime($client['updated_at'])) : '',
|
||||
];
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт клиентов
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
if (!$this->access->canView('clients')) {
|
||||
return $this->forbiddenResponse('Доступ запрещён');
|
||||
}
|
||||
|
||||
$format = $this->request->getGet('format') ?? 'csv';
|
||||
|
||||
// Получаем всех клиентов организации
|
||||
$clients = $this->clientModel->forCurrentOrg()->findAll();
|
||||
|
||||
// Устанавливаем заголовки для скачивания
|
||||
if ($format === 'xlsx') {
|
||||
$filename = 'clients_' . date('Y-m-d') . '.xlsx';
|
||||
$this->exportToXlsx($clients, $filename);
|
||||
} else {
|
||||
$filename = 'clients_' . date('Y-m-d') . '.csv';
|
||||
$this->exportToCsv($clients, $filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт в CSV
|
||||
*/
|
||||
protected function exportToCsv(array $clients, string $filename)
|
||||
{
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Заголовок CSV
|
||||
fputcsv($output, ['ID', 'Имя', 'Email', 'Телефон', 'Статус', 'Создан', 'Обновлён'], ';');
|
||||
|
||||
// Данные
|
||||
foreach ($clients as $client) {
|
||||
fputcsv($output, [
|
||||
$client['id'],
|
||||
$client['name'],
|
||||
$client['email'] ?? '',
|
||||
$client['phone'] ?? '',
|
||||
$client['status'] ?? 'active',
|
||||
$client['created_at'] ?? '',
|
||||
$client['updated_at'] ?? '',
|
||||
], ';');
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт в XLSX (упрощённый через HTML table)
|
||||
*/
|
||||
protected function exportToXlsx(array $clients, string $filename)
|
||||
{
|
||||
// Для упрощения используем HTML table с правильными заголовками Excel
|
||||
// В продакшене рекомендуется использовать PhpSpreadsheet
|
||||
|
||||
header('Content-Type: application/vnd.ms-excel');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Cache-Control: max-age=0');
|
||||
|
||||
echo '<table border="1">';
|
||||
echo '<tr><th>ID</th><th>Имя</th><th>Email</th><th>Телефон</th><th>Статус</th><th>Создан</th><th>Обновлён</th></tr>';
|
||||
|
||||
foreach ($clients as $client) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . $client['id'] . '</td>';
|
||||
echo '<td>' . htmlspecialchars($client['name']) . '</td>';
|
||||
echo '<td>' . htmlspecialchars($client['email'] ?? '') . '</td>';
|
||||
echo '<td>' . htmlspecialchars($client['phone'] ?? '') . '</td>';
|
||||
echo '<td>' . ($client['status'] ?? 'active') . '</td>';
|
||||
echo '<td>' . ($client['created_at'] ?? '') . '</td>';
|
||||
echo '<td>' . ($client['updated_at'] ?? '') . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</table>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Страница импорта клиентов (форма)
|
||||
*/
|
||||
public function importPage()
|
||||
{
|
||||
if (!$this->access->canCreate('clients')) {
|
||||
return $this->forbiddenResponse('Доступ запрещён');
|
||||
}
|
||||
|
||||
return $this->renderTwig('@Clients/import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт клиентов из файла
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
if (!$this->access->canCreate('clients')) {
|
||||
return $this->forbiddenResponse('Доступ запрещён');
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('file');
|
||||
|
||||
if (!$file->isValid()) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Файл не загружен'
|
||||
]);
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getClientExtension());
|
||||
|
||||
if (!in_array($extension, ['csv', 'xlsx', 'xls'])) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Неподдерживаемый формат файла. Используйте CSV или XLSX.'
|
||||
]);
|
||||
}
|
||||
|
||||
$organizationId = session()->get('active_org_id');
|
||||
$imported = 0;
|
||||
$errors = [];
|
||||
|
||||
// Парсим CSV файл
|
||||
if ($extension === 'csv') {
|
||||
$handle = fopen($file->getTempName(), 'r');
|
||||
|
||||
// Пропускаем заголовок
|
||||
fgetcsv($handle, 0, ';');
|
||||
|
||||
$row = 1;
|
||||
while (($data = fgetcsv($handle, 0, ';')) !== false) {
|
||||
$row++;
|
||||
|
||||
$name = trim($data[1] ?? '');
|
||||
$email = trim($data[2] ?? '');
|
||||
|
||||
// Валидация
|
||||
if (empty($name)) {
|
||||
$errors[] = "Строка $row: Имя обязательно";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = "Строка $row: Некорректный email";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверка на дубликат email
|
||||
if (!empty($email)) {
|
||||
$exists = $this->clientModel->where('organization_id', $organizationId)
|
||||
->where('email', $email)
|
||||
->countAllResults();
|
||||
if ($exists > 0) {
|
||||
$errors[] = "Строка $row: Клиент с email $email уже существует";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Вставка
|
||||
$this->clientModel->insert([
|
||||
'organization_id' => $organizationId,
|
||||
'name' => $name,
|
||||
'email' => $email ?: null,
|
||||
'phone' => trim($data[3] ?? '') ?: null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
} else {
|
||||
// Для XLSX в продакшене использовать PhpSpreadsheet
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Импорт XLSX файлов временно недоступен. Пожалуйста, используйте CSV формат.'
|
||||
]);
|
||||
}
|
||||
|
||||
$message = "Импортировано клиентов: $imported";
|
||||
if (!empty($errors)) {
|
||||
$message .= '. Ошибок: ' . count($errors);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'imported' => $imported,
|
||||
'errors' => $errors
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
{#
|
||||
# Модальное окно просмотра клиента
|
||||
#}
|
||||
|
||||
{# Скрытый модальный контейнер - будет показан при клике на строку таблицы #}
|
||||
<div class="modal fade" id="viewClientModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
{# Header #}
|
||||
<div class="modal-header">
|
||||
<div class="d-flex align-items-center">
|
||||
<div id="clientAvatar" class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fa-solid fa-user fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="modal-title mb-0" id="clientName">—</h5>
|
||||
<span id="clientStatus" class="badge bg-success">Активен</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
{# Body #}
|
||||
<div class="modal-body">
|
||||
{# Навигация по вкладкам #}
|
||||
<ul class="nav nav-tabs mb-3" id="clientTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab">
|
||||
<i class="fa-solid fa-user me-2"></i>Основное
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="contact-tab" data-bs-toggle="tab" data-bs-target="#contact" type="button" role="tab">
|
||||
<i class="fa-solid fa-address-book me-2"></i>Контакты
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
|
||||
<i class="fa-solid fa-note-sticky me-2"></i>Заметки
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# Контент вкладок #}
|
||||
<div class="tab-content" id="clientTabsContent">
|
||||
{# Вкладка: Основное #}
|
||||
<div class="tab-pane fade show active" id="general" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="text-muted small mb-1">Email</label>
|
||||
<div id="clientEmail" class="fw-medium">—</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="text-muted small mb-1">Телефон</label>
|
||||
<div id="clientPhone" class="fw-medium">—</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="text-muted small mb-1">Дата создания</label>
|
||||
<div id="clientCreated" class="fw-medium">—</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="text-muted small mb-1">Последнее обновление</label>
|
||||
<div id="clientUpdated" class="fw-medium">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Вкладка: Контакты #}
|
||||
<div class="tab-pane fade" id="contact" role="tabpanel">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fa-solid fa-envelope fa-3x mb-3"></i>
|
||||
<p class="mb-0">Email: <a id="contactEmail" href="#">—</a></p>
|
||||
<p>Телефон: <a id="contactPhone" href="#">—</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Вкладка: Заметки #}
|
||||
<div class="tab-pane fade" id="notes" role="tabpanel">
|
||||
<div id="clientNotes" class="p-3 bg-light rounded">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fa-solid fa-times me-2"></i>Закрыть
|
||||
</button>
|
||||
<a id="clientEditLink" href="#" class="btn btn-primary">
|
||||
<i class="fa-solid fa-pen me-2"></i>Редактировать
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let viewClientModal = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализируем модальное окно
|
||||
viewClientModal = new bootstrap.Modal(document.getElementById('viewClientModal'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Открыть карточку клиента
|
||||
*/
|
||||
function viewClient(clientId) {
|
||||
// Показываем модалку с лоадером
|
||||
showClientLoader();
|
||||
viewClientModal.show();
|
||||
|
||||
// Загружаем данные
|
||||
fetch('/clients/view/' + clientId)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderClientData(data.data);
|
||||
} else {
|
||||
showClientError(data.error || 'Ошибка загрузки');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showClientError('Ошибка соединения');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать лоадер в модальном окне
|
||||
*/
|
||||
function showClientLoader() {
|
||||
document.getElementById('clientName').textContent = 'Загрузка...';
|
||||
document.getElementById('clientStatus').className = 'badge bg-secondary';
|
||||
document.getElementById('clientStatus').textContent = '—';
|
||||
|
||||
// Очищаем поля
|
||||
document.getElementById('clientEmail').textContent = '—';
|
||||
document.getElementById('clientPhone').textContent = '—';
|
||||
document.getElementById('clientCreated').textContent = '—';
|
||||
document.getElementById('clientUpdated').textContent = '—';
|
||||
document.getElementById('clientNotes').innerHTML = '—';
|
||||
document.getElementById('clientNotes').classList.remove('bg-light');
|
||||
|
||||
document.getElementById('contactEmail').textContent = '—';
|
||||
document.getElementById('contactEmail').href = '#';
|
||||
document.getElementById('contactPhone').textContent = '—';
|
||||
document.getElementById('contactPhone').href = '#';
|
||||
|
||||
document.getElementById('clientEditLink').href = '#';
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать ошибку загрузки
|
||||
*/
|
||||
function showClientError(message) {
|
||||
document.getElementById('clientName').textContent = 'Ошибка';
|
||||
document.getElementById('clientNotes').innerHTML = '<span class="text-danger">' + message + '</span>';
|
||||
document.getElementById('clientNotes').classList.add('bg-light');
|
||||
}
|
||||
|
||||
/**
|
||||
* Отобразить данные клиента
|
||||
*/
|
||||
function renderClientData(client) {
|
||||
// Имя и аватар
|
||||
const initials = client.name ? client.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '?';
|
||||
const avatarEl = document.getElementById('clientAvatar');
|
||||
avatarEl.innerHTML = initials;
|
||||
|
||||
document.getElementById('clientName').textContent = client.name || '—';
|
||||
|
||||
// Статус
|
||||
const statusEl = document.getElementById('clientStatus');
|
||||
if (client.status === 'active') {
|
||||
statusEl.className = 'badge bg-success';
|
||||
statusEl.textContent = 'Активен';
|
||||
} else if (client.status === 'blocked') {
|
||||
statusEl.className = 'badge bg-danger';
|
||||
statusEl.textContent = 'Заблокирован';
|
||||
} else {
|
||||
statusEl.className = 'badge bg-secondary';
|
||||
statusEl.textContent = client.status || '—';
|
||||
}
|
||||
|
||||
// Основная информация
|
||||
document.getElementById('clientEmail').textContent = client.email || '—';
|
||||
document.getElementById('clientPhone').textContent = client.phone || '—';
|
||||
document.getElementById('clientCreated').textContent = client.created_at || '—';
|
||||
document.getElementById('clientUpdated').textContent = client.updated_at || '—';
|
||||
|
||||
// Заметки
|
||||
const notesEl = document.getElementById('clientNotes');
|
||||
if (client.notes && client.notes.trim()) {
|
||||
notesEl.textContent = client.notes;
|
||||
notesEl.classList.add('bg-light');
|
||||
} else {
|
||||
notesEl.innerHTML = '<em class="text-muted">Заметок нет</em>';
|
||||
notesEl.classList.add('bg-light');
|
||||
}
|
||||
|
||||
// Контакты для клика
|
||||
const contactEmail = document.getElementById('contactEmail');
|
||||
if (client.email) {
|
||||
contactEmail.textContent = client.email;
|
||||
contactEmail.href = 'mailto:' + client.email;
|
||||
} else {
|
||||
contactEmail.textContent = '—';
|
||||
contactEmail.href = '#';
|
||||
}
|
||||
|
||||
const contactPhone = document.getElementById('contactPhone');
|
||||
if (client.phone) {
|
||||
contactPhone.textContent = client.phone;
|
||||
contactPhone.href = 'tel:' + client.phone;
|
||||
} else {
|
||||
contactPhone.textContent = '—';
|
||||
contactPhone.href = '#';
|
||||
}
|
||||
|
||||
// Ссылка на редактирование
|
||||
document.getElementById('clientEditLink').href = '/clients/edit/' + client.id;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}Импорт клиентов - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 700px;">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fa-solid fa-file-import me-2"></i>Импорт клиентов</h5>
|
||||
<a href="{{ base_url('/clients') }}" class="btn btn-light btn-sm">
|
||||
<i class="fa-solid fa-arrow-left"></i> Назад
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form id="importForm" action="{{ base_url('/clients/import') }}" method="POST" enctype="multipart/form-data">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
{# Инструкция #}
|
||||
<div class="alert alert-info mb-4">
|
||||
<h6 class="alert-heading"><i class="fa-solid fa-info-circle me-2"></i>Инструкция по импорту</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Загрузите файл в формате <strong>CSV</strong> (разделитель — точка с запятой)</li>
|
||||
<li>Файл должен содержать заголовки в первой строке</li>
|
||||
<li>Обязательные поля: <strong>Имя</strong></li>
|
||||
<li>Опционально: Email, Телефон</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Ссылка на шаблон #}
|
||||
<div class="mb-4">
|
||||
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fa-solid fa-download me-2"></i>Скачать шаблон
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Загрузка файла #}
|
||||
<div class="mb-4">
|
||||
<label for="file" class="form-label fw-medium">Выберите файл</label>
|
||||
<input type="file"
|
||||
class="form-control"
|
||||
id="file"
|
||||
name="file"
|
||||
accept=".csv"
|
||||
required>
|
||||
<div class="form-text">Поддерживается только формат CSV</div>
|
||||
</div>
|
||||
|
||||
{# Кнопка отправки #}
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{{ base_url('/clients') }}" class="btn btn-secondary me-3">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fa-solid fa-upload me-2"></i>Импортировать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Результат импорта (скрыт по умолчанию) #}
|
||||
<div id="importResult" class="mt-4" style="display: none;">
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="fa-solid fa-check-circle me-2"></i>Импорт завершён</h6>
|
||||
<p class="mb-0" id="importMessage"></p>
|
||||
</div>
|
||||
<div id="importErrors" class="alert alert-warning" style="display: none;">
|
||||
<h6><i class="fa-solid fa-exclamation-triangle me-2"></i>Ошибки</h6>
|
||||
<ul class="mb-0 small" id="errorsList"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('importForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const resultDiv = document.getElementById('importResult');
|
||||
const messageEl = document.getElementById('importMessage');
|
||||
const errorsDiv = document.getElementById('importErrors');
|
||||
const errorsList = document.getElementById('errorsList');
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Блокируем кнопку
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Загрузка...';
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Показываем результат
|
||||
resultDiv.style.display = 'block';
|
||||
messageEl.textContent = data.message;
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
errorsDiv.style.display = 'block';
|
||||
errorsList.innerHTML = data.errors.map(err => '<li>' + err + '</li>').join('');
|
||||
} else {
|
||||
errorsDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// Очищаем форму
|
||||
form.reset();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
resultDiv.style.display = 'block';
|
||||
messageEl.innerHTML = '<span class="text-danger">Произошла ошибка при загрузке файла</span>';
|
||||
errorsDiv.style.display = 'none';
|
||||
} finally {
|
||||
// Разблокируем кнопку
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fa-solid fa-upload me-2"></i>Импортировать';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,9 +6,26 @@
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
||||
</div>
|
||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
{# Кнопки экспорта и импорта #}
|
||||
<div class="btn-group">
|
||||
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-success" title="Экспорт CSV">
|
||||
<i class="fa-solid fa-file-csv me-1"></i>CSV
|
||||
</a>
|
||||
<a href="{{ base_url('/clients/export?format=xlsx') }}" class="btn btn-outline-success" title="Экспорт Excel">
|
||||
<i class="fa-solid fa-file-excel me-1"></i>Excel
|
||||
</a>
|
||||
</div>
|
||||
<a href="{{ base_url('/clients/import') }}" class="btn btn-outline-primary" title="Импорт клиентов">
|
||||
<i class="fa-solid fa-file-import me-1"></i>Импорт
|
||||
</a>
|
||||
|
||||
{% if can_create %}
|
||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
@@ -26,6 +43,9 @@
|
||||
{{ csrf_field()|raw }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Модальное окно просмотра клиента #}
|
||||
{% include '@Clients/_client_modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
@@ -38,6 +58,7 @@
|
||||
<script src="/assets/js/modules/DataTable.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Инициализация DataTable
|
||||
document.querySelectorAll('.data-table').forEach(function(container) {
|
||||
const id = container.id;
|
||||
const url = container.dataset.url;
|
||||
|
||||
Reference in New Issue
Block a user