509 lines
18 KiB
PHP
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);
|
||
}
|
||
}
|