dynamic table
This commit is contained in:
parent
5f5a28e292
commit
d27f66953c
|
|
@ -78,8 +78,6 @@ abstract class BaseController extends Controller
|
||||||
'sortable' => [],
|
'sortable' => [],
|
||||||
'defaultSort' => 'id',
|
'defaultSort' => 'id',
|
||||||
'order' => 'asc',
|
'order' => 'asc',
|
||||||
'viewPath' => '',
|
|
||||||
'partialPath' => '',
|
|
||||||
'itemsKey' => 'items',
|
'itemsKey' => 'items',
|
||||||
'scope' => null, // callable($builder) для дополнительных модификаций
|
'scope' => null, // callable($builder) для дополнительных модификаций
|
||||||
];
|
];
|
||||||
|
|
@ -159,7 +157,7 @@ abstract class BaseController extends Controller
|
||||||
|
|
||||||
// Получаем данные с пагинацией
|
// Получаем данные с пагинацией
|
||||||
$builder->select('*');
|
$builder->select('*');
|
||||||
$items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResult();
|
$items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
|
||||||
|
|
||||||
$from = ($page - 1) * $perPage + 1;
|
$from = ($page - 1) * $perPage + 1;
|
||||||
$to = min($page * $perPage, $total);
|
$to = min($page * $perPage, $total);
|
||||||
|
|
@ -173,61 +171,62 @@ abstract class BaseController extends Controller
|
||||||
'to' => $to,
|
'to' => $to,
|
||||||
];
|
];
|
||||||
|
|
||||||
$pagerStub = new class($pagerData) {
|
|
||||||
private $data;
|
|
||||||
public function __construct(array $data)
|
|
||||||
{
|
|
||||||
$this->data = $data;
|
|
||||||
}
|
|
||||||
public function getCurrentPage(): int
|
|
||||||
{
|
|
||||||
return $this->data['currentPage'] ?? 1;
|
|
||||||
}
|
|
||||||
public function getPageCount(): int
|
|
||||||
{
|
|
||||||
return $this->data['pageCount'] ?? 1;
|
|
||||||
}
|
|
||||||
public function getTotal(): int
|
|
||||||
{
|
|
||||||
return $this->data['total'] ?? 0;
|
|
||||||
}
|
|
||||||
public function getDetails(): array
|
|
||||||
{
|
|
||||||
return $this->data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
$config['itemsKey'] => $items,
|
'items' => $items, // Алиас для универсального шаблона
|
||||||
'pager' => $pagerStub,
|
|
||||||
'pagerDetails' => $pagerData,
|
'pagerDetails' => $pagerData,
|
||||||
'perPage' => $perPage,
|
'perPage' => $perPage,
|
||||||
'sort' => $sort,
|
'sort' => $sort,
|
||||||
'order' => $order,
|
'order' => $order,
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'columns' => $config['columns'],
|
'columns' => $config['columns'],
|
||||||
|
'actionsConfig' => $config['actionsConfig'] ?? [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// В конце prepareTableData, перед return
|
|
||||||
log_message('debug', 'Total records calculated: ' . $total);
|
|
||||||
log_message('debug', 'Organization ID: ' . session()->get('active_org_id'));
|
|
||||||
log_message('debug', 'SQL Query: ' . $countBuilder->getCompiledSelect());
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит HTML таблицы из конфигурации
|
||||||
|
*
|
||||||
|
* @param array|null $config Конфигурация таблицы (если null, используется getTableConfig())
|
||||||
|
* @param bool $isPartial Если true, возвращает только tbody + tfoot (для AJAX)
|
||||||
|
* @return string HTML таблицы
|
||||||
|
*/
|
||||||
|
protected function renderTable(?array $config = null, bool $isPartial = false): string
|
||||||
|
{
|
||||||
|
$config = $config ?? $this->getTableConfig();
|
||||||
|
$tableData = $this->prepareTableData($config);
|
||||||
|
|
||||||
|
// Дополнительные параметры для компонента таблицы
|
||||||
|
$tableData['id'] = $config['id'] ?? 'data-table';
|
||||||
|
$tableData['url'] = $config['url'] ?? '/table';
|
||||||
|
$tableData['perPage'] = $tableData['perPage'] ?? 10;
|
||||||
|
$tableData['sort'] = $tableData['sort'] ?? '';
|
||||||
|
$tableData['order'] = $tableData['order'] ?? 'asc';
|
||||||
|
$tableData['filters'] = $tableData['filters'] ?? [];
|
||||||
|
$tableData['actions'] = $config['actions'] ?? false;
|
||||||
|
$tableData['actionsConfig'] = $config['actionsConfig'] ?? [];
|
||||||
|
$tableData['columns'] = $config['columns'] ?? [];
|
||||||
|
|
||||||
|
// Параметры для пустого состояния
|
||||||
|
$tableData['emptyMessage'] = $config['emptyMessage'] ?? 'Нет данных';
|
||||||
|
$tableData['emptyIcon'] = $config['emptyIcon'] ?? '';
|
||||||
|
$tableData['emptyActionUrl'] = $config['emptyActionUrl'] ?? '';
|
||||||
|
$tableData['emptyActionLabel'] = $config['emptyActionLabel'] ?? 'Добавить';
|
||||||
|
$tableData['emptyActionIcon'] = $config['emptyActionIcon'] ?? '';
|
||||||
|
|
||||||
|
$template = $isPartial ? '@components/table/ajax_table' : '@components/table/table';
|
||||||
|
|
||||||
|
return $this->renderTwig($template, $tableData);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AJAX endpoint для таблицы - возвращает partial (tbody + tfoot)
|
* AJAX endpoint для таблицы - возвращает partial (tbody + tfoot)
|
||||||
|
* Если запрос не AJAX - возвращает полную таблицу
|
||||||
*/
|
*/
|
||||||
public function table()
|
public function table()
|
||||||
{
|
{
|
||||||
$config = $this->getTableConfig();
|
$isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax();
|
||||||
$data = $this->prepareTableData($config);
|
return $this->renderTable(null, $isPartial);
|
||||||
|
|
||||||
if (!$this->isAjax()) {
|
|
||||||
return redirect()->to('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->renderTwig($config['partialPath'], $data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ class TwigGlobalsExtension extends AbstractExtension
|
||||||
new TwigFunction('render_pager', [$this, 'renderPager'], ['is_safe' => ['html']]),
|
new TwigFunction('render_pager', [$this, 'renderPager'], ['is_safe' => ['html']]),
|
||||||
new TwigFunction('is_active_route', [$this, 'isActiveRoute'], ['is_safe' => ['html']]),
|
new TwigFunction('is_active_route', [$this, 'isActiveRoute'], ['is_safe' => ['html']]),
|
||||||
new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]),
|
new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]),
|
||||||
|
new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]),
|
||||||
|
new TwigFunction('render_cell', [$this, 'renderCell'], ['is_safe' => ['html']]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,4 +117,187 @@ class TwigGlobalsExtension extends AbstractExtension
|
||||||
// Убираем начальный слеш если есть
|
// Убираем начальный слеш если есть
|
||||||
return ltrim($route, '/');
|
return ltrim($route, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует HTML для кнопок действий в строке таблицы
|
||||||
|
*
|
||||||
|
* @param object|array $item Данные строки (объект или массив)
|
||||||
|
* @param array $actions Массив конфигураций действий
|
||||||
|
* @return string HTML код кнопок
|
||||||
|
*/
|
||||||
|
public function renderActions($item, array $actions = []): string
|
||||||
|
{
|
||||||
|
if (empty($actions)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конвертируем объект в массив для доступа к свойствам
|
||||||
|
$itemArray = $this->objectToArray($item);
|
||||||
|
|
||||||
|
// DEBUG: логируем для отладки
|
||||||
|
log_message('debug', 'renderActions: item type = ' . gettype($item));
|
||||||
|
log_message('debug', 'renderActions: item keys = ' . (is_array($itemArray) ? implode(', ', array_keys($itemArray)) : 'N/A'));
|
||||||
|
log_message('debug', 'renderActions: item = ' . print_r($itemArray, true));
|
||||||
|
|
||||||
|
$html = '<div class="btn-group btn-group-sm">';
|
||||||
|
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
$label = $action['label'] ?? 'Action';
|
||||||
|
$urlPattern = $action['url'] ?? '#';
|
||||||
|
$icon = $action['icon'] ?? '';
|
||||||
|
$class = $action['class'] ?? 'btn-outline-secondary';
|
||||||
|
$title = $action['title'] ?? $label;
|
||||||
|
$target = $action['target'] ?? '';
|
||||||
|
|
||||||
|
// Подставляем значения из item в URL
|
||||||
|
$url = $this->interpolate($urlPattern, $itemArray);
|
||||||
|
|
||||||
|
log_message('debug', 'renderActions: urlPattern = ' . $urlPattern . ', url = ' . $url);
|
||||||
|
|
||||||
|
// Формируем HTML кнопки/ссылки
|
||||||
|
$iconHtml = $icon ? '<i class="' . esc($icon) . '"></i> ' : '';
|
||||||
|
$targetAttr = $target ? ' target="' . esc($target) . '"' : '';
|
||||||
|
|
||||||
|
$html .= '<a href="' . esc($url) . '" '
|
||||||
|
. 'class="btn ' . esc($class) . '" '
|
||||||
|
. 'title="' . esc($title) . '"'
|
||||||
|
. $targetAttr . '>'
|
||||||
|
. $iconHtml . esc($label)
|
||||||
|
. '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует объект в массив (включая защищённые свойства)
|
||||||
|
*/
|
||||||
|
private function objectToArray($data): array
|
||||||
|
{
|
||||||
|
if (is_array($data)) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($data)) {
|
||||||
|
// Используем json_decode/encode для надёжного извлечения всех свойств
|
||||||
|
$json = json_encode($data);
|
||||||
|
return json_decode($json, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит значение ячейки таблицы
|
||||||
|
*
|
||||||
|
* @param object|array $item Данные строки
|
||||||
|
* @param string $key Ключ поля для отображения
|
||||||
|
* @param array $config Конфигурация колонки (опционально)
|
||||||
|
* @return string HTML код ячейки
|
||||||
|
*/
|
||||||
|
public function renderCell($item, string $key, array $config = []): string
|
||||||
|
{
|
||||||
|
$itemArray = $this->objectToArray($item);
|
||||||
|
$value = $itemArray[$key] ?? null;
|
||||||
|
|
||||||
|
// Если значение пустое и есть значение по умолчанию
|
||||||
|
if (($value === null || $value === '' || $value === false) && isset($config['default'])) {
|
||||||
|
return $config['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если указан шаблон, используем его для рендеринга
|
||||||
|
if (isset($config['template'])) {
|
||||||
|
$template = $config['template'];
|
||||||
|
// Подставляем все значения из item в шаблон
|
||||||
|
foreach ($itemArray as $k => $v) {
|
||||||
|
$template = str_replace('{' . $k . '}', esc($v ?? ''), $template);
|
||||||
|
}
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем тип преобразования если указан
|
||||||
|
if (isset($config['type'])) {
|
||||||
|
switch ($config['type']) {
|
||||||
|
case 'email':
|
||||||
|
if ($value) {
|
||||||
|
return '<a href="mailto:' . esc($value) . '">' . esc($value) . '</a>';
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
|
||||||
|
case 'phone':
|
||||||
|
if ($value) {
|
||||||
|
return '<a href="tel:' . esc($value) . '">' . esc($value) . '</a>';
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
if ($value) {
|
||||||
|
return esc(date('d.m.Y', strtotime($value)));
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
|
||||||
|
case 'datetime':
|
||||||
|
if ($value) {
|
||||||
|
return esc(date('d.m.Y H:i', strtotime($value)));
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
case 'bool':
|
||||||
|
if ($value) {
|
||||||
|
return '<span class="badge bg-success">Да</span>';
|
||||||
|
}
|
||||||
|
return '<span class="badge bg-secondary">Нет</span>';
|
||||||
|
|
||||||
|
case 'uppercase':
|
||||||
|
return $value ? esc(strtoupper($value)) : ($config['default'] ?? '');
|
||||||
|
|
||||||
|
case 'lowercase':
|
||||||
|
return $value ? esc(strtolower($value)) : ($config['default'] ?? '');
|
||||||
|
|
||||||
|
case 'truncate':
|
||||||
|
$length = $config['length'] ?? 50;
|
||||||
|
if ($value && strlen($value) > $length) {
|
||||||
|
return esc(substr($value, 0, $length)) . '...';
|
||||||
|
}
|
||||||
|
return esc($value ?? '');
|
||||||
|
|
||||||
|
case 'currency':
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
return number_format((float) $value, 0, '.', ' ') . ' ₽';
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
|
||||||
|
case 'percent':
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
return esc((float) $value) . '%';
|
||||||
|
}
|
||||||
|
return $config['default'] ?? '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// По умолчанию просто возвращаем значение
|
||||||
|
return $value !== null && $value !== '' ? esc((string) $value) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подставляет значения из данных в шаблон строки
|
||||||
|
*
|
||||||
|
* @param string $pattern Шаблон с плейсхолдерами вида {field_name}
|
||||||
|
* @param object|array $data Данные для подстановки
|
||||||
|
* @return string Результирующая строка
|
||||||
|
*/
|
||||||
|
private function interpolate(string $pattern, $data): string
|
||||||
|
{
|
||||||
|
// Конвертируем объект в массив
|
||||||
|
$data = is_object($data) ? $this->objectToArray($data) : $data;
|
||||||
|
|
||||||
|
// Заменяем все {key} на значения
|
||||||
|
return preg_replace_callback('/\{(\w+)\}/', function ($matches) use ($data) {
|
||||||
|
$key = $matches[1];
|
||||||
|
return isset($data[$key]) ? esc($data[$key]) : $matches[0];
|
||||||
|
}, $pattern);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ class Clients extends BaseController
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$config = $this->getTableConfig();
|
$config = $this->getTableConfig();
|
||||||
$data = $this->prepareTableData($config);
|
|
||||||
|
|
||||||
$data['title'] = 'Клиенты';
|
return $this->renderTwig('@Clients/index', [
|
||||||
|
'title' => 'Клиенты',
|
||||||
return $this->renderTwig($config['viewPath'], $data);
|
'tableHtml' => $this->renderTable($config),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,6 +32,8 @@ class Clients extends BaseController
|
||||||
$organizationId = session()->get('active_org_id');
|
$organizationId = session()->get('active_org_id');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'id' => 'clients-table',
|
||||||
|
'url' => '/clients/table',
|
||||||
'model' => $this->clientModel,
|
'model' => $this->clientModel,
|
||||||
'columns' => [
|
'columns' => [
|
||||||
'name' => ['label' => 'Имя / Название', 'width' => '40%'],
|
'name' => ['label' => 'Имя / Название', 'width' => '40%'],
|
||||||
|
|
@ -42,17 +44,51 @@ class Clients extends BaseController
|
||||||
'sortable' => ['name', 'email', 'phone', 'created_at'],
|
'sortable' => ['name', 'email', 'phone', 'created_at'],
|
||||||
'defaultSort' => 'name',
|
'defaultSort' => 'name',
|
||||||
'order' => 'asc',
|
'order' => 'asc',
|
||||||
'viewPath' => '@Clients/index',
|
'actions' => ['label' => 'Действия', 'width' => '15%'],
|
||||||
'partialPath' => '@Clients/_table',
|
'actionsConfig' => [
|
||||||
'itemsKey' => 'clients',
|
[
|
||||||
'scope' => function ($builder) use ($organizationId) {
|
'label' => '',
|
||||||
|
'url' => '/clients/edit/{id}',
|
||||||
|
'icon' => 'fa-solid fa-pen',
|
||||||
|
'class' => 'btn-outline-primary',
|
||||||
|
'title' => 'Редактировать'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => '',
|
||||||
|
'url' => '/clients/delete/{id}',
|
||||||
|
'icon' => 'fa-solid fa-trash',
|
||||||
|
'class' => 'btn-outline-danger',
|
||||||
|
'title' => 'Удалить'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'emptyMessage' => 'Клиентов пока нет',
|
||||||
|
'emptyIcon' => 'fa-solid fa-users',
|
||||||
|
'emptyActionUrl' => base_url('/clients/new'),
|
||||||
|
'emptyActionLabel'=> 'Добавить клиента',
|
||||||
|
'emptyActionIcon' => 'fa-solid fa-plus',
|
||||||
|
'scope' => function ($builder) use ($organizationId) {
|
||||||
$builder->where('organization_id', $organizationId);
|
$builder->where('organization_id', $organizationId);
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table()
|
public function table()
|
||||||
{
|
{
|
||||||
return parent::table();
|
$isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax();
|
||||||
|
|
||||||
|
if ($isPartial) {
|
||||||
|
// AJAX — только tbody + tfoot
|
||||||
|
return $this->renderTable(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прямой запрос — полная страница
|
||||||
|
$config = $this->getTableConfig();
|
||||||
|
$tableHtml = $this->renderTable($config, false);
|
||||||
|
|
||||||
|
return $this->renderTwig('@Clients/index', [
|
||||||
|
'title' => $config['pageTitle'] ?? 'Клиенты',
|
||||||
|
'tableHtml' => $tableHtml,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function new()
|
public function new()
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
{# app/Modules/Clients/Views/_table.twig #}
|
|
||||||
|
|
||||||
{# Определяем тип запроса: AJAX = только tbody + footer #}
|
|
||||||
{% set isAjax = app.request.headers.get('X-Requested-With') == 'XMLHttpRequest' %}
|
|
||||||
|
|
||||||
{# Настройки пагинации - ИСПОЛЬЗУЕМ pagerDetails напрямую #}
|
|
||||||
{% if pagerDetails is defined %}
|
|
||||||
{% set pagination = pagerDetails %}
|
|
||||||
{% else %}
|
|
||||||
{# Fallback если pagerDetails нет #}
|
|
||||||
{% set pagination = {
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
total: clients|length|default(0),
|
|
||||||
perPage: perPage|default(10),
|
|
||||||
from: 1,
|
|
||||||
to: clients|length|default(0)
|
|
||||||
} %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{# Проверка на пустое состояние #}
|
|
||||||
{% set isEmpty = clients is empty or clients|length == 0 %}
|
|
||||||
|
|
||||||
{# AJAX запрос - tbody + footer #}
|
|
||||||
<tbody>
|
|
||||||
{% if isEmpty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center py-5">
|
|
||||||
<i class="fa-solid fa-users text-muted mb-3" style="font-size: 3rem;"></i>
|
|
||||||
<p class="text-muted mb-3">
|
|
||||||
{% if filters.name or filters.email or filters.phone %}
|
|
||||||
Клиенты не найдены
|
|
||||||
{% else %}
|
|
||||||
Клиентов пока нет
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
|
||||||
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
{% for client in clients %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-primary text-white rounded-circle d-flex justify-content-center align-items-center me-3" style="width:40px;height:40px;">
|
|
||||||
{{ client.name|first|upper }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{{ client.name }}</strong>
|
|
||||||
{% if client.notes %}
|
|
||||||
<br><small class="text-muted">{{ client.notes|slice(0, 50) }}{{ client.notes|length > 50 ? '...' : '' }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if client.email %}
|
|
||||||
<a href="mailto:{{ client.email }}">{{ client.email }}</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if client.phone %}
|
|
||||||
<a href="tel:{{ client.phone }}">{{ client.phone }}</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a href="{{ base_url('/clients/edit/' ~ client.id) }}" class="btn btn-sm btn-outline-primary" title="Редактировать">
|
|
||||||
<i class="fa-solid fa-pen"></i>
|
|
||||||
</a>
|
|
||||||
<a href="{{ base_url('/clients/delete/' ~ client.id) }}" class="btn btn-sm btn-outline-danger" title="Удалить" onclick="return confirm('Вы уверены что хотите удалить этого клиента?');">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4">
|
|
||||||
{{ include('@components/table/pagination.twig', { pagination: pagination, id: 'clients-table' }) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<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">Клиенты</h1>
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
|
||||||
|
|
@ -20,63 +20,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Формируем строки таблицы из клиентов #}
|
|
||||||
{% set tableRows = [] %}
|
|
||||||
{% if clients is defined and clients|length > 0 %}
|
|
||||||
{% for client in clients %}
|
|
||||||
{% set tableRows = tableRows|merge([{
|
|
||||||
cells: [
|
|
||||||
{
|
|
||||||
content: '<div class="d-flex align-items-center">
|
|
||||||
<div class="bg-primary text-white rounded-circle d-flex justify-content-center align-items-center me-3" style="width:40px;height:40px;">' ~ client.name|first|upper ~ '</div>
|
|
||||||
<div><strong>' ~ client.name ~ '</strong>' ~ (client.notes ? '<br><small class="text-muted">' ~ client.notes|slice(0, 50) ~ (client.notes|length > 50 ? '...' : '') ~ '</small>') ~ '</div>
|
|
||||||
</div>',
|
|
||||||
class: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: client.email ? '<a href="mailto:' ~ client.email ~ '">' ~ client.email ~ '</a>' : '<span class="text-muted">—</span>',
|
|
||||||
class: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: client.phone ? '<a href="tel:' ~ client.phone ~ '">' ~ client.phone ~ '</a>' : '<span class="text-muted">—</span>',
|
|
||||||
class: ''
|
|
||||||
}
|
|
||||||
],
|
|
||||||
actions: '<a href="' ~ base_url('/clients/edit/' ~ client.id) ~ '" class="btn btn-sm btn-outline-primary" title="Редактировать"><i class="fa-solid fa-pen"></i></a>
|
|
||||||
<a href="' ~ base_url('/clients/delete/' ~ client.id) ~ '" class="btn btn-sm btn-outline-danger" title="Удалить" onclick="return confirm(\'Вы уверены что хотите удалить этого клиента?\');"><i class="fa-solid fa-trash"></i></a>'
|
|
||||||
}]) %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="clients-table">
|
<div id="clients-table">
|
||||||
{{ include('@components/table/table.twig', {
|
{{ tableHtml|raw }}
|
||||||
id: 'clients-table',
|
|
||||||
url: '/clients/table',
|
|
||||||
perPage: perPage|default(10),
|
|
||||||
sort: sort|default(''),
|
|
||||||
order: order|default('asc'),
|
|
||||||
filters: filters|default({}),
|
|
||||||
columns: {
|
|
||||||
name: { label: 'Имя / Название', width: '40%' },
|
|
||||||
email: { label: 'Email', width: '25%' },
|
|
||||||
phone: { label: 'Телефон', width: '20%' }
|
|
||||||
},
|
|
||||||
rows: tableRows,
|
|
||||||
pagerDetails: {
|
|
||||||
currentPage: pagerDetails.currentPage|default(1),
|
|
||||||
pageCount: pagerDetails.pageCount|default(1),
|
|
||||||
total: pagerDetails.total|default(0),
|
|
||||||
perPage: perPage|default(10),
|
|
||||||
from: pagerDetails.from|default(1),
|
|
||||||
to: pagerDetails.to|default(clients|length|default(0))
|
|
||||||
},
|
|
||||||
actions: { label: 'Действия', width: '15%' },
|
|
||||||
emptyMessage: 'Клиентов пока нет',
|
|
||||||
emptyIcon: 'fa-solid fa-users',
|
|
||||||
emptyActionUrl: base_url('/clients/new'),
|
|
||||||
emptyActionLabel: 'Добавить клиента',
|
|
||||||
emptyActionIcon: 'fa-solid fa-plus'
|
|
||||||
}) }}
|
|
||||||
{# CSRF токен для AJAX запросов #}
|
{# CSRF токен для AJAX запросов #}
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<tbody>
|
||||||
|
{% if items is defined and items|length > 0 %}
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
{# Рендерим каждую колонку #}
|
||||||
|
{% for key, column in columns %}
|
||||||
|
<td>
|
||||||
|
{{ render_cell(item, key, column)|raw }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Колонка действий #}
|
||||||
|
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
||||||
|
<td class="actions-cell text-end">
|
||||||
|
{{ render_actions(item, actionsConfig)|raw }}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{# Пустое состояние #}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ columns|length + (actions is defined and actions ? 1 : 0) }}"
|
||||||
|
class="text-center py-5">
|
||||||
|
{% if emptyIcon is defined and emptyIcon %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<i class="{{ emptyIcon }} text-muted" style="font-size: 3rem;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-muted mb-3">{{ emptyMessage|default('Нет данных') }}</p>
|
||||||
|
{% if emptyActionUrl is defined and emptyActionUrl %}
|
||||||
|
<a href="{{ emptyActionUrl }}" class="btn btn-primary">
|
||||||
|
{% if emptyActionIcon is defined and emptyActionIcon %}
|
||||||
|
<i class="{{ emptyActionIcon }} me-2"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ emptyActionLabel|default('Добавить') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
{# Футер с пагинацией #}
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ columns|length + 1 }}">
|
||||||
|
{{ include('@components/table/pagination.twig', {
|
||||||
|
pagination: pagerDetails,
|
||||||
|
id: id
|
||||||
|
}) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{#
|
||||||
|
macros.twig - Универсальные макросы для таблиц
|
||||||
|
|
||||||
|
Макросы:
|
||||||
|
- render_actions(actions): Рендерит кнопки действий для строки таблицы
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_actions(actions) %}
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% for action in actions %}
|
||||||
|
<a href="{{ action.url }}"
|
||||||
|
class="btn {{ action.class|default('btn-outline-primary') }}"
|
||||||
|
title="{{ action.title|default('') }}"
|
||||||
|
{% if action.confirm %}onclick="return confirm('{{ action.confirm }}');"{% endif %}>
|
||||||
|
{% if action.icon %}<i class="{{ action.icon }}"></i>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -5,20 +5,25 @@
|
||||||
- id: ID контейнера таблицы (обязательно)
|
- id: ID контейнера таблицы (обязательно)
|
||||||
- url: URL для AJAX-загрузки данных (обязательно)
|
- url: URL для AJAX-загрузки данных (обязательно)
|
||||||
- perPage: Количество записей на странице (по умолчанию 10)
|
- perPage: Количество записей на странице (по умолчанию 10)
|
||||||
- columns: Ассоциативный массив ['name' => ['label' => 'Name', 'width' => '40%']]
|
- columns: Конфигурация колонок
|
||||||
- sort: Текущий столбец сортировки
|
Пример:
|
||||||
- order: Направление сортировки
|
columns: {
|
||||||
- filters: Текущие значения фильтров
|
name: { label: 'Имя', width: '40%' },
|
||||||
- items: Массив объектов модели (автоматический рендеринг)
|
email: { label: 'Email' }
|
||||||
- rows: Предварительно построенные строки (устаревший формат, для совместимости)
|
}
|
||||||
|
- items: Массив объектов для отображения
|
||||||
|
- actionsConfig: Конфигурация действий строки
|
||||||
|
Пример:
|
||||||
|
actionsConfig: [
|
||||||
|
{ label: 'Ред.', url: '/clients/edit/{id}', icon: 'bi bi-pencil', class: 'btn-outline-primary' },
|
||||||
|
{ label: 'Удалить', url: '/clients/delete/{id}', icon: 'bi bi-trash', class: 'btn-outline-danger' }
|
||||||
|
]
|
||||||
- emptyMessage: Сообщение при отсутствии данных
|
- emptyMessage: Сообщение при отсутствии данных
|
||||||
- emptyActionUrl: URL для кнопки действия (опционально)
|
- emptyActionUrl: URL для кнопки действия
|
||||||
- emptyActionLabel: Текст кнопки действия (опционально)
|
- emptyActionLabel: Текст кнопки
|
||||||
- emptyIcon: FontAwesome иконка (опционально)
|
- emptyIcon: FontAwesome иконка
|
||||||
- tableClass: Дополнительные классы для таблицы
|
- tableClass: Дополнительные классы для таблицы
|
||||||
#}
|
#}
|
||||||
{% set hasRows = (rows is defined and rows|length > 0) or (items is defined and items|length > 0) %}
|
|
||||||
|
|
||||||
<div id="{{ id }}" class="data-table" data-url="{{ url }}" data-per-page="{{ perPage|default(10) }}">
|
<div id="{{ id }}" class="data-table" data-url="{{ url }}" data-per-page="{{ perPage|default(10) }}">
|
||||||
<table class="table table-hover mb-0 {{ tableClass|default('') }}">
|
<table class="table table-hover mb-0 {{ tableClass|default('') }}">
|
||||||
{# Заголовок таблицы #}
|
{# Заголовок таблицы #}
|
||||||
|
|
@ -32,40 +37,24 @@
|
||||||
|
|
||||||
{# Тело таблицы #}
|
{# Тело таблицы #}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if hasRows %}
|
{% if items is defined and items|length > 0 %}
|
||||||
{% if rows is defined and rows|length > 0 %}
|
{% for item in items %}
|
||||||
{# Старый формат: предварительно построенные строки #}
|
<tr>
|
||||||
{% for row in rows %}
|
{# Рендерим каждую колонку #}
|
||||||
<tr>
|
{% for key, column in columns %}
|
||||||
{% for cell in row.cells %}
|
<td>
|
||||||
<td class="{{ cell.class|default('') }}">{{ cell.content|raw }}</td>
|
{{ render_cell(item, key, column)|raw }}
|
||||||
{% endfor %}
|
</td>
|
||||||
{% if row.actions is defined %}
|
|
||||||
<td class="actions-cell text-end">
|
|
||||||
{{ row.actions|raw }}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elseif items is defined and items|length > 0 %}
|
|
||||||
{# Новый формат: автоматический рендеринг из объектов модели #}
|
|
||||||
{% set columnKeys = columns|keys %}
|
|
||||||
{% for item in items %}
|
|
||||||
<tr>
|
|
||||||
{# Ячейки данных #}
|
|
||||||
{% for columnKey in columnKeys %}
|
|
||||||
<td>{{ attribute(item, columnKey)|default('—') }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{# Колонка действий #}
|
{# Колонка действий #}
|
||||||
{% if actions is defined and actions %}
|
{% if actionsConfig is defined and actionsConfig|length > 0 %}
|
||||||
<td class="actions-cell text-end">
|
<td class="actions-cell text-end">
|
||||||
{% if item.actions is defined %}{{ item.actions|raw }}{% endif %}
|
{{ render_actions(item, actionsConfig)|raw }}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Пустое состояние #}
|
{# Пустое состояние #}
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -76,7 +65,7 @@
|
||||||
<i class="{{ emptyIcon }} text-muted" style="font-size: 3rem;"></i>
|
<i class="{{ emptyIcon }} text-muted" style="font-size: 3rem;"></i>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="text-muted mb-3">{{ emptyMessage|default('Нет данных для отображения') }}</p>
|
<p class="text-muted mb-3">{{ emptyMessage|default('Нет данных') }}</p>
|
||||||
{% if emptyActionUrl is defined and emptyActionUrl %}
|
{% if emptyActionUrl is defined and emptyActionUrl %}
|
||||||
<a href="{{ emptyActionUrl }}" class="btn btn-primary">
|
<a href="{{ emptyActionUrl }}" class="btn btn-primary">
|
||||||
{% if emptyActionIcon is defined and emptyActionIcon %}
|
{% if emptyActionIcon is defined and emptyActionIcon %}
|
||||||
|
|
@ -90,6 +79,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
|
{# Футер с пагинацией #}
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ columns|length + 1 }}">
|
<td colspan="{{ columns|length + 1 }}">
|
||||||
|
|
|
||||||
|
|
@ -336,8 +336,8 @@ class DataTable {
|
||||||
const csrfToken = this.getCsrfToken();
|
const csrfToken = this.getCsrfToken();
|
||||||
const csrfTokenName = this.getCsrfTokenName();
|
const csrfTokenName = this.getCsrfTokenName();
|
||||||
|
|
||||||
// Добавляем CSRF токен в параметры запроса
|
// Добавляем CSRF токен и format=partial в параметры запроса
|
||||||
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}`;
|
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}&format=partial`;
|
||||||
|
|
||||||
// Показываем лоадер в tbody
|
// Показываем лоадер в tbody
|
||||||
const tableBody = this.container.querySelector('tbody');
|
const tableBody = this.container.querySelector('tbody');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue