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'); helper('crm_deals'); } /** * Получение лениво инициализированной модели OrganizationUserModel */ protected function getOrgUserModel(): OrganizationUserModel { if ($this->orgUserModel === null) { $this->orgUserModel = new OrganizationUserModel(); } return $this->orgUserModel; } // ======================================== // Методы для работы с пользователем и организацией // ======================================== /** * Получение ID текущего пользователя */ protected function getCurrentUserId(): ?int { $userId = $this->session->get('user_id'); return $userId ? (int) $userId : null; } /** * Получение данных текущего пользователя */ protected function getCurrentUser(): ?array { $userId = $this->getCurrentUserId(); if (!$userId) { return null; } $userModel = new \App\Models\UserModel(); return $userModel->find($userId); } /** * Получение ID активной организации */ protected function getActiveOrgId(): ?int { $orgId = $this->session->get('active_org_id'); return $orgId ? (int) $orgId : null; } /** * Проверка права на действие (shortcut для $this->access->can()) */ protected function can(string $action, string $resource): bool { return $this->access->can($action, $resource); } /** * Проверка роли (shortcut для $this->access->isRole()) */ protected function isRole($roles): bool { return $this->access->isRole($roles); } /** * Получение membership пользователя для организации * * @param int $orgId * @return array|null */ protected function getMembership(int $orgId): ?array { $userId = $this->getCurrentUserId(); if (!$userId || !$orgId) { return null; } return $this->getOrgUserModel() ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); } /** * Получение membership с требованием наличия доступа * Бросает исключение если доступ запрещён * * @param int $orgId * @return array */ protected function requireMembership(int $orgId): array { $membership = $this->getMembership($orgId); if (!$membership) { throw new \RuntimeException('Доступ запрещён'); } return $membership; } /** * Получение ID активной организации с требованием * Бросает исключение если организация не выбрана * * @return int */ protected function requireActiveOrg(): int { $orgId = $this->getActiveOrgId(); if (!$orgId) { throw new \RuntimeException('Организация не выбрана'); } return $orgId; } // ======================================== // Методы для редиректов и ответов // ======================================== /** * Редирект с сообщением об ошибке */ protected function redirectWithError(string $message, string $redirectUrl): ResponseInterface { if ($this->request->isAJAX()) { return service('response') ->setStatusCode(403) ->setJSON(['error' => $message]); } $this->session->setFlashdata('error', $message); return redirect()->to($redirectUrl); } /** * Редирект с сообщением об успехе */ protected function redirectWithSuccess(string $message, string $redirectUrl): ResponseInterface { if ($this->request->isAJAX()) { return service('response') ->setStatusCode(200) ->setJSON(['success' => true, 'message' => $message]); } $this->session->setFlashdata('success', $message); return redirect()->to($redirectUrl); } /** * Ответ для AJAX запросов с ошибкой доступа */ protected function forbiddenResponse(string $message = 'Доступ запрещён'): ResponseInterface { return service('response') ->setStatusCode(403) ->setJSON(['error' => $message]); } /** * Ответ для AJAX запросов с ошибкой валидации */ protected function validationErrorResponse(string $message = 'Ошибка валидации', array $errors = []): ResponseInterface { return service('response') ->setStatusCode(422) ->setJSON([ 'success' => false, 'message' => $message, 'errors' => $errors, ]); } // ======================================== // Утилиты для вывода // ======================================== /** * Форматирование времени блокировки для отображения */ protected function formatBlockTime(int $seconds): string { if ($seconds >= 60) { $minutes = ceil($seconds / 60); return $minutes . ' ' . $this->pluralize($minutes, ['минуту', 'минуты', 'минут']); } return $seconds . ' ' . $this->pluralize($seconds, ['секунду', 'секунды', 'секунд']); } /** * Склонение окончаний для чисел * * @param int $number * @param array $forms [одна, две, пять] * @return string */ protected function pluralize(int $number, array $forms): string { $abs = abs($number); $mod = $abs % 10; if ($abs % 100 >= 11 && $abs % 100 <= 19) { return $forms[2]; } if ($mod === 1) { return $forms[0]; } if ($mod >= 2 && $mod <= 4) { return $forms[1]; } return $forms[2]; } // ======================================== // Рендеринг Twig // ======================================== public function renderTwig($template, $data = []) { helper('csrf'); helper('crm_deals'); $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); $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); } }