From d27f66953cf2b5c1f956b6b4be42cad59e226058 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 10 Jan 2026 19:43:04 +0300 Subject: [PATCH] dynamic table --- app/Controllers/BaseController.php | 85 +++++---- app/Libraries/Twig/TwigGlobalsExtension.php | 185 ++++++++++++++++++++ app/Modules/Clients/Controllers/Clients.php | 56 ++++-- app/Modules/Clients/Views/_table.twig | 92 ---------- app/Modules/Clients/Views/index.twig | 65 +------ app/Views/components/table/ajax_table.twig | 54 ++++++ app/Views/components/table/macros.twig | 19 ++ app/Views/components/table/table.twig | 78 ++++----- public/assets/js/modules/DataTable.js | 4 +- 9 files changed, 387 insertions(+), 251 deletions(-) delete mode 100644 app/Modules/Clients/Views/_table.twig create mode 100644 app/Views/components/table/ajax_table.twig create mode 100644 app/Views/components/table/macros.twig diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index f45b0fa..489dbfb 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -8,7 +8,7 @@ use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; use App\Models\OrganizationModel; -/** +/** * BaseController provides a convenient place for loading components * and performing functions that are needed by all your controllers. * @@ -78,8 +78,6 @@ abstract class BaseController extends Controller 'sortable' => [], 'defaultSort' => 'id', 'order' => 'asc', - 'viewPath' => '', - 'partialPath' => '', 'itemsKey' => 'items', 'scope' => null, // callable($builder) для дополнительных модификаций ]; @@ -159,7 +157,7 @@ abstract class BaseController extends Controller // Получаем данные с пагинацией $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; $to = min($page * $perPage, $total); @@ -173,61 +171,62 @@ abstract class BaseController extends Controller '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 = [ - $config['itemsKey'] => $items, - 'pager' => $pagerStub, + 'items' => $items, // Алиас для универсального шаблона 'pagerDetails' => $pagerData, 'perPage' => $perPage, 'sort' => $sort, 'order' => $order, 'filters' => $filters, '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; } + /** + * Рендерит 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 - возвращает полную таблицу */ public function table() { - $config = $this->getTableConfig(); - $data = $this->prepareTableData($config); - - if (!$this->isAjax()) { - return redirect()->to('/'); - } - - return $this->renderTwig($config['partialPath'], $data); + $isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax(); + return $this->renderTable(null, $isPartial); } } diff --git a/app/Libraries/Twig/TwigGlobalsExtension.php b/app/Libraries/Twig/TwigGlobalsExtension.php index de08940..f0e3966 100644 --- a/app/Libraries/Twig/TwigGlobalsExtension.php +++ b/app/Libraries/Twig/TwigGlobalsExtension.php @@ -19,6 +19,8 @@ class TwigGlobalsExtension extends AbstractExtension new TwigFunction('render_pager', [$this, 'renderPager'], ['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('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, '/'); } + + /** + * Генерирует 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 = '
'; + + 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 ? ' ' : ''; + $targetAttr = $target ? ' target="' . esc($target) . '"' : ''; + + $html .= '' + . $iconHtml . esc($label) + . ''; + } + + $html .= '
'; + + 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 '' . esc($value) . ''; + } + return $config['default'] ?? '—'; + + case 'phone': + if ($value) { + return '' . esc($value) . ''; + } + 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 'Да'; + } + return 'Нет'; + + 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); + } } diff --git a/app/Modules/Clients/Controllers/Clients.php b/app/Modules/Clients/Controllers/Clients.php index df1d171..80c9484 100644 --- a/app/Modules/Clients/Controllers/Clients.php +++ b/app/Modules/Clients/Controllers/Clients.php @@ -17,11 +17,11 @@ class Clients extends BaseController public function index() { $config = $this->getTableConfig(); - $data = $this->prepareTableData($config); - - $data['title'] = 'Клиенты'; - - return $this->renderTwig($config['viewPath'], $data); + + return $this->renderTwig('@Clients/index', [ + 'title' => 'Клиенты', + 'tableHtml' => $this->renderTable($config), + ]); } /** @@ -32,6 +32,8 @@ class Clients extends BaseController $organizationId = session()->get('active_org_id'); return [ + 'id' => 'clients-table', + 'url' => '/clients/table', 'model' => $this->clientModel, 'columns' => [ 'name' => ['label' => 'Имя / Название', 'width' => '40%'], @@ -42,17 +44,51 @@ class Clients extends BaseController 'sortable' => ['name', 'email', 'phone', 'created_at'], 'defaultSort' => 'name', 'order' => 'asc', - 'viewPath' => '@Clients/index', - 'partialPath' => '@Clients/_table', - 'itemsKey' => 'clients', - 'scope' => function ($builder) use ($organizationId) { + 'actions' => ['label' => 'Действия', 'width' => '15%'], + 'actionsConfig' => [ + [ + '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); }, ]; } + 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() diff --git a/app/Modules/Clients/Views/_table.twig b/app/Modules/Clients/Views/_table.twig deleted file mode 100644 index 5d3e0e6..0000000 --- a/app/Modules/Clients/Views/_table.twig +++ /dev/null @@ -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 #} - -{% if isEmpty %} - - - -

- {% if filters.name or filters.email or filters.phone %} - Клиенты не найдены - {% else %} - Клиентов пока нет - {% endif %} -

- - Добавить клиента - - - -{% else %} -{% for client in clients %} - - -
-
- {{ client.name|first|upper }} -
-
- {{ client.name }} - {% if client.notes %} -
{{ client.notes|slice(0, 50) }}{{ client.notes|length > 50 ? '...' : '' }} - {% endif %} -
-
- - - {% if client.email %} - {{ client.email }} - {% else %} - - {% endif %} - - - {% if client.phone %} - {{ client.phone }} - {% else %} - - {% endif %} - - - - - - - - - - -{% endfor %} -{% endif %} - - - - - - {{ include('@components/table/pagination.twig', { pagination: pagination, id: 'clients-table' }) }} - - - diff --git a/app/Modules/Clients/Views/index.twig b/app/Modules/Clients/Views/index.twig index 7ce2780..1b4bfc8 100644 --- a/app/Modules/Clients/Views/index.twig +++ b/app/Modules/Clients/Views/index.twig @@ -3,7 +3,7 @@ {% block content %}
-

Клиенты

+

{{ title }}

Управление клиентами вашей организации

@@ -20,63 +20,8 @@
- {# Формируем строки таблицы из клиентов #} - {% set tableRows = [] %} - {% if clients is defined and clients|length > 0 %} - {% for client in clients %} - {% set tableRows = tableRows|merge([{ - cells: [ - { - content: '
-
' ~ client.name|first|upper ~ '
-
' ~ client.name ~ '' ~ (client.notes ? '
' ~ client.notes|slice(0, 50) ~ (client.notes|length > 50 ? '...' : '') ~ '') ~ '
-
', - class: '' - }, - { - content: client.email ? '
' ~ client.email ~ '' : '', - class: '' - }, - { - content: client.phone ? '' ~ client.phone ~ '' : '', - class: '' - } - ], - actions: ' - ' - }]) %} - {% endfor %} - {% endif %} -
- {{ include('@components/table/table.twig', { - 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' - }) }} + {{ tableHtml|raw }} {# CSRF токен для AJAX запросов #} {{ csrf_field()|raw }}
@@ -97,16 +42,16 @@ document.addEventListener('DOMContentLoaded', function() { 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; }); diff --git a/app/Views/components/table/ajax_table.twig b/app/Views/components/table/ajax_table.twig new file mode 100644 index 0000000..182389e --- /dev/null +++ b/app/Views/components/table/ajax_table.twig @@ -0,0 +1,54 @@ + + {% if items is defined and items|length > 0 %} + {% for item in items %} + + {# Рендерим каждую колонку #} + {% for key, column in columns %} + + {{ render_cell(item, key, column)|raw }} + + {% endfor %} + + {# Колонка действий #} + {% if actionsConfig is defined and actionsConfig|length > 0 %} + + {{ render_actions(item, actionsConfig)|raw }} + + {% endif %} + + {% endfor %} + {% else %} + {# Пустое состояние #} + + + {% if emptyIcon is defined and emptyIcon %} +
+ +
+ {% endif %} +

{{ emptyMessage|default('Нет данных') }}

+ {% if emptyActionUrl is defined and emptyActionUrl %} + + {% if emptyActionIcon is defined and emptyActionIcon %} + + {% endif %} + {{ emptyActionLabel|default('Добавить') }} + + {% endif %} + + + {% endif %} + + +{# Футер с пагинацией #} + + + + {{ include('@components/table/pagination.twig', { + pagination: pagerDetails, + id: id + }) }} + + + diff --git a/app/Views/components/table/macros.twig b/app/Views/components/table/macros.twig new file mode 100644 index 0000000..3c98715 --- /dev/null +++ b/app/Views/components/table/macros.twig @@ -0,0 +1,19 @@ +{# + macros.twig - Универсальные макросы для таблиц + + Макросы: + - render_actions(actions): Рендерит кнопки действий для строки таблицы +#} + +{% macro render_actions(actions) %} +
+ {% for action in actions %} + + {% if action.icon %}{% endif %} + + {% endfor %} +
+{% endmacro %} diff --git a/app/Views/components/table/table.twig b/app/Views/components/table/table.twig index 2f20f82..d3b50c4 100644 --- a/app/Views/components/table/table.twig +++ b/app/Views/components/table/table.twig @@ -5,20 +5,25 @@ - id: ID контейнера таблицы (обязательно) - url: URL для AJAX-загрузки данных (обязательно) - perPage: Количество записей на странице (по умолчанию 10) - - columns: Ассоциативный массив ['name' => ['label' => 'Name', 'width' => '40%']] - - sort: Текущий столбец сортировки - - order: Направление сортировки - - filters: Текущие значения фильтров - - items: Массив объектов модели (автоматический рендеринг) - - rows: Предварительно построенные строки (устаревший формат, для совместимости) + - columns: Конфигурация колонок + Пример: + columns: { + name: { label: 'Имя', width: '40%' }, + email: { label: 'Email' } + } + - 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: Сообщение при отсутствии данных - - emptyActionUrl: URL для кнопки действия (опционально) - - emptyActionLabel: Текст кнопки действия (опционально) - - emptyIcon: FontAwesome иконка (опционально) + - emptyActionUrl: URL для кнопки действия + - emptyActionLabel: Текст кнопки + - emptyIcon: FontAwesome иконка - tableClass: Дополнительные классы для таблицы #} -{% set hasRows = (rows is defined and rows|length > 0) or (items is defined and items|length > 0) %} -
{# Заголовок таблицы #} @@ -32,40 +37,24 @@ {# Тело таблицы #} - {% if hasRows %} - {% if rows is defined and rows|length > 0 %} - {# Старый формат: предварительно построенные строки #} - {% for row in rows %} - - {% for cell in row.cells %} - - {% endfor %} - {% if row.actions is defined %} - - {% endif %} - + {% if items is defined and items|length > 0 %} + {% for item in items %} + + {# Рендерим каждую колонку #} + {% for key, column in columns %} + {% endfor %} - {% elseif items is defined and items|length > 0 %} - {# Новый формат: автоматический рендеринг из объектов модели #} - {% set columnKeys = columns|keys %} - {% for item in items %} - - {# Ячейки данных #} - {% for columnKey in columnKeys %} - - {% endfor %} - {# Колонка действий #} - {% if actions is defined and actions %} - - {% endif %} - - {% endfor %} - {% endif %} + {# Колонка действий #} + {% if actionsConfig is defined and actionsConfig|length > 0 %} + + {% endif %} + + {% endfor %} {% else %} {# Пустое состояние #} @@ -76,7 +65,7 @@ {% endif %} -

{{ emptyMessage|default('Нет данных для отображения') }}

+

{{ emptyMessage|default('Нет данных') }}

{% if emptyActionUrl is defined and emptyActionUrl %} {% if emptyActionIcon is defined and emptyActionIcon %} @@ -90,6 +79,7 @@ {% endif %} + {# Футер с пагинацией #}
{{ cell.content|raw }} - {{ row.actions|raw }} -
+ {{ render_cell(item, key, column)|raw }} +
{{ attribute(item, columnKey)|default('—') }} - {% if item.actions is defined %}{{ item.actions|raw }}{% endif %} -
+ {{ render_actions(item, actionsConfig)|raw }} +
diff --git a/public/assets/js/modules/DataTable.js b/public/assets/js/modules/DataTable.js index e157726..e31a2aa 100644 --- a/public/assets/js/modules/DataTable.js +++ b/public/assets/js/modules/DataTable.js @@ -336,8 +336,8 @@ class DataTable { const csrfToken = this.getCsrfToken(); const csrfTokenName = this.getCsrfTokenName(); - // Добавляем CSRF токен в параметры запроса - const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}`; + // Добавляем CSRF токен и format=partial в параметры запроса + const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}&format=partial`; // Показываем лоадер в tbody const tableBody = this.container.querySelector('tbody');