dynamic table

This commit is contained in:
root 2026-01-10 19:43:04 +03:00
parent 5f5a28e292
commit d27f66953c
9 changed files with 387 additions and 251 deletions

View File

@ -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);
} }
} }

View File

@ -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);
}
} }

View File

@ -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', [
'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) { '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()

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View File

@ -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 row in rows %}
<tr>
{% for cell in row.cells %}
<td class="{{ cell.class|default('') }}">{{ cell.content|raw }}</td>
{% endfor %}
{% if row.actions is defined %}
<td class="actions-cell text-end">
{{ row.actions|raw }}
</td>
{% endif %}
</tr>
{% endfor %}
{% elseif items is defined and items|length > 0 %}
{# Новый формат: автоматический рендеринг из объектов модели #}
{% set columnKeys = columns|keys %}
{% for item in items %} {% for item in items %}
<tr> <tr>
{# Ячейки данных #} {# Рендерим каждую колонку #}
{% for columnKey in columnKeys %} {% for key, column in columns %}
<td>{{ attribute(item, columnKey)|default('—') }}</td> <td>
{{ render_cell(item, key, column)|raw }}
</td>
{% endfor %} {% 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 }}">

View File

@ -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');