Files
bp/app/Controllers/BaseController.php
2026-01-15 07:09:36 +03:00

509 lines
18 KiB
PHP

<?php
namespace App\Controllers;
use App\Models\OrganizationUserModel;
use App\Services\AccessService;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
*
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
protected $session;
protected AccessService $access;
protected ?OrganizationUserModel $orgUserModel = null;
/**
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
// $this->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);
}
}