helpers = ['form', 'url']; // Caution: Do not edit this line. parent::initController($request, $response, $logger); // Preload any models, libraries, etc, here. $this->session = service('session'); $this->access = service('access'); // Загружаем хелпер доступа для Twig helper('access'); } /** * Проверка права на действие ( shortcut для $this->access->can() ) * * @param string $action * @param string $resource * @return bool */ protected function can(string $action, string $resource): bool { return $this->access->can($action, $resource); } /** * Проверка роли (shortcut для $this->access->isRole() ) * * @param string|array $roles * @return bool */ protected function isRole($roles): bool { return $this->access->isRole($roles); } public function renderTwig($template, $data = []) { helper('csrf'); $twig = \Config\Services::twig(); // oldInput из сессии добавляется в данные шаблона // Расширение TwigGlobalsExtension автоматически добавляет session, alerts, old, currentOrg $oldInput = $this->session->get('_ci_old_input') ?? []; $data['old'] = $data['old'] ?? $oldInput; // Добавляем access в данные шаблона для функций can(), isRole() и т.д. $data['access'] = $this->access; ob_start(); $twig->display($template, $data); $content = ob_get_clean(); return $content; } // ======================================== // Методы для универсальных таблиц // ======================================== /** * Конфигурация таблицы - переопределяется в каждом контроллере */ protected function getTableConfig(): array { return [ 'model' => null, 'columns' => [], 'searchable' => [], 'sortable' => [], 'defaultSort' => 'id', 'order' => 'asc', 'itemsKey' => 'items', 'scope' => null, // callable($builder) для дополнительных модификаций ]; } /** * Проверка AJAX запроса */ protected function isAjax(): bool { $header = $this->request->header('X-Requested-With'); $value = $header ? $header->getValue() : ''; return strtolower($value) === 'xmlhttprequest'; } /** * Подготовка данных таблицы (общая логика для всех таблиц) */ protected function prepareTableData(?array $config = null): array { $config = array_merge($this->getTableConfig(), $config ?? []); $page = (int) ($this->request->getGet('page') ?? 1); $perPage = (int) ($this->request->getGet('perPage') ?? 10); $sort = $this->request->getGet('sort') ?? $config['defaultSort']; $order = $this->request->getGet('order') ?? $config['order']; // Исправление: получаем фильтры из параметра filters[] $filters = []; $rawFilters = $this->request->getGet('filters'); if ($rawFilters) { if (is_array($rawFilters)) { $filters = $rawFilters; } else { // Для обратной совместимости, если фильтры пришли в строке parse_str($rawFilters, $filters); if (isset($filters['filters'])) { $filters = $filters['filters']; } } } else { // Старый способ извлечения фильтров для совместимости foreach ($this->request->getGet() as $key => $value) { if (str_starts_with($key, 'filters[') && str_ends_with($key, ']')) { $field = substr($key, 8, -1); // Исправлено: было 9, должно быть 8 $filters[$field] = $value; } } } $model = $config['model']; // Если есть кастомный scope - создаём новый чистый запрос // scope будет полностью контролировать FROM, JOIN, SELECT if (isset($config['scope']) && is_callable($config['scope'])) { $builder = $model->db()->newQuery(); $config['scope']($builder); } else { // Стандартный путь - используем builder модели $builder = $model->builder(); $builder->resetQuery(); // Автоматическая фильтрация по организации для моделей с TenantScopedModel $modelClass = get_class($model); $traits = class_uses($modelClass); if (in_array('App\Models\Traits\TenantScopedModel', $traits)) { $model->forCurrentOrg(); } } // Применяем фильтры foreach ($filters as $filterKey => $value) { if ($value === '') { continue; } // Сначала проверяем fieldMap (алиасы) — они имеют приоритет if (isset($config['fieldMap']) && isset($config['fieldMap'][$filterKey])) { $realField = $config['fieldMap'][$filterKey]; $builder->like($realField, $value); } // Потом проверяем прямое совпадение elseif (in_array($filterKey, $config['searchable'])) { $builder->like($filterKey, $value); } } // Сортировка if ($sort && in_array($sort, $config['sortable'])) { $builder->orderBy($sort, $order); } // Сохраняем текущее состояние builder для подсчета $countBuilder = clone $builder; $total = $countBuilder->countAllResults(false); // Получаем данные с пагинацией (scope уже установил нужный SELECT) $items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray(); $from = ($page - 1) * $perPage + 1; $to = min($page * $perPage, $total); $pagerData = [ 'currentPage' => $page, 'pageCount' => $total > 0 ? (int) ceil($total / $perPage) : 1, 'total' => $total, 'perPage' => $perPage, 'from' => $from, 'to' => $to, ]; $data = [ 'items' => $items, // Алиас для универсального шаблона 'pagerDetails' => $pagerData, 'perPage' => $perPage, 'sort' => $sort, 'order' => $order, 'filters' => $filters, 'columns' => $config['columns'], 'actionsConfig' => $config['actionsConfig'] ?? [], 'can_edit' => $config['can_edit'] ?? true, 'can_delete' => $config['can_delete'] ?? true, ]; 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 для таблицы * * Логика: * - Если format=partial или AJAX → возвращает только tbody + tfoot * - Если прямой GET → редиректит на основную страницу с теми же параметрами * * @param array|null $config Кастомная конфигурация таблицы (если null, используется getTableConfig()) * @param string|null $pageUrl URL основной страницы таблицы (для редиректа) * @return string|ResponseInterface */ public function table(?array $config = null, ?string $pageUrl = null) { $isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax(); // Если это частичный запрос (AJAX) — возвращаем только таблицу if ($isPartial) { return $this->renderTable($config, true); } // Прямой запрос к /table — редиректим на основную страницу // Сохраняем все параметры: page, perPage, sort, order, filters $params = $this->request->getGet(); unset($params['format']); // Убираем format=partial если был if ($pageUrl) { $redirectUrl = $pageUrl; } else { // Пытаемся извлечь URL из config['url'] $tableUrl = $config['url'] ?? '/table'; $redirectUrl = $tableUrl; } if (!empty($params)) { $redirectUrl .= '?' . http_build_query($params); } return redirect()->to($redirectUrl); } }