first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{# app/Views/components/alerts.twig #}
|
||||
|
||||
{% set alerts = get_alerts() %}
|
||||
|
||||
{% if alerts is not empty %}
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1050">
|
||||
{% for alert in alerts %}
|
||||
{# Преобразуем наш тип 'error' в класс Bootstrap 'danger' #}
|
||||
{% set bs_type = alert.type == 'error' ? 'danger' : alert.type %}
|
||||
|
||||
<div class="toast align-items-center text-white bg-{{ bs_type }} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ alert.message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Автоматически показываем все toasts при загрузке страницы
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'));
|
||||
toastElList.forEach(function(toastEl) {
|
||||
var toast = new bootstrap.Toast(toastEl);
|
||||
toast.show();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,275 @@
|
||||
# DataTable Components
|
||||
|
||||
Переиспользуемые компоненты для отображения интерактивных таблиц с AJAX-загрузкой, сортировкой и поиском.
|
||||
|
||||
## Структура компонентов
|
||||
|
||||
```
|
||||
public/
|
||||
├── js/
|
||||
│ └── modules/
|
||||
│ └── DataTable.js # JS-модуль для инициализации таблиц
|
||||
└── css/
|
||||
└── components/
|
||||
└── data-table.css # Стили для интерактивных таблиц
|
||||
|
||||
app/Views/components/table/
|
||||
├── table.twig # Основной компонент таблицы
|
||||
├── table_header.twig # Переиспользуемый заголовок
|
||||
└── pagination.twig # Компонент пагинации
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Подключение стилей и скриптов
|
||||
|
||||
В вашем шаблоне добавьте:
|
||||
|
||||
```twig
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="/css/components/data-table.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ parent() }}
|
||||
<script src="/js/modules/DataTable.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new DataTable('your-table-id', {
|
||||
url: '/your-module/table',
|
||||
perPage: 10
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### 2. Использование компонента таблицы
|
||||
|
||||
```twig
|
||||
{{ include('@components/table/table.twig', {
|
||||
id: 'products-table',
|
||||
url: '/products/table',
|
||||
perPage: 25,
|
||||
sort: sort|default(''),
|
||||
order: order|default('asc'),
|
||||
filters: filters|default({}),
|
||||
columns: [
|
||||
{ name: 'name', label: 'Название', width: '35%' },
|
||||
{ name: 'sku', label: 'Артикул', width: '15%' },
|
||||
{ name: 'price', label: 'Цена', width: '15%' },
|
||||
{ name: 'stock', label: 'Остаток', width: '15%' }
|
||||
],
|
||||
actions: { label: 'Действия', width: '20%' },
|
||||
emptyMessage: 'Товары не найдены'
|
||||
}) }}
|
||||
```
|
||||
|
||||
## Конфигурация столбцов
|
||||
|
||||
Каждый столбец поддерживает следующие параметры:
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `name` | string | Идентификатор столбца (используется для сортировки и фильтрации) |
|
||||
| `label` | string | Отображаемое название столбца |
|
||||
| `width` | string | Ширина столбца (например, '35%', '200px') |
|
||||
| `placeholder` | string | Текст-подсказка в поле поиска |
|
||||
| `searchTitle` | string | Title для иконки поиска |
|
||||
| `align` | string | CSS-класс выравнивания |
|
||||
|
||||
## Конфигурация пагинации
|
||||
|
||||
Компонент автоматически получает данные из объекта `pager`:
|
||||
|
||||
```php
|
||||
// В контроллере
|
||||
$pagination = [
|
||||
'currentPage' => $pager->getCurrentPage(),
|
||||
'totalPages' => $pager->getPageCount(),
|
||||
'totalRecords' => $pager->getTotal(),
|
||||
'perPage' => $perPage,
|
||||
'from' => (($pager->getCurrentPage() - 1) * $perPage + 1),
|
||||
'to' => min($pager->getCurrentPage() * $perPage, $pager->getTotal())
|
||||
];
|
||||
```
|
||||
|
||||
## Пример контроллера
|
||||
|
||||
```php
|
||||
public function table()
|
||||
{
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
$perPage = (int) ($this->request->getGet('perPage') ?? 10);
|
||||
$sort = $this->request->getGet('sort') ?? '';
|
||||
$order = $this->request->getGet('order') ?? 'asc';
|
||||
|
||||
// Фильтры
|
||||
$filters = [
|
||||
'name' => $this->request->getGet('filters[name]') ?? '',
|
||||
];
|
||||
|
||||
// Построение запроса
|
||||
$builder = $this->model->builder();
|
||||
|
||||
// Применяем фильтры
|
||||
if (!empty($filters['name'])) {
|
||||
$builder->like('name', $filters['name']);
|
||||
}
|
||||
|
||||
// Сортировка
|
||||
if (!empty($sort)) {
|
||||
$builder->orderBy($sort, $order);
|
||||
}
|
||||
|
||||
// Пагинация
|
||||
$items = $builder->paginate($perPage, 'default', $page);
|
||||
|
||||
$data = [
|
||||
'items' => $items,
|
||||
'pager' => $this->model->pager,
|
||||
'perPage' => $perPage,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'filters' => $filters,
|
||||
];
|
||||
|
||||
return $this->renderTwig('path/to/your/_table', $data);
|
||||
}
|
||||
```
|
||||
|
||||
## AJAX-ответ
|
||||
|
||||
Для AJAX-запросов контроллер должен возвращать только `tbody` и `tfoot`:
|
||||
|
||||
```twig
|
||||
{# _table.twig для модуля #}
|
||||
{% set isAjax = app.request.headers.get('X-Requested-With') == 'XMLHttpRequest' %}
|
||||
|
||||
{% if isAjax %}
|
||||
{# AJAX: только tbody #}
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.price }}</td>
|
||||
<td class="text-end">
|
||||
<a href="...">Редактировать</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
{% if items is not empty and pager %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
{{ include('@components/table/pagination.twig', {
|
||||
pagination: paginationData,
|
||||
id: 'your-table-id'
|
||||
}) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Обычный запрос: полная таблица #}
|
||||
<div class="table-responsive">
|
||||
{{ include('@components/table/table.twig', {
|
||||
id: 'your-table-id',
|
||||
url: '/your-module/table',
|
||||
perPage: perPage,
|
||||
columns: columns,
|
||||
pagination: paginationData,
|
||||
actions: { label: 'Действия' },
|
||||
emptyMessage: 'Нет данных'
|
||||
}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## API DataTable
|
||||
|
||||
### Опции при инициализации
|
||||
|
||||
```javascript
|
||||
new DataTable('container-id', {
|
||||
url: '/api/endpoint', // URL для AJAX-загрузки
|
||||
perPage: 10, // Записей на странице по умолчанию
|
||||
debounceTime: 300, // Задержка поиска в мс
|
||||
preserveSearchOnSort: true // Сохранять видимость поиска при сортировке
|
||||
});
|
||||
```
|
||||
|
||||
### Методы
|
||||
|
||||
```javascript
|
||||
const table = new DataTable('my-table', options);
|
||||
|
||||
// Установка фильтра
|
||||
table.setFilter('columnName', 'value');
|
||||
|
||||
// Установка количества записей
|
||||
table.setPerPage(25);
|
||||
|
||||
// Переход на страницу
|
||||
table.goToPage(3);
|
||||
```
|
||||
|
||||
## Доступные CSS-классы
|
||||
|
||||
| Класс | Описание |
|
||||
|-------|----------|
|
||||
| `.data-table` | Основной контейнер таблицы |
|
||||
| `.header-content` | Контейнер для элементов заголовка |
|
||||
| `.header-text` | Текст заголовка столбца |
|
||||
| `.search-trigger` | Иконка поиска |
|
||||
| `.sort-icon` | Иконка сортировки |
|
||||
| `.header-search-input` | Поле ввода поиска |
|
||||
| `.sort-icon.active` | Активная сортировка |
|
||||
| `.pagination-wrapper` | Обёртка пагинации |
|
||||
|
||||
## Расширение функциональности
|
||||
|
||||
### Добавление кастомных действий
|
||||
|
||||
Для добавления кнопок действий в строки:
|
||||
|
||||
```twig
|
||||
{% for client in clients %}
|
||||
<tr>
|
||||
<td>{{ client.name }}</td>
|
||||
<td>{{ client.email }}</td>
|
||||
<td class="actions-cell text-end">
|
||||
<a href="{{ editUrl }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Кастомные строки
|
||||
|
||||
Компонент поддерживает произвольное содержимое ячеек через параметр `rows`:
|
||||
|
||||
```twig
|
||||
{% set rows = [] %}
|
||||
{% for product in products %}
|
||||
{% set rows = rows|merge([{
|
||||
cells: [
|
||||
{ content: '<strong>' ~ product.name ~ '</strong>', class: '' },
|
||||
{ content: product.price ~ ' ₽', class: 'text-end' }
|
||||
],
|
||||
actions: '<a href="...">Редактировать</a>'
|
||||
}]) %}
|
||||
{% endfor %}
|
||||
|
||||
{{ include('@components/table/table.twig', {
|
||||
id: 'products-table',
|
||||
rows: rows,
|
||||
columns: columns,
|
||||
...
|
||||
}) }}
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
{#
|
||||
pagination.twig - Универсальный компонент пагинации
|
||||
Использует встроенный пейджер CodeIgniter 4
|
||||
|
||||
Параметры:
|
||||
- pagination: Объект с данными пагинации (из pager->getDetails())
|
||||
- currentPage: Текущая страница
|
||||
- pageCount: Всего страниц
|
||||
- total: Всего записей
|
||||
- perPage: Записей на странице
|
||||
- from: Начальная запись
|
||||
- to: Конечная запись
|
||||
- id: ID таблицы для уникальности элементов
|
||||
#}
|
||||
{% set currentPage = pagination.currentPage|default(1) %}
|
||||
{% set totalPages = pagination.pageCount|default(1) %}
|
||||
{% set totalRecords = pagination.total|default(0) %}
|
||||
{% set perPage = pagination.perPage|default(10) %}
|
||||
{% set from = pagination.from|default((currentPage - 1) * perPage + 1) %}
|
||||
{% set to = pagination.to|default(min(currentPage * perPage, totalRecords)) %}
|
||||
|
||||
{# Информация о записях #}
|
||||
{% set infoText = 'Показано ' ~ from ~ '–' ~ to ~ ' из ' ~ totalRecords %}
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
{# Информация о количестве записей #}
|
||||
<div class="pagination-info">
|
||||
<span>{{ infoText }}</span>
|
||||
</div>
|
||||
|
||||
{# Кнопки навигации - посередине #}
|
||||
<nav aria-label="Пагинация" style="float: left;">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{# Предыдущая страница #}
|
||||
<li class="page-item {{ currentPage <= 1 ? 'disabled' }}">
|
||||
<a class="page-link" href="#" data-nav-page="prev" aria-label="Предыдущая">
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{# Номера страниц #}
|
||||
{% set startPage = max(1, currentPage - 2) %}
|
||||
{% set endPage = min(totalPages, currentPage + 2) %}
|
||||
|
||||
{# Всегда показываем первую страницу если есть #}
|
||||
{% if startPage > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="#" data-page="1">1</a>
|
||||
</li>
|
||||
{% if startPage > 2 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Страницы вокруг текущей #}
|
||||
{% for page in startPage..endPage %}
|
||||
<li class="page-item {{ page == currentPage ? 'active' }}">
|
||||
<a class="page-link" href="#" data-page="{{ page }}">{{ page }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{# Всегда показываем последнюю страницу если есть #}
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="#" data-page="{{ totalPages }}">{{ totalPages }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{# Следующая страница #}
|
||||
<li class="page-item {{ currentPage >= totalPages ? 'disabled' }}">
|
||||
<a class="page-link" href="#" data-nav-page="next" aria-label="Следующая">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Выбор количества записей - справа #}
|
||||
<div class="per-page-selector" style="float: right;">
|
||||
<label for="per-page-select-{{ id|default('table') }}">Записей на странице:</label>
|
||||
<select id="per-page-select-{{ id|default('table') }}" class="form-select-sm per-page-select">
|
||||
<option value="10" {{ perPage == 10 ? 'selected' }}>10</option>
|
||||
<option value="25" {{ perPage == 25 ? 'selected' }}>25</option>
|
||||
<option value="50" {{ perPage == 50 ? 'selected' }}>50</option>
|
||||
<option value="100" {{ perPage == 100 ? 'selected' }}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
{#
|
||||
table.twig - Универсальный компонент таблицы с AJAX-загрузкой
|
||||
|
||||
Параметры:
|
||||
- id: ID контейнера таблицы (обязательно)
|
||||
- url: URL для AJAX-загрузки данных (обязательно)
|
||||
- perPage: Количество записей на странице (по умолчанию 10)
|
||||
- columns: Ассоциативный массив ['name' => ['label' => 'Name', 'width' => '40%']]
|
||||
- sort: Текущий столбец сортировки
|
||||
- order: Направление сортировки
|
||||
- filters: Текущие значения фильтров
|
||||
- items: Массив объектов модели (автоматический рендеринг)
|
||||
- rows: Предварительно построенные строки (устаревший формат, для совместимости)
|
||||
- emptyMessage: Сообщение при отсутствии данных
|
||||
- emptyActionUrl: URL для кнопки действия (опционально)
|
||||
- emptyActionLabel: Текст кнопки действия (опционально)
|
||||
- emptyIcon: FontAwesome иконка (опционально)
|
||||
- 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) }}">
|
||||
<table class="table table-hover mb-0 {{ tableClass|default('') }}">
|
||||
{# Заголовок таблицы #}
|
||||
{{ include('@components/table/table_header.twig', {
|
||||
columns: columns,
|
||||
sort: sort|default(''),
|
||||
order: order|default('asc'),
|
||||
filters: filters|default({}),
|
||||
actions: actions|default(false)
|
||||
}) }}
|
||||
|
||||
{# Тело таблицы #}
|
||||
<tbody>
|
||||
{% if hasRows %}
|
||||
{% 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 %}
|
||||
<tr>
|
||||
{# Ячейки данных #}
|
||||
{% for columnKey in columnKeys %}
|
||||
<td>{{ attribute(item, columnKey)|default('—') }}</td>
|
||||
{% endfor %}
|
||||
|
||||
{# Колонка действий #}
|
||||
{% if actions is defined and actions %}
|
||||
<td class="actions-cell text-end">
|
||||
{% if item.actions is defined %}{{ item.actions|raw }}{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
{#
|
||||
table_header.twig - Переиспользуемый шаблон заголовка таблицы
|
||||
|
||||
Параметры:
|
||||
- columns: Ассоциативный массив столбцов ['name' => ['label' => 'Name', 'width' => '40%']]
|
||||
- sort: Текущий столбец сортировки
|
||||
- order: Направление сортировки (asc/desc)
|
||||
- filters: Текущие значения фильтров
|
||||
#}
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
{% for columnKey, column in columns %}
|
||||
<th style="width: {{ column.width|default('auto') }};"
|
||||
class="{{ column.align|default('') }}"
|
||||
data-sort-column="{{ columnKey }}">
|
||||
<div class="header-content">
|
||||
{# Поле поиска - первым, для правильного позиционирования #}
|
||||
|
||||
|
||||
{# Иконка поиска #}
|
||||
<i class="fa-solid fa-search search-trigger text-muted"
|
||||
data-search-trigger="{{ columnKey }}"
|
||||
title="{{ column.searchTitle|default('Поиск по ' ~ column.label|lower) }}"></i>
|
||||
|
||||
<input type="text"
|
||||
class="form-control-sm header-search-input"
|
||||
data-search-input="{{ columnKey }}"
|
||||
value="{{ filters[columnKey]|default('') }}"
|
||||
placeholder="{{ column.placeholder|default('Поиск...') }}"
|
||||
style="display: none;">
|
||||
{# Текст заголовка #}
|
||||
<span class="header-text" data-header-text="{{ columnKey }}">
|
||||
{{ column.label }}
|
||||
</span>
|
||||
|
||||
{# Иконка сортировки #}
|
||||
<i class="fa-solid fa-sort sort-icon {{ sort == columnKey ? 'active' : 'text-muted' }}"
|
||||
data-sort="{{ columnKey }}"
|
||||
title="{{ sort == columnKey ? (order == 'asc' ? 'Сортировка по возрастанию (нажмите для сортировки по убыванию)' : 'Сортировка по убыванию (нажмите для сортировки по возрастанию)') : 'Сортировка' }}"></i>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
|
||||
{# Колонка действий (опционально) #}
|
||||
{% if actions is defined and actions %}
|
||||
<th class="text-end {{ actions.class|default('') }}" style="width: {{ actions.width|default('auto') }};">
|
||||
{{ actions.label|default('Действия') }}
|
||||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
Reference in New Issue
Block a user