{% set totalOrgs = 0 %}
{% for plan in planStats %}
{% set totalOrgs = totalOrgs + plan.orgs_count %}
{% endfor %}
{% for plan in planStats %}
{% set percent = totalOrgs > 0 ? (plan.orgs_count / totalOrgs * 100)|round(1) : 0 %}
{{ plan.name }}
{{ plan.orgs_count }}
{{ percent }}%
{% endfor %}
{% endif %}
Сводка
Всего пользователей (30 дней)
{% set totalUsers = 0 %}
{% for stat in dailyStats %}
{% set totalUsers = totalUsers + stat.users %}
{% endfor %}
{{ totalUsers|number_format(0, '', ' ') }}
Всего организаций (30 дней)
{% set totalOrgs = 0 %}
{% for stat in dailyStats %}
{% set totalOrgs = totalOrgs + stat.orgs %}
{% endfor %}
{{ totalOrgs|number_format(0, '', ' ') }}
Среднее организаций на пользователя
{{ totalUsers > 0 ? (totalOrgs / totalUsers)|round(2) : 0 }}
Запомненные устройства: Если вы отметили "Запомнить меня" при входе, устройство будет автоматически авторизовано в течение 30 дней. Вы можете завершить эти сессии вручную.
{% else %}
Нет активных сессий на других устройствах
{% endif %}
Рекомендации по безопасности
Используйте пароль длиной не менее 8 символов
Комбинируйте буквы, цифры и специальные символы
Не используйте один и тот же пароль для разных сервисов
// app/Views/errors/html/debug.js
var tabLinks = new Array();
var contentDivs = new Array();
function init()
{
var tabListItems = document.getElementById('tabs').childNodes;
console.log(tabListItems);
for (var i = 0; i < tabListItems.length; i ++)
{
if (tabListItems[i].nodeName == "LI")
{
var tabLink = getFirstChildWithTagName(tabListItems[i], 'A');
var id = getHash(tabLink.getAttribute('href'));
tabLinks[id] = tabLink;
contentDivs[id] = document.getElementById(id);
}
}
var i = 0;
for (var id in tabLinks)
{
tabLinks[id].onclick = showTab;
tabLinks[id].onfocus = function () {
this.blur()
};
if (i == 0)
{
tabLinks[id].className = 'active';
}
i ++;
}
var i = 0;
for (var id in contentDivs)
{
if (i != 0)
{
console.log(contentDivs[id]);
contentDivs[id].className = 'content hide';
}
i ++;
}
}
function showTab()
{
var selectedId = getHash(this.getAttribute('href'));
for (var id in contentDivs)
{
if (id == selectedId)
{
tabLinks[id].className = 'active';
contentDivs[id].className = 'content';
}
else
{
tabLinks[id].className = '';
contentDivs[id].className = 'content hide';
}
}
return false;
}
function getFirstChildWithTagName(element, tagName)
{
for (var i = 0; i < element.childNodes.length; i ++)
{
if (element.childNodes[i].nodeName == tagName)
{
return element.childNodes[i];
}
}
}
function getHash(url)
{
var hashPos = url.lastIndexOf('#');
return url.substring(hashPos + 1);
}
function toggle(elem)
{
elem = document.getElementById(elem);
if (elem.style && elem.style['display'])
{
var disp = elem.style['display'];
}
else if (elem.currentStyle)
{
var disp = elem.currentStyle['display'];
}
else if (window.getComputedStyle)
{
var disp = document.defaultView.getComputedStyle(elem, null).getPropertyValue('display');
}
elem.style.display = disp == 'block' ? 'none' : 'block';
return false;
}
// app/Views/errors/html/error_404.php
= lang('Errors.pageNotFound') ?>
{% endblock %}
// app/Views/components/alerts.twig
{# app/Views/components/alerts.twig #}
{% set alerts = get_alerts() %}
{% if alerts is not empty %}
{% for alert in alerts %}
{# Преобразуем наш тип 'error' в класс Bootstrap 'danger' #}
{% set bs_type = alert.type == 'error' ? 'danger' : alert.type %}
{{ alert.message }}
{% endfor %}
{% endif %}
// app/Views/components/calendar/default_event.twig
{#
default_event.twig - Событие по умолчанию для Календаря
Параметры:
- event: Объект события
Ожидаемые поля:
- id: Идентификатор
- title: Заголовок
- date: Дата события (для сравнения с today)
- color: Цвет для бордера
- url: Ссылка (опционально)
- onEventClick: JavaScript функция при клике (опционально)
#}
{% if event.url %}
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
{% else %}
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
{{ day }}
{% endfor %}
{# Сетка календаря #}
{# Пустые ячейки до первого дня #}
{% for i in 0..(firstDayOfWeek - 1) %}
{% endfor %}
{# Дни месяца #}
{% for day in 1..daysInMonth %}
{% set dateStr = currentMonth ~ '-' ~ (day < 10 ? '0' ~ day : day) %}
{% set dayEvents = eventsByDate[dateStr]|default([]) %}
{% set isToday = dateStr == today %}
{{ day }}
{% for event in dayEvents|slice(0, 3) %}
{% if eventComponent is defined %}
{{ include(eventComponent, {event: event}) }}
{% else %}
{{ include('@components/calendar/default_event.twig', {event: event, onEventClick: onEventClick|default('')}) }}
{% endif %}
{% endfor %}
{% if dayEvents|length > 3 %}
+{{ dayEvents|length - 3 }} ещё
{% endif %}
{% endfor %}
{# Пустые ячейки после последнего дня #}
{% set remainingCells = 7 - ((firstDayOfWeek + daysInMonth) % 7) %}
{% if remainingCells < 7 %}
{% for i in 1..remainingCells %}
{% endfor %}
{% endif %}
{% block stylesheets %}
{% endblock %}
{% if showLegend|default(true) and (legend is defined or events is defined) %}
Легенда
{% if legend is defined %}
{% for item in legend %}
{{ item.name }}
{% endfor %}
{% else %}
{# Автоматическая легенда из типов событий #}
{% set uniqueColors = {} %}
{% for event in events %}
{% if event.color is defined and event.color not in uniqueColors %}
{{ event.title }}
{% set uniqueColors = uniqueColors|merge([event.color]) %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
// app/Views/components/table/macros.twig
{#
macros.twig - Универсальные макросы для таблиц
Макросы:
- render_actions(actions): Рендерит кнопки действий для строки таблицы
#}
{% macro render_actions(actions) %}
{% endmacro %}
// app/Views/components/table/pagination.twig
{#
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 %}
{# Информация о количестве записей #}
{{ infoText }}
{# Кнопки навигации - посередине #}
{# Выбор количества записей - справа #}
{% 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 %}
{{ client.name }}
{{ client.email }}
{% endfor %}
```
### Кастомные строки
Компонент поддерживает произвольное содержимое ячеек через параметр `rows`:
```twig
{% set rows = [] %}
{% for product in products %}
{% set rows = rows|merge([{
cells: [
{ content: '' ~ product.name ~ '', class: '' },
{ content: product.price ~ ' ₽', class: 'text-end' }
],
actions: 'Редактировать'
}]) %}
{% endfor %}
{{ include('@components/table/table.twig', {
id: 'products-table',
rows: rows,
columns: columns,
...
}) }}
```
// app/Views/components/table/table.twig
{#
table.twig - Универсальный компонент таблицы с AJAX-загрузкой
Параметры:
- id: ID контейнера таблицы (обязательно)
- url: URL для AJAX-загрузки данных (обязательно)
- perPage: Количество записей на странице (по умолчанию 10)
- columns: Конфигурация колонок
Пример:
columns: {
name: { label: 'Имя', width: '40%' },
email: { label: 'Email' }
}
- items: Массив объектов для отображения
- actionsConfig: Конфигурация действий строки
Пример:
actionsConfig: [
{ label: 'Ред.', url: '/clients/edit/{id}', icon: 'bi bi-pencil', class: 'btn-outline-primary' },
{ label: 'Удалить', url: '/clients/delete/{id}', icon: 'bi bi-trash', class: 'btn-outline-danger' }
]
- can_edit: Разрешено ли редактирование (для фильтрации действий)
- can_delete: Разрешено ли удаление (для фильтрации действий)
- onRowClick: JavaScript функция для обработки клика по строке (опционально)
- emptyMessage: Сообщение при отсутствии данных
- emptyActionUrl: URL для кнопки действия
- emptyActionLabel: Текст кнопки
- emptyIcon: FontAwesome иконка
- tableClass: Дополнительные классы для таблицы
#}
{# Заголовок таблицы #}
{{ include('@components/table/table_header.twig', {
columns: columns,
sort: sort|default(''),
order: order|default('asc'),
filters: filters|default({}),
actions: actions|default(false)
}) }}
{# Тело таблицы #}
{% if items is defined and items|length > 0 %}
{% for item in items %}
{# Рендерим каждую колонку #}
{% for key, column in columns %}
{{ render_cell(item, key, column)|raw }}
{% endfor %}
{# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %}
{# Фильтруем действия на основе прав доступа #}
{% set visibleActions = [] %}
{% for action in actionsConfig %}
{% set showAction = true %}
{% if action.type is defined %}
{% if action.type == 'edit' and not (can_edit|default(true)) %}
{% set showAction = false %}
{% elseif action.type == 'delete' and not (can_delete|default(true)) %}
{% set showAction = false %}
{% endif %}
{% endif %}
{% if showAction %}
{% set visibleActions = visibleActions|merge([action]) %}
{% endif %}
{% endfor %}
{% if visibleActions|length > 0 %}
{{ render_actions(item, visibleActions)|raw }}
{% else %}
—
{% endif %}
{{ include('@components/table/pagination.twig', {
pagination: pagerDetails,
id: id
}) }}
// app/Views/components/table/ajax_table.twig
{% if items is defined and items|length > 0 %}
{% for item in items %}
{# Рендерим каждую колонку #}
{% for key, column in columns %}
{{ render_cell(item, key, column)|raw }}
{% endfor %}
{# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %}
{# Фильтруем действия на основе прав доступа #}
{% set visibleActions = [] %}
{% for action in actionsConfig %}
{% set showAction = true %}
{% if action.type is defined %}
{% if action.type == 'edit' and not (can_edit|default(true)) %}
{% set showAction = false %}
{% elseif action.type == 'delete' and not (can_delete|default(true)) %}
{% set showAction = false %}
{% endif %}
{% endif %}
{% if showAction %}
{% set visibleActions = visibleActions|merge([action]) %}
{% endif %}
{% endfor %}
{% if visibleActions|length > 0 %}
{{ render_actions(item, visibleActions)|raw }}
{% else %}
—
{% endif %}
// app/Views/components/kanban/default_card.twig
{#
default_card.twig - Карточка по умолчанию для Канбан-компонента
Параметры:
- item: Объект элемента
- column: Объект колонки (для доступа к color и т.д.)
Ожидаемые поля в item:
- id: Идентификатор
- title: Заголовок
- url: Ссылка на просмотр (опционально)
- amount: Сумма для отображения (опционально)
- date: Дата для отображения (опционально)
- assignee: Ответственный (опционально)
- status: Статус для цветовой маркировки (опционально)
#}
{# Заголовок и сумма #}
{% if item.url %}
{{ item.title }}
{% else %}
{{ item.title }}
{% endif %}
{% if item.amount is defined and item.amount %}
₽{{ item.amount|number_format(0, ',', ' ') }}
{% endif %}
{# Дополнительная информация #}
{% if item.description is defined and item.description %}
{{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }}
{% endif %}
{# Нижняя панель #}
{% if item.assignee is defined and item.assignee %}
{{ item.assignee }}
{% else %}
{% endif %}
{% if item.date is defined and item.date %}
{{ item.date|date('d.m') }}
{% endif %}
{# Теги/метки #}
{% if item.tags is defined and item.tags|length > 0 %}
{% for tag in item.tags %}
{{ tag.name }}
{% endfor %}
{% endif %}
// app/Views/macros/forms.twig
{# app/Views/macros/forms.twig #}
{% macro form_open(action, attributes = '') %}
{# Добавляем data-ajax="true" для автоматической CSRF защиты #}
{% endmacro %}
// app/.htaccess
Require all denied
Deny from all
// app/Filters/OrganizationFilter.php
get('isLoggedIn')) {
return;
}
if (empty(session()->get('active_org_id'))) {
return redirect()->to('/organizations');
}
}
/**
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
}
// app/Filters/RoleFilter.php
isSystemRole($roles)) {
return $this->forbiddenResponse();
}
return null;
}
if (is_string($arguments) && str_starts_with($arguments, 'role:')) {
$roles = explode(',', substr($arguments, 5));
$roles = array_map('trim', $roles);
if (!$access->isAuthenticated()) {
return redirect()->to('/organizations');
}
if (!$access->isRole($roles)) {
return $this->forbiddenResponse();
}
}
if (is_string($arguments) && str_starts_with($arguments, 'permission:')) {
if (!$access->isAuthenticated()) {
return redirect()->to('/organizations');
}
$parts = explode(':', substr($arguments, 11));
if (count($parts) >= 2) {
$permission = $parts[0];
$resource = $parts[1] ?? '*';
if (!$access->can($permission, $resource)) {
return $this->forbiddenResponse();
}
}
}
return null;
}
/**
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}
/**
private function forbiddenResponse(): ResponseInterface
{
if (service('request')->isAJAX()) {
return service('response')
->setStatusCode(403)
->setJSON(['error' => 'Доступ запрещён']);
}
session()->setFlashdata('error', 'У вас нет прав для выполнения этого действия');
return redirect()->to('/');
}
}
// app/Filters/AuthFilter.php
get('isLoggedIn')) {
return;
}
$userId = Auth::checkRememberToken();
if ($userId !== null) {
$userModel = new UserModel();
$user = $userModel->find($userId);
if ($user && $user['email_verified']) {
$orgUserModel = new OrganizationUserModel();
$userOrgs = $orgUserModel->where('user_id', $user['id'])->findAll();
if (!empty($userOrgs)) {
$sessionData = [
'user_id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'isLoggedIn' => true,
];
if (count($userOrgs) === 1) {
$sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
}
$session->set($sessionData);
log_message('info', "User {$user['email']} logged in via remember token");
return;
}
}
$response = service('response');
$response->deleteCookie('remember_selector');
$response->deleteCookie('remember_token');
}
return redirect()->to('/login');
}
/**
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
}
// app/Filters/ModuleSubscriptionFilter.php
get('active_org_id');
if (!$orgId) {
return;
}
if ($moduleCode === 'base') {
return;
}
$subscriptionService = new ModuleSubscriptionService();
if (!$subscriptionService->isModuleAvailable($moduleCode, $orgId)) {
$session->setFlashdata('error', 'Модуль "' . $this->getModuleName($moduleCode) . '" не активен для вашей организации');
return redirect()->to('/');
}
}
/**
protected function getModuleName(string $moduleCode): string
{
$names = [
'crm' => 'CRM',
'booking' => 'Бронирования',
'tasks' => 'Задачи',
'proof' => 'Proof',
];
return $names[$moduleCode] ?? $moduleCode;
}
/**
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
}
}
// app/Filters/.gitkeep
// app/Services/EventManager.php
moduleSubscriptionService === null) {
$this->moduleSubscriptionService = service('moduleSubscription');
}
return $this->moduleSubscriptionService;
}
/**
private function getModulesConfig(): \Config\BusinessModules
{
if ($this->modulesConfig === null) {
$this->modulesConfig = config('BusinessModules');
}
return $this->modulesConfig;
}
/**
public function forModule(string $moduleCode): self
{
$this->moduleCode = $moduleCode;
$this->moduleActive = null;
return $this;
}
/**
private function isModuleActive(): bool
{
if ($this->moduleCode === null) {
return false;
}
if ($this->moduleActive === null) {
$orgId = session('org_id') ?? null;
$this->moduleActive = $this->getModuleSubscriptionService()
->isModuleActive($this->moduleCode, $orgId);
}
return $this->moduleActive;
}
/**
public function moduleOn(
string $event,
callable $callback,
int $priority = 100
): bool {
if ($this->moduleCode === null) {
throw new \RuntimeException(
'Module code not set. Use forModule() method first.'
);
}
$modulesConfig = $this->getModulesConfig();
if (!isset($modulesConfig->modules[$this->moduleCode])) {
log_message(
'error',
"EventManager: Module '{$this->moduleCode}' not found in config"
);
return false;
}
if (isset($modulesConfig->modules[$this->moduleCode]['enabled']) &&
empty($modulesConfig->modules[$this->moduleCode]['enabled'])) {
log_message(
'info',
"EventManager: Module '{$this->moduleCode}' is disabled globally"
);
return false;
}
if (!$this->isModuleActive()) {
log_message(
'debug',
"EventManager: Organization subscription not active for module '{$this->moduleCode}'"
);
return false;
}
Events::on($event, $callback, $priority);
log_message(
'debug',
"EventManager: Subscribed to event '{$event}' for module '{$this->moduleCode}'"
);
return true;
}
/**
public function systemOn(
string $event,
callable $callback,
int $priority = 100
): void {
Events::on($event, $callback, $priority);
log_message(
'debug',
"EventManager: System event subscribed: '{$event}'"
);
}
/**
public function off(string $event, ?callable $callback = null): void
{
if ($callback === null) {
Events::off($event);
} else {
Events::off($event, $callback);
}
}
/**
public function currentModuleActive(): bool
{
return $this->isModuleActive();
}
/**
public function getCurrentModuleCode(): ?string
{
return $this->moduleCode;
}
}
// app/Services/InvitationService.php
orgUserModel = new OrganizationUserModel();
$this->orgModel = new OrganizationModel();
$this->userModel = new UserModel();
$this->baseUrl = rtrim(config('App')->baseURL, '/');
}
/**
public function createInvitation(int $organizationId, string $email, string $role, int $invitedBy): array
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return [
'success' => false,
'message' => 'Некорректный email адрес',
'invite_link' => '',
'invitation_id' => 0,
];
}
$organization = $this->orgModel->find($organizationId);
if (!$organization) {
return [
'success' => false,
'message' => 'Организация не найдена',
'invite_link' => '',
'invitation_id' => 0,
];
}
$existingUser = $this->userModel->where('email', $email)->first();
$userId = $existingUser['id'] ?? null;
if ($userId) {
$existingMembership = $this->orgUserModel
->where('organization_id', $organizationId)
->where('user_id', $userId)
->first();
if ($existingMembership) {
return [
'success' => false,
'message' => 'Пользователь уже состоит в этой организации',
'invite_link' => '',
'invitation_id' => 0,
];
}
if ($this->orgUserModel->hasPendingInvite($organizationId, $userId)) {
return [
'success' => false,
'message' => 'Приглашение для этого пользователя уже отправлено',
'invite_link' => '',
'invitation_id' => 0,
];
}
}
$inviteToken = $this->generateToken();
$inviteExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$invitationData = [
'organization_id' => $organizationId,
'user_id' => $userId,
'role' => $role,
'status' => OrganizationUserModel::STATUS_PENDING,
'invite_token' => $inviteToken,
'invite_expires_at' => $inviteExpiresAt,
'invited_by' => $invitedBy,
];
$invitationId = $this->orgUserModel->createInvitation($invitationData);
if (!$invitationId) {
return [
'success' => false,
'message' => 'Ошибка при создании приглашения',
'invite_link' => '',
'invitation_id' => 0,
];
}
if (!$existingUser) {
$this->createShadowUser($email);
}
$inviteLink = $this->baseUrl . '/invitation/accept/' . $inviteToken;
$emailSent = $this->sendInvitationEmail($email, $organization['name'], $role, $inviteLink);
return [
'success' => $emailSent,
'message' => $emailSent
? 'Приглашение успешно отправлено'
: 'Приглашение создано, но не удалось отправить email',
'invite_link' => $inviteLink,
'invitation_id' => $invitationId,
];
}
/**
public function acceptInvitation(string $token, int $userId): array
{
$invitation = $this->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return [
'success' => false,
'message' => 'Приглашение не найдено или уже обработано',
];
}
$updated = $this->orgUserModel->acceptInvitation($invitation['id'], $userId);
if (!$updated) {
return [
'success' => false,
'message' => 'Ошибка при принятии приглашения',
];
}
if ($invitation['user_id'] === null) {
$this->bindShadowUser($invitation['organization_id'], $userId);
}
$organization = $this->orgModel->find($invitation['organization_id']);
return [
'success' => true,
'message' => 'Приглашение принято',
'organization_id' => $invitation['organization_id'],
'organization_name' => $organization['name'] ?? '',
];
}
/**
public function declineInvitation(string $token): array
{
$invitation = $this->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return [
'success' => false,
'message' => 'Приглашение не найдено или уже обработано',
];
}
$deleted = $this->orgUserModel->declineInvitation($invitation['id']);
return [
'success' => $deleted,
'message' => $deleted ? 'Приглашение отклонено' : 'Ошибка при отклонении приглашения',
];
}
/**
public function cancelInvitation(int $invitationId, int $organizationId): array
{
$invitation = $this->orgUserModel
->where('id', $invitationId)
->where('organization_id', $organizationId)
->where('status', OrganizationUserModel::STATUS_PENDING)
->first();
if (!$invitation) {
return [
'success' => false,
'message' => 'Приглашение не найдено',
];
}
$deleted = $this->orgUserModel->cancelInvitation($invitationId);
return [
'success' => $deleted,
'message' => $deleted ? 'Приглашение отозвано' : 'Ошибка при отзыве приглашения',
];
}
/**
public function resendInvitation(int $invitationId, int $organizationId): array
{
$invitation = $this->orgUserModel
->where('id', $invitationId)
->where('organization_id', $organizationId)
->where('status', OrganizationUserModel::STATUS_PENDING)
->first();
if (!$invitation) {
return [
'success' => false,
'message' => 'Приглашение не найдено',
];
}
$newToken = $this->generateToken();
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
$this->orgUserModel->update($invitationId, [
'invite_token' => $newToken,
'invite_expires_at' => $newExpiresAt,
'invited_at' => date('Y-m-d H:i:s'),
]);
$user = $this->userModel->find($invitation['user_id']);
if (!$user) {
return [
'success' => false,
'message' => 'Пользователь не найден',
];
}
$organization = $this->orgModel->find($organizationId);
$inviteLink = $this->baseUrl . '/invitation/accept/' . $newToken;
$sent = $this->sendInvitationEmail(
$user['email'],
$organization['name'],
$invitation['role'],
$inviteLink
);
return [
'success' => $sent,
'message' => $sent ? 'Приглашение отправлено повторно' : 'Ошибка отправки',
'invite_link' => $inviteLink,
];
}
/**
protected function generateToken(): string
{
do {
$token = bin2hex(random_bytes(32));
$exists = $this->orgUserModel->where('invite_token', $token)->first();
} while ($exists);
return $token;
}
/**
protected function createShadowUser(string $email): int
{
$token = bin2hex(random_bytes(32));
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
return $this->userModel->insert([
'email' => $email,
'name' => '',
'password' => null,
'email_verified' => 0,
'verification_token' => $token,
'token_expires_at' => $tokenExpiresAt,
'created_at' => date('Y-m-d H:i:s'),
]);
}
/**
protected function bindShadowUser(int $organizationId, int $userId): void
{
$user = $this->userModel->find($userId);
if ($user && empty($user['password'])) {
$this->orgUserModel
->where('user_id', null)
->where('status', OrganizationUserModel::STATUS_PENDING)
->set(['user_id' => $userId])
->update();
}
}
/**
protected function sendInvitationEmail(string $email, string $orgName, string $role, string $inviteLink): bool
{
$roleLabels = [
'owner' => 'Владелец',
'admin' => 'Администратор',
'manager' => 'Менеджер',
'guest' => 'Гость',
];
$roleLabel = $roleLabels[$role] ?? $role;
$emailService = service('email');
$emailService->setTo($email);
$emailService->setSubject('Приглашение в организацию ' . $orgName);
$message = <<
Приглашение в Бизнес.Точка
Вас приглашают присоединиться к организации {$orgName}
Ваша роль: {$roleLabel}
Нажмите кнопку ниже, чтобы принять или отклонить приглашение:
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
{{ day }}
{% endfor %}
{# Календарная сетка #}
{% set firstDay = firstDayOfWeek %}
{% set daysInMonth = daysInMonth %}
{# Пустые ячейки до первого дня #}
{% for i in 0..(firstDay - 1) %}
{% endfor %}
{# Дни месяца #}
{% for day in 1..daysInMonth %}
{% set dateStr = currentMonth ~ '-' ~ (day < 10 ? '0' ~ day : day) %}
{% set isToday = dateStr == today %}
{% set isPast = dateStr < today %}
{% set dayEvents = eventsByDate[dateStr]|default([]) %}
{{ day }}
{% if dayEvents|length > 0 %}
{{ dayEvents|length }}
{% endif %}
{% endfor %}
{# Пустые ячейки после последнего дня #}
{% set remaining = 7 - ((firstDay + daysInMonth) % 7) %}
{% if remaining < 7 %}
{% for i in 1..remaining %}
{% endfor %}
{% endif %}
{% endblock %}
{% block scripts %}
{% endblock %}
// app/Modules/Tasks/Services/TaskBoardService.php
boardModel = new TaskBoardModel();
$this->columnModel = new TaskColumnModel();
}
/**
public function createBoard(array $data, int $userId): int
{
$boardId = $this->boardModel->insert($data);
if ($boardId) {
$this->columnModel->createDefaultColumns($boardId);
}
return $boardId;
}
/**
public function updateBoard(int $boardId, array $data, int $organizationId): bool
{
$board = $this->boardModel->getBoard($boardId, $organizationId);
if (!$board) {
return false;
}
return $this->boardModel->update($boardId, $data);
}
/**
public function deleteBoard(int $boardId, int $organizationId): bool
{
$board = $this->boardModel->getBoard($boardId, $organizationId);
if (!$board) {
return false;
}
return $this->boardModel->delete($boardId);
}
/**
public function getBoardWithColumns(int $boardId, int $organizationId): ?array
{
$board = $this->boardModel->getBoard($boardId, $organizationId);
if (!$board) {
return null;
}
$board['columns'] = $this->columnModel->getColumnsByBoard($boardId);
return $board;
}
/**
public function getOrganizationBoards(int $organizationId): array
{
return $this->boardModel->getBoardsByOrganization($organizationId);
}
/**
public function createColumn(int $boardId, array $data): int
{
$data['board_id'] = $boardId;
$data['order_index'] = $this->columnModel->getNextOrderIndex($boardId);
return $this->columnModel->insert($data);
}
/**
public function updateColumn(int $columnId, array $data): bool
{
return $this->columnModel->update($columnId, $data);
}
/**
public function deleteColumn(int $columnId, int $boardId): bool
{
$column = $this->columnModel->find($columnId);
if (!$column || $column['board_id'] !== $boardId) {
return false;
}
return $this->columnModel->delete($columnId);
}
/**
public function reorderColumns(array $columnOrders): bool
{
foreach ($columnOrders as $index => $columnId) {
$this->columnModel->update($columnId, ['order_index' => $index]);
}
return true;
}
}
// app/Modules/Tasks/Services/TaskService.php
taskModel = new TaskModel();
$this->assigneeModel = new TaskAssigneeModel();
$this->columnModel = new TaskColumnModel();
}
/**
public function getModel(): TaskModel
{
return $this->taskModel;
}
/**
public function createTask(array $data, int $userId, array $assigneeIds = []): int
{
$data['created_by'] = $userId;
$taskId = $this->taskModel->insert($data);
if ($taskId) {
foreach ($assigneeIds as $userId) {
$this->assigneeModel->addAssignee($taskId, (int)$userId);
}
Events::trigger('tasks.created', $taskId, $data, $userId);
}
return $taskId;
}
/**
public function updateTask(int $taskId, array $data, int $userId): bool
{
$oldTask = $this->taskModel->find($taskId);
if (!$oldTask) {
return false;
}
$result = $this->taskModel->update($taskId, $data);
if ($result) {
Events::trigger('tasks.updated', $taskId, $data, $userId);
}
return $result;
}
/**
public function changeColumn(int $taskId, int $newColumnId, int $userId): bool
{
$task = $this->taskModel->find($taskId);
if (!$task) {
return false;
}
$newColumn = $this->columnModel->find($newColumnId);
if (!$newColumn) {
return false;
}
$oldColumnId = $task['column_id'];
$data = ['column_id' => $newColumnId];
if ($newColumn['name'] !== 'Завершено') {
$data['completed_at'] = null;
}
$result = $this->taskModel->update($taskId, $data);
if ($result) {
Events::trigger('tasks.column_changed', $taskId, $oldColumnId, $newColumnId, $userId);
}
return $result;
}
/**
public function completeTask(int $taskId, int $userId): bool
{
$task = $this->taskModel->find($taskId);
if (!$task) {
return false;
}
$result = $this->taskModel->update($taskId, [
'completed_at' => date('Y-m-d H:i:s'),
]);
if ($result) {
Events::trigger('tasks.completed', $taskId, $userId);
}
return $result;
}
/**
public function reopenTask(int $taskId, int $userId): bool
{
$task = $this->taskModel->find($taskId);
if (!$task) {
return false;
}
$result = $this->taskModel->update($taskId, [
'completed_at' => null,
]);
if ($result) {
Events::trigger('tasks.reopened', $taskId, $userId);
}
return $result;
}
/**
public function deleteTask(int $taskId, int $userId): bool
{
$task = $this->taskModel->find($taskId);
if (!$task) {
return false;
}
$result = $this->taskModel->delete($taskId);
if ($result) {
Events::trigger('tasks.deleted', $taskId, $userId);
}
return $result;
}
/**
public function getTask(int $taskId, int $organizationId): ?array
{
$task = $this->taskModel->getTask($taskId, $organizationId);
if (!$task) {
return null;
}
$task['assignees'] = $this->assigneeModel->getAssigneesByTask($taskId);
return $task;
}
/**
public function getTasksForKanban(int $boardId): array
{
return $this->taskModel->getTasksGroupedByColumn($boardId);
}
/**
public function getTasksForCalendar(int $organizationId, string $month): array
{
return $this->taskModel->getTasksForCalendar($organizationId, $month);
}
/**
public function getStats(int $organizationId): array
{
return $this->taskModel->getTaskStats($organizationId);
}
/**
public function updateAssignees(int $taskId, array $userIds): bool
{
$this->assigneeModel->where('task_id', $taskId)->delete();
foreach ($userIds as $userId) {
$this->assigneeModel->addAssignee($taskId, (int)$userId);
}
return true;
}
/**
public function createFromEvent(string $eventType, array $eventData, int $organizationId): ?int
{
$taskData = [
'organization_id' => $organizationId,
'board_id' => $this->getDefaultBoardId($organizationId),
'column_id' => $this->getFirstColumnId($organizationId),
'title' => $eventData['title'] ?? 'Задача',
'description' => $eventData['description'] ?? '',
'priority' => $eventData['priority'] ?? 'medium',
'due_date' => $eventData['due_date'] ?? null,
];
$assignees = $eventData['assignees'] ?? [];
return $this->createTask($taskData, $eventData['created_by'] ?? 1, $assignees);
}
/**
protected function getDefaultBoardId(int $organizationId): int
{
$boardModel = new TaskBoardModel();
$board = $boardModel->getDefaultBoard($organizationId);
if (!$board) {
return $boardModel->createDefaultBoard($organizationId);
}
return $board['id'];
}
/**
protected function getFirstColumnId(int $organizationId): int
{
$boardId = $this->getDefaultBoardId($organizationId);
$columns = $this->columnModel->getColumnsByBoard($boardId);
return $columns[0]['id'] ?? 1;
}
}
// app/Modules/Tasks/Models/TaskAssigneeModel.php
select('task_assignees.*, users.name as user_name, users.email as user_email')
->join('users', 'task_assignees.user_id = users.id', 'left')
->where('task_id', $taskId)
->findAll();
}
/**
public function getTasksByUser(int $userId, int $organizationId): array
{
return $this->select('tasks.*')
->join('tasks', 'task_assignees.task_id = tasks.id')
->where('task_assignees.user_id', $userId)
->where('tasks.organization_id', $organizationId)
->findAll();
}
/**
public function addAssignee(int $taskId, int $userId, string $role = 'assignee'): int
{
return $this->insert([
'task_id' => $taskId,
'user_id' => $userId,
'role' => $role,
'assigned_at' => date('Y-m-d H:i:s'),
]);
}
/**
public function removeAssignee(int $taskId, int $userId): bool
{
return $this->where('task_id', $taskId)
->where('user_id', $userId)
->delete() > 0;
}
/**
public function isAssignee(int $taskId, int $userId): bool
{
return $this->where('task_id', $taskId)
->where('user_id', $userId)
->countAllResults() > 0;
}
}
// app/Modules/Tasks/Models/TaskModel.php
select('
tasks.id,
tasks.title,
tasks.description,
tasks.priority,
tasks.due_date,
tasks.completed_at,
tasks.created_at,
tc.name as column_name,
tc.color as column_color,
u.name as created_by_name
')
->join('task_columns tc', 'tasks.column_id = tc.id', 'left')
->join('users u', 'tasks.created_by = u.id', 'left')
->where('tasks.organization_id', $organizationId)
->orderBy('tasks.created_at', 'DESC')
->findAll();
}
/**
public function getTasksGroupedByColumn(int $boardId): array
{
$tasks = $this->select('tasks.*, tc.name as column_name, tc.color as column_color')
->join('task_columns tc', 'tasks.column_id = tc.id', 'left')
->where('tasks.board_id', $boardId)
->orderBy('tc.order_index', 'ASC')
->orderBy('tasks.order_index', 'ASC')
->orderBy('tasks.created_at', 'DESC')
->findAll();
$grouped = [];
foreach ($tasks as $task) {
$columnId = $task['column_id'] ?? 0;
if (!isset($grouped[$columnId])) {
$grouped[$columnId] = [
'column_name' => $task['column_name'] ?? 'Без колонки',
'column_color' => $task['column_color'] ?? '#6B7280',
'tasks' => [],
];
}
$grouped[$columnId]['tasks'][] = $task;
}
return $grouped;
}
/**
public function getTasksForCalendar(int $organizationId, string $month): array
{
return $this->select('tasks.*, tc.color as column_color, tc.name as column_name')
->join('task_columns tc', 'tasks.column_id = tc.id', 'left')
->where('tasks.organization_id', $organizationId)
->where('tasks.due_date >=', date('Y-m-01', strtotime($month)))
->where('tasks.due_date <=', date('Y-m-t', strtotime($month)))
->where('tasks.completed_at', null)
->orderBy('tasks.due_date', 'ASC')
->findAll();
}
/**
public function getTask(int $taskId, int $organizationId): ?array
{
return $this->select('tasks.*, tc.name as column_name, tc.color as column_color')
->join('task_columns tc', 'tasks.column_id = tc.id', 'left')
->where('tasks.id', $taskId)
->where('tasks.organization_id', $organizationId)
->first();
}
/**
public function getTaskStats(int $organizationId): array
{
$total = $this->select('COUNT(*) as count')
->where('organization_id', $organizationId)
->countAllResults();
$completed = $this->select('COUNT(*) as count')
->where('organization_id', $organizationId)
->where('completed_at IS NOT NULL')
->countAllResults();
$overdue = $this->select('COUNT(*) as count')
->where('organization_id', $organizationId)
->where('completed_at', null)
->where('due_date <', date('Y-m-d'))
->countAllResults();
return [
'total' => $total,
'completed' => $completed,
'overdue' => $overdue,
'pending' => $total - $completed,
];
}
}
// app/Modules/Tasks/Models/TaskColumnModel.php
where('board_id', $boardId)
->orderBy('order_index', 'ASC')
->findAll();
}
/**
public function getNextOrderIndex(int $boardId): int
{
$max = $this->selectMax('order_index')
->where('board_id', $boardId)
->first();
return ($max['order_index'] ?? 0) + 1;
}
/**
public function createDefaultColumns(int $boardId): bool
{
$defaultColumns = [
[
'board_id' => $boardId,
'name' => 'К выполнению',
'color' => '#6B7280',
'order_index' => 1,
'is_default' => 0,
],
[
'board_id' => $boardId,
'name' => 'В работе',
'color' => '#3B82F6',
'order_index' => 2,
'is_default' => 0,
],
[
'board_id' => $boardId,
'name' => 'На проверке',
'color' => '#F59E0B',
'order_index' => 3,
'is_default' => 0,
],
[
'board_id' => $boardId,
'name' => 'Завершено',
'color' => '#10B981',
'order_index' => 4,
'is_default' => 0,
],
];
return $this->insertBatch($defaultColumns);
}
}
// app/Modules/Tasks/Models/TaskBoardModel.php
where('organization_id', $organizationId)
->orderBy('is_default', 'DESC')
->orderBy('created_at', 'DESC')
->findAll();
}
/**
public function getBoard(int $boardId, int $organizationId): ?array
{
return $this->where('id', $boardId)
->where('organization_id', $organizationId)
->first();
}
/**
public function getDefaultBoard(int $organizationId): ?array
{
return $this->where('organization_id', $organizationId)
->where('is_default', 1)
->first();
}
/**
public function createDefaultBoard(int $organizationId): int
{
$data = [
'organization_id' => $organizationId,
'name' => 'Мои задачи',
'description' => 'Основная доска задач',
'is_default' => 1,
];
$boardId = $this->insert($data);
if ($boardId) {
$columnModel = new TaskColumnModel();
$columnModel->createDefaultColumns($boardId);
}
return $boardId;
}
}
// app/Modules/Tasks/Config/Routes.php
group('tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' => 'App\Modules\Tasks\Controllers'], static function ($routes) {
$routes->get('/', 'TasksController::index');
$routes->get('table', 'TasksController::table');
$routes->get('kanban', 'TasksController::kanban');
$routes->get('calendar', 'TasksController::calendar');
$routes->get('new', 'TasksController::create');
$routes->get('create', 'TasksController::create');
$routes->post('/', 'TasksController::store');
$routes->get('(:num)', 'TasksController::show/$1');
$routes->get('(:num)/edit', 'TasksController::edit/$1');
$routes->post('(:num)', 'TasksController::update/$1');
$routes->get('(:num)/delete', 'TasksController::destroy/$1');
$routes->post('move-column', 'TasksController::moveColumn');
$routes->post('(:num)/complete', 'TasksController::complete/$1');
$routes->post('(:num)/reopen', 'TasksController::reopen/$1');
});
$routes->group('api/tasks', ['filter' => ['org', 'subscription:tasks'], 'namespace' => 'App\Modules\Tasks\Controllers'], static function ($routes) {
$routes->get('columns', 'TaskApiController::getColumns');
});
// app/Modules/Tasks/Config/Events.php
createTask([
'title' => 'Проверить новую сделку: ' . ($data['deal']['name'] ?? 'Без названия'),
'description' => 'Автоматически созданная задача для проверки новой сделки #' . $data['deal_id'],
'board_id' => null,
'assigned_to' => $data['user_id'],
'due_date' => date('Y-m-d', strtotime('+1 day')),
'priority' => 'medium',
'metadata' => json_encode([
'source' => 'deal.created',
'deal_id' => $data['deal_id'],
'created_at' => date('Y-m-d H:i:s'),
]),
]);
});
/**
Events::on('deal.stage_changed', function (array $data) {
$taskService = service('taskService');
$oldStageName = $data['old_stage']['name'] ?? 'Неизвестно';
$newStageName = $data['new_stage']['name'] ?? 'Неизвестно';
$taskConfig = getAutoTaskConfig($data['old_stage_id'], $data['new_stage_id'], $data['deal']);
if ($taskConfig) {
$taskService->createTask([
'title' => $taskConfig['title'],
'description' => $taskConfig['description'],
'board_id' => $taskConfig['board_id'] ?? null,
'assigned_to' => $taskConfig['assigned_to'] ?? $data['user_id'],
'due_date' => $taskConfig['due_date'] ?? null,
'priority' => $taskConfig['priority'] ?? 'medium',
'metadata' => json_encode([
'source' => 'deal.stage_changed',
'deal_id' => $data['deal_id'],
'old_stage_id' => $data['old_stage_id'],
'new_stage_id' => $data['new_stage_id'],
'transition' => $oldStageName . ' → ' . $newStageName,
'created_at' => date('Y-m-d H:i:s'),
]),
]);
}
});
/**
Events::on('deal.updated', function (array $data) {
log_message('info', 'Deal updated: ' . $data['deal_id'] . ' by user: ' . $data['user_id']);
});
/**
Events::on('deal.deleted', function (array $data) {
$taskService = service('taskService');
log_message('info', 'Deal deleted: ' . $data['deal_id'] . '. Consider cleaning up related tasks.');
});
});
/**
function getAutoTaskConfig(int $oldStageId, int $newStageId, array $dealData): ?array
{
$taskConfigs = [
'won_stage' => [
'title' => 'Подготовить документы для закрытой сделки',
'description' => 'Сделка "' . ($dealData['name'] ?? 'Без названия') . '" успешно закрыта. Необходимо подготовить закрывающие документы.',
'priority' => 'high',
'due_days' => 3,
],
'negotiation' => [
'title' => 'Провести переговоры по сделке',
'description' => 'Сделка переведена на этап переговоров. Требуется связаться с клиентом.',
'priority' => 'medium',
'due_days' => 2,
],
'contract' => [
'title' => 'Подготовить договор',
'description' => 'Сделка переведена на этап договора. Необходимо подготовить и отправить договор клиенту.',
'priority' => 'high',
'due_days' => 1,
],
];
return null;
}
// app/Modules/Tasks/Controllers/TasksController.php
taskService = new TaskService();
$this->boardService = new TaskBoardService();
}
/**
public function index()
{
$organizationId = $this->requireActiveOrg();
return $this->renderTwig('@Tasks/tasks/index', [
'title' => 'Задачи',
'tableHtml' => $this->renderTable($this->getTableConfig()),
'stats' => $this->taskService->getStats($organizationId),
'boards' => $this->boardService->getOrganizationBoards($organizationId),
]);
}
/**
public function table(?array $config = null, ?string $pageUrl = null)
{
return parent::table($this->getTableConfig(), '/tasks');
}
/**
protected function getTableConfig(): array
{
$organizationId = $this->getActiveOrgId();
return [
'id' => 'tasks-table',
'url' => '/tasks/table',
'model' => $this->taskService->getModel(),
'columns' => [
'title' => [
'label' => 'Задача',
'width' => '35%',
],
'column_name' => [
'label' => 'Статус',
'width' => '15%',
],
'priority' => [
'label' => 'Приоритет',
'width' => '10%',
],
'due_date' => [
'label' => 'Срок',
'width' => '10%',
],
'created_by_name' => [
'label' => 'Автор',
'width' => '15%',
],
],
'searchable' => ['title', 'column_name', 'created_by_name'],
'sortable' => ['title', 'priority', 'due_date', 'created_at', 'column_name'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '15%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/tasks/{id}',
'icon' => 'fa-solid fa-eye',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Просмотр',
],
[
'label' => '',
'url' => '/tasks/{id}/edit',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Редактировать',
'type' => 'edit',
],
[
'label' => '',
'url' => '/tasks/{id}/delete',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger btn-sm',
'title' => 'Удалить',
'type' => 'delete',
],
],
'emptyMessage' => 'Задач пока нет',
'emptyIcon' => 'fa-solid fa-check-square',
'emptyActionUrl' => '/tasks/new',
'emptyActionLabel' => 'Создать задачу',
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => true,
'can_delete' => true,
'fieldMap' => [
'column_name' => 'tc.name',
'created_by_name' => 'u.name',
],
'scope' => function($builder) use ($organizationId) {
$builder->from('tasks')
->select('tasks.id, tasks.title, tasks.description, tasks.priority, tasks.due_date, tasks.completed_at, tasks.created_at, tc.name as column_name, tc.color as column_color, u.name as created_by_name')
->join('task_columns tc', 'tasks.column_id = tc.id', 'left')
->join('users u', 'tasks.created_by = u.id', 'left')
->where('tasks.organization_id', $organizationId);
},
];
}
/**
public function kanban()
{
$organizationId = $this->requireActiveOrg();
$boardId = (int) ($this->request->getGet('board') ?? 0);
if (!$boardId) {
$boards = $this->boardService->getOrganizationBoards($organizationId);
$boardId = $boards[0]['id'] ?? 0;
}
$board = $this->boardService->getBoardWithColumns($boardId, $organizationId);
if (!$board) {
return redirect()->to('/tasks')->with('error', 'Доска не найдена');
}
$kanbanData = $this->taskService->getTasksForKanban($boardId);
$kanbanColumns = [];
foreach ($board['columns'] as $column) {
$columnTasks = $kanbanData[$column['id']]['tasks'] ?? [];
$kanbanColumns[] = [
'id' => $column['id'],
'name' => $column['name'],
'color' => $column['color'],
'items' => $columnTasks,
];
}
return $this->renderTwig('@Tasks/tasks/kanban', [
'title' => 'Задачи — Канбан',
'kanbanColumns' => $kanbanColumns,
'board' => $board,
'boards' => $this->boardService->getOrganizationBoards($organizationId),
'stats' => $this->taskService->getStats($organizationId),
]);
}
/**
public function calendar()
{
$organizationId = $this->requireActiveOrg();
$month = $this->request->getGet('month') ?? date('Y-m');
$currentTimestamp = strtotime($month . '-01');
$daysInMonth = date('t', $currentTimestamp);
$firstDayOfWeek = date('N', $currentTimestamp) - 1;
$tasks = $this->taskService->getTasksForCalendar($organizationId, $month);
$eventsByDate = [];
foreach ($tasks as $task) {
if ($task['due_date']) {
$dateKey = date('Y-m-d', strtotime($task['due_date']));
$eventsByDate[$dateKey][] = [
'id' => $task['id'],
'title' => $task['title'],
'date' => $task['due_date'],
'column_color' => $task['column_color'] ?? '#6B7280',
'priority' => $task['priority'],
'url' => '/tasks/' . $task['id'],
];
}
}
return $this->renderTwig('@Tasks/tasks/calendar', [
'title' => 'Задачи — Календарь',
'eventsByDate' => $eventsByDate,
'currentMonth' => $month,
'monthName' => date('F Y', $currentTimestamp),
'daysInMonth' => $daysInMonth,
'firstDayOfWeek' => $firstDayOfWeek,
'prevMonth' => date('Y-m', strtotime('-1 month', $currentTimestamp)),
'nextMonth' => date('Y-m', strtotime('+1 month', $currentTimestamp)),
'today' => date('Y-m-d'),
'stats' => $this->taskService->getStats($organizationId),
]);
}
/**
public function create()
{
$organizationId = $this->requireActiveOrg();
$boardId = (int) ($this->request->getGet('board') ?? 0);
$boards = $this->boardService->getOrganizationBoards($organizationId);
if (empty($boards)) {
$boardId = $this->boardService->createBoard([
'organization_id' => $organizationId,
'name' => 'Мои задачи',
'description' => 'Основная доска задач',
], $this->getCurrentUserId());
$boards = $this->boardService->getOrganizationBoards($organizationId);
}
if (!$boardId && !empty($boards)) {
$boardId = $boards[0]['id'];
}
$orgUserModel = new OrganizationUserModel();
$orgUsers = $orgUserModel->getOrganizationUsers($organizationId);
$users = [];
foreach ($orgUsers as $user) {
$users[$user['user_id']] = $user['user_name'] ?: $user['user_email'];
}
return $this->renderTwig('@Tasks/tasks/form', [
'title' => 'Новая задача',
'actionUrl' => '/tasks',
'boards' => $boards,
'selectedBoard' => $boardId,
'users' => $users,
'currentUserId' => $this->getCurrentUserId(),
'priorities' => [
'low' => 'Низкий',
'medium' => 'Средний',
'high' => 'Высокий',
'urgent' => 'Срочный',
],
]);
}
/**
public function store()
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$data = [
'organization_id' => $organizationId,
'board_id' => $this->request->getPost('board_id'),
'column_id' => $this->request->getPost('column_id'),
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'priority' => $this->request->getPost('priority') ?? 'medium',
'due_date' => $this->request->getPost('due_date') ?: null,
];
$taskId = $this->taskService->createTask($data, $userId);
if ($taskId) {
$assignees = $this->request->getPost('assignees') ?? [];
if (!empty($assignees)) {
$this->taskService->updateAssignees($taskId, $assignees);
}
return redirect()->to('/tasks')->with('success', 'Задача успешно создана');
}
return redirect()->back()->with('error', 'Ошибка при создании задачи')->withInput();
}
/**
public function show(int $id)
{
$organizationId = $this->requireActiveOrg();
$task = $this->taskService->getTask($id, $organizationId);
if (!$task) {
return redirect()->to('/tasks')->with('error', 'Задача не найдена');
}
return $this->renderTwig('@Tasks/tasks/show', [
'title' => $task['title'],
'task' => (object) $task,
]);
}
/**
public function edit(int $id)
{
$organizationId = $this->requireActiveOrg();
$task = $this->taskService->getTask($id, $organizationId);
if (!$task) {
return redirect()->to('/tasks')->with('error', 'Задача не найдена');
}
$orgUserModel = new OrganizationUserModel();
$orgUsers = $orgUserModel->getOrganizationUsers($organizationId);
$users = [];
foreach ($orgUsers as $user) {
$users[$user['user_id']] = $user['user_name'] ?: $user['user_email'];
}
$boards = $this->boardService->getOrganizationBoards($organizationId);
return $this->renderTwig('@Tasks/tasks/form', [
'title' => 'Редактирование задачи',
'actionUrl' => "/tasks/{$id}",
'task' => (object) $task,
'boards' => $boards,
'users' => $users,
'currentUserId' => $this->getCurrentUserId(),
'priorities' => [
'low' => 'Низкий',
'medium' => 'Средний',
'high' => 'Высокий',
'urgent' => 'Срочный',
],
]);
}
/**
public function update(int $id)
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$data = [
'board_id' => $this->request->getPost('board_id'),
'column_id' => $this->request->getPost('column_id'),
'title' => $this->request->getPost('title'),
'description' => $this->request->getPost('description'),
'priority' => $this->request->getPost('priority') ?? 'medium',
'due_date' => $this->request->getPost('due_date') ?: null,
];
$result = $this->taskService->updateTask($id, $data, $userId);
if ($result) {
$assignees = $this->request->getPost('assignees') ?? [];
$this->taskService->updateAssignees($id, $assignees);
return redirect()->to("/tasks/{$id}")->with('success', 'Задача обновлена');
}
return redirect()->back()->with('error', 'Ошибка при обновлении задачи')->withInput();
}
/**
public function destroy(int $id)
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$this->taskService->deleteTask($id, $userId);
return redirect()->to('/tasks')->with('success', 'Задача удалена');
}
/**
public function moveColumn()
{
$organizationId = $this->requireActiveOrg();
$userId = $this->getCurrentUserId();
$taskId = $this->request->getPost('task_id');
$newColumnId = $this->request->getPost('column_id');
$result = $this->taskService->changeColumn($taskId, $newColumnId, $userId);
$csrfToken = csrf_hash();
$csrfHash = csrf_token();
return $this->response
->setHeader('X-CSRF-TOKEN', $csrfToken)
->setHeader('X-CSRF-HASH', $csrfHash)
->setJSON(['success' => $result]);
}
/**
public function complete(int $id)
{
$userId = $this->getCurrentUserId();
$result = $this->taskService->completeTask($id, $userId);
return $this->response->setJSON(['success' => $result]);
}
/**
public function reopen(int $id)
{
$userId = $this->getCurrentUserId();
$result = $this->taskService->reopenTask($id, $userId);
return $this->response->setJSON(['success' => $result]);
}
}
// app/Modules/Tasks/Controllers/TaskApiController.php
columnModel = new TaskColumnModel();
}
/**
public function getColumns()
{
$boardId = $this->request->getGet('board_id');
if (!$boardId) {
return $this->response->setJSON([
'success' => false,
'message' => 'board_id required',
]);
}
$columns = $this->columnModel->getColumnsByBoard((int) $boardId);
return $this->response->setJSON([
'success' => true,
'columns' => $columns,
]);
}
}
// app/Controllers/Auth.php
emailLibrary = new EmailLibrary();
try {
$this->rateLimitService = RateLimitService::getInstance();
} catch (\Exception $e) {
log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage());
$this->rateLimitService = null;
}
}
/**
protected function checkRateLimit(string $action): ?array
{
if ($this->rateLimitService === null) {
return null;
}
if ($this->rateLimitService->isBlocked($action)) {
$ttl = $this->rateLimitService->getBlockTimeLeft($action);
return [
'blocked' => true,
'message' => "Слишком много попыток. Повторите через {$ttl} секунд.",
'ttl' => $ttl,
];
}
return null;
}
/**
protected function recordFailedAttempt(string $action): ?array
{
if ($this->rateLimitService === null) {
return null;
}
$result = $this->rateLimitService->recordFailedAttempt($action);
if ($result['blocked']) {
return [
'blocked' => true,
'message' => "Превышено максимальное количество попыток. Доступ заблокирован на {$result['block_ttl']} секунд.",
'ttl' => $result['block_ttl'],
];
}
return null;
}
/**
protected function resetRateLimit(string $action): void
{
if ($this->rateLimitService !== null) {
$this->rateLimitService->resetAttempts($action);
}
}
public function register()
{
if ($this->request->getMethod() === 'POST') {
$rateLimitError = $this->checkRateLimit('register');
if ($rateLimitError !== null) {
return redirect()->back()
->with('error', $rateLimitError['message'])
->withInput();
}
log_message('debug', 'POST запрос получен: ' . print_r($this->request->getPost(), true));
$rules = [
'name' => 'required|min_length[3]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[6]',
];
if (!$this->validate($rules)) {
return redirect()->back()->with('error', 'Ошибка регистрации');
}
$userModel = new UserModel();
$orgModel = new OrganizationModel();
$orgUserModel = new OrganizationUserModel();
$verificationToken = bin2hex(random_bytes(32));
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
$userData = [
'name' => $this->request->getPost('name'),
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
'verification_token' => $verificationToken,
'token_expires_at' => $tokenExpiresAt,
'email_verified' => 0,
];
log_message('debug', 'Registration userData: ' . print_r($userData, true));
$userId = $userModel->insert($userData);
log_message('debug', 'Insert result, userId: ' . $userId);
$orgData = [
'owner_id' => $userId,
'name' => 'Личное пространство',
'type' => 'personal',
];
$orgId = $orgModel->insert($orgData);
$orgUserModel->insert([
'organization_id' => $orgId,
'user_id' => $userId,
'role' => 'owner',
'status' => 'active',
'joined_at' => date('Y-m-d H:i:s'),
]);
$this->emailLibrary->sendVerificationEmail(
$userData['email'],
$userData['name'],
$verificationToken
);
$this->resetRateLimit('register');
session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.');
return redirect()->to('/register/success');
}
return $this->renderTwig('auth/register');
}
/**
public function registerSuccess()
{
return $this->renderTwig('auth/register_success');
}
/**
public function verify($token)
{
log_message('debug', 'Verify called with token: ' . $token);
if (empty($token)) {
return $this->renderTwig('auth/verify_error', [
'message' => 'Отсутствует токен подтверждения.'
]);
}
$userModel = new UserModel();
$user = $userModel->where('verification_token', $token)->first();
log_message('debug', 'User found: ' . ($user ? 'yes' : 'no'));
if ($user) {
log_message('debug', 'User email_verified: ' . $user['email_verified']);
}
if (!$user) {
return $this->renderTwig('auth/verify_error', [
'message' => 'Недействительная ссылка для подтверждения. Возможно, ссылка уже была использована или истек срок её действия.'
]);
}
if (!empty($user['token_expires_at']) && strtotime($user['token_expires_at']) < time()) {
return $this->renderTwig('auth/verify_error', [
'message' => 'Ссылка для подтверждения истекла. Пожалуйста, запросите письмо повторно.'
]);
}
if ($user['email_verified']) {
return $this->renderTwig('auth/verify_error', [
'message' => 'Email уже подтверждён. Вы можете войти в систему.'
]);
}
$updateData = [
'email_verified' => 1,
'verified_at' => date('Y-m-d H:i:s'),
'verification_token' => null,
];
$result = $userModel->update($user['id'], $updateData);
log_message('debug', 'Update result: ' . ($result ? 'success' : 'failed'));
log_message('debug', 'Update data: ' . print_r($updateData, true));
if (!$result) {
log_message('error', 'Update errors: ' . print_r($userModel->errors(), true));
}
$this->emailLibrary->sendWelcomeEmail($user['email'], $user['name']);
return $this->renderTwig('auth/verify_success', [
'name' => $user['name']
]);
}
/**
public function resendVerification()
{
if ($this->request->getMethod() === 'POST') {
$rateLimitError = $this->checkRateLimit('reset');
if ($rateLimitError !== null) {
return redirect()->back()
->with('error', $rateLimitError['message'])
->withInput();
}
$email = $this->request->getPost('email');
if (empty($email)) {
return redirect()->back()->with('error', 'Введите email');
}
$userModel = new UserModel();
$user = $userModel->where('email', $email)->first();
if (!$user) {
$this->recordFailedAttempt('reset');
return redirect()->back()->with('error', 'Пользователь с таким email не найден');
}
if ($user['email_verified']) {
return redirect()->to('/login')->with('info', 'Email уже подтверждён. Вы можете войти.');
}
$newToken = bin2hex(random_bytes(32));
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
$userModel->update($user['id'], [
'verification_token' => $newToken,
'token_expires_at' => $newExpiresAt
]);
$this->emailLibrary->sendVerificationEmail(
$user['email'],
$user['name'],
$newToken
);
$this->resetRateLimit('reset');
return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.');
}
return $this->renderTwig('auth/resend_verification');
}
public function login()
{
if ($this->request->getMethod() === 'POST') {
$rateLimitError = $this->checkRateLimit('login');
if ($rateLimitError !== null) {
return redirect()->back()
->with('error', $rateLimitError['message'])
->withInput();
}
$userModel = new \App\Models\UserModel();
$orgUserModel = new \App\Models\OrganizationUserModel();
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$user = $userModel->where('email', $email)->first();
if ($user && password_verify($password, $user['password'])) {
if (!$user['email_verified']) {
session()->setFlashdata('warning', 'Email не подтверждён. Проверьте почту или запросите письмо повторно.');
return redirect()->to('/login');
}
$userOrgs = $orgUserModel->where('user_id', $user['id'])->findAll();
if (empty($userOrgs)) {
session()->setFlashdata('error', 'Ваш аккаунт не привязан ни к одной организации. Обратитесь к поддержке.');
return redirect()->to('/login');
}
$sessionData = [
'user_id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'isLoggedIn' => true
];
$remember = $this->request->getPost('remember');
$redirectUrl = count($userOrgs) === 1 ? '/' : '/organizations';
if ($remember) {
$redirectUrl = $this->createRememberTokenAndRedirect($user['id'], $redirectUrl);
}
if (count($userOrgs) === 1) {
$sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
session()->set($sessionData);
$this->resetRateLimit('login');
return $redirectUrl !== '/'
? redirect()->to($redirectUrl)
: redirect()->to('/');
}
session()->remove('active_org_id');
session()->set($sessionData);
session()->setFlashdata('info', 'Выберите пространство для работы');
$this->resetRateLimit('login');
return $redirectUrl !== '/' && $redirectUrl !== '/organizations'
? redirect()->to($redirectUrl)
: redirect()->to('/organizations');
} else {
$limitExceeded = $this->recordFailedAttempt('login');
if ($limitExceeded !== null && $limitExceeded['blocked']) {
$message = "Слишком много неудачных попыток входа. ";
$message .= "Доступ заблокирован на " . $this->formatBlockTime($limitExceeded['ttl']) . ".";
return redirect()->back()->with('error', $message)->withInput();
}
$remaining = $this->rateLimitService ? $this->rateLimitService->checkAttempt('login')['remaining'] : 0;
$message = 'Неверный логин или пароль';
if ($remaining > 0 && $remaining <= 2) {
$message .= " Осталось попыток: {$remaining}";
}
return redirect()->back()->with('error', $message)->withInput();
}
}
return $this->renderTwig('auth/login');
}
public function logout()
{
$userId = session()->get('user_id');
if ($userId) {
$db = \Config\Database::connect();
$db->table('remember_tokens')->where('user_id', $userId)->delete();
}
session()->destroy();
session()->remove('active_org_id');
return redirect()->to('/');
}
/**
protected function createRememberTokenData(int $userId): array
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $validator);
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$db = \Config\Database::connect();
$db->table('remember_tokens')->insert([
'user_id' => $userId,
'selector' => $selector,
'token_hash' => $tokenHash,
'expires_at' => $expiresAt,
'created_at' => date('Y-m-d H:i:s'),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
'ip_address' => $this->request->getIPAddress(),
]);
return [
'selector' => $selector,
'validator' => $validator,
];
}
/**
protected function createRememberTokenAndRedirect(int $userId, string $redirectUrl)
{
$tokenData = $this->createRememberTokenData($userId);
$redirect = redirect()->to($redirectUrl);
$redirect->setCookie('remember_selector', $tokenData['selector'], 30 * 24 * 60 * 60);
$redirect->setCookie('remember_token', $tokenData['validator'], 30 * 24 * 60 * 60);
return $redirectUrl;
}
/**
public static function checkRememberToken(): ?int
{
$request = \Config\Services::request();
$selector = $request->getCookie('remember_selector');
$validator = $request->getCookie('remember_token');
if (!$selector || !$validator) {
return null;
}
$db = \Config\Database::connect();
$token = $db->table('remember_tokens')
->where('selector', $selector)
->where('expires_at >', date('Y-m-d H:i:s'))
->get()
->getRowArray();
if (!$token) {
return null;
}
$tokenHash = hash('sha256', $validator);
if (!hash_equals($token['token_hash'], $tokenHash)) {
return null;
}
return (int) $token['user_id'];
}
/**
public function rateLimitStatus()
{
if (env('CI_ENVIRONMENT') === 'production') {
return $this->response->setStatusCode(403)->setJSON(['error' => 'Forbidden']);
}
if ($this->rateLimitService === null) {
return $this->response->setJSON([
'status' => 'unavailable',
'message' => 'RateLimitService недоступен (Redis не подключен)',
]);
}
$loginStatus = $this->rateLimitService->getStatus('login');
$registerStatus = $this->rateLimitService->getStatus('register');
$resetStatus = $this->rateLimitService->getStatus('reset');
return $this->response->setJSON([
'ip' => service('request')->getIPAddress(),
'redis_connected' => $this->rateLimitService->isConnected(),
'rate_limiting' => [
'login' => [
'attempts' => $loginStatus['attempts'],
'limit' => $loginStatus['limit'],
'window_seconds' => $loginStatus['window'],
'is_blocked' => $loginStatus['is_blocked'],
'block_ttl_seconds' => $loginStatus['block_ttl'],
],
'register' => [
'attempts' => $registerStatus['attempts'],
'limit' => $registerStatus['limit'],
'window_seconds' => $registerStatus['window'],
'is_blocked' => $registerStatus['is_blocked'],
'block_ttl_seconds' => $registerStatus['block_ttl'],
],
'reset' => [
'attempts' => $resetStatus['attempts'],
'limit' => $resetStatus['limit'],
'window_seconds' => $resetStatus['window'],
'is_blocked' => $resetStatus['is_blocked'],
'block_ttl_seconds' => $resetStatus['block_ttl'],
],
],
]);
}
}
// app/Controllers/BaseController.php
session = service('session');
$this->access = service('access');
helper('access');
helper('crm_deals');
}
/**
protected function getOrgUserModel(): OrganizationUserModel
{
if ($this->orgUserModel === null) {
$this->orgUserModel = new OrganizationUserModel();
}
return $this->orgUserModel;
}
/**
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);
}
/**
protected function getActiveOrgId(): ?int
{
$orgId = $this->session->get('active_org_id');
return $orgId ? (int) $orgId : null;
}
/**
protected function can(string $action, string $resource): bool
{
return $this->access->can($action, $resource);
}
/**
protected function isRole($roles): bool
{
return $this->access->isRole($roles);
}
/**
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();
}
/**
protected function requireMembership(int $orgId): array
{
$membership = $this->getMembership($orgId);
if (!$membership) {
throw new \RuntimeException('Доступ запрещён');
}
return $membership;
}
/**
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);
}
/**
protected function forbiddenResponse(string $message = 'Доступ запрещён'): ResponseInterface
{
return service('response')
->setStatusCode(403)
->setJSON(['error' => $message]);
}
/**
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, ['секунду', 'секунды', 'секунд']);
}
/**
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];
}
public function renderTwig($template, $data = [])
{
helper('csrf');
helper('crm_deals');
$twig = \Config\Services::twig();
$oldInput = $this->session->get('_ci_old_input') ?? [];
$data['old'] = $data['old'] ?? $oldInput;
$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,
];
}
/**
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 = [];
$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'];
if (isset($config['scope']) && is_callable($config['scope'])) {
$builder = $model->db()->newQuery();
$config['scope']($builder);
} else {
$builder = $model->builder();
$builder->resetQuery();
$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;
}
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);
}
$countBuilder = clone $builder;
$total = $countBuilder->countAllResults(false);
$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;
}
/**
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);
}
/**
public function table(?array $config = null, ?string $pageUrl = null)
{
$isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax();
if ($isPartial) {
return $this->renderTable($config, true);
}
$params = $this->request->getGet();
unset($params['format']);
if ($pageUrl) {
$redirectUrl = $pageUrl;
} else {
$tableUrl = $config['url'] ?? '/table';
$redirectUrl = $tableUrl;
}
if (!empty($params)) {
$redirectUrl .= '?' . http_build_query($params);
}
return redirect()->to($redirectUrl);
}
}
// app/Controllers/Superadmin.php
organizationModel = new OrganizationModel();
$this->userModel = new UserModel();
$this->subscriptionModel = new OrganizationSubscriptionModel();
$this->subscriptionService = service('moduleSubscription');
}
/**
public function index()
{
$stats = [
'total_users' => $this->userModel->countAll(),
'total_orgs' => $this->organizationModel->countAll(),
'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(),
'total_modules' => count($this->subscriptionService->getAllModules()),
];
$recentOrgs = $this->organizationModel
->orderBy('created_at', 'DESC')
->findAll(5);
$recentUsers = $this->userModel
->orderBy('created_at', 'DESC')
->findAll(5);
return $this->renderTwig('superadmin/dashboard', compact('stats', 'recentOrgs', 'recentUsers'));
}
/**
public function modules()
{
$modules = $this->subscriptionService->getAllModules();
return $this->renderTwig('superadmin/modules/index', compact('modules'));
}
/**
public function updateModule()
{
$moduleCode = $this->request->getPost('module_code');
$config = $this->subscriptionService->getModuleConfig($moduleCode);
if (!$moduleCode || !$config) {
return redirect()->back()->with('error', 'Модуль не найден');
}
$this->subscriptionService->saveModuleSettings(
$moduleCode,
$this->request->getPost('name'),
$this->request->getPost('description'),
(int) $this->request->getPost('price_monthly'),
(int) $this->request->getPost('price_yearly'),
(int) $this->request->getPost('trial_days')
);
return redirect()->to('/superadmin/modules')->with('success', 'Модуль успешно обновлён');
}
/**
protected function getSubscriptionsTableConfig(): array
{
return [
'id' => 'subscriptions-table',
'url' => '/superadmin/subscriptions/table',
'model' => $this->subscriptionModel,
'columns' => [
'id' => ['label' => 'ID', 'width' => '60px'],
'organization_name' => ['label' => 'Организация'],
'module_code' => ['label' => 'Модуль', 'width' => '100px'],
'status' => ['label' => 'Статус', 'width' => '100px'],
'expires_at' => ['label' => 'Истекает', 'width' => '120px'],
'created_at' => ['label' => 'Создана', 'width' => '120px'],
],
'searchable' => ['id', 'organization_name', 'module_code'],
'sortable' => ['id', 'created_at', 'expires_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'fieldMap' => [
'organization_name' => 'organizations.name',
'id' => 'organization_subscriptions.id',
'module_code' => 'organization_subscriptions.module_code',
'status' => 'organization_subscriptions.status',
'expires_at' => 'organization_subscriptions.expires_at',
'created_at' => 'organization_subscriptions.created_at',
],
'scope' => function ($builder) {
$builder->from('organization_subscriptions')
->select('organization_subscriptions.*, organizations.name as organization_name')
->join('organizations', 'organizations.id = organization_subscriptions.organization_id');
},
'actions' => ['label' => 'Действия', 'width' => '100px'],
'actionsConfig' => [
[
'label' => '',
'url' => '/superadmin/subscriptions/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'title' => 'Удалить',
'confirm' => 'Удалить подписку?',
],
],
'emptyMessage' => 'Подписки не найдены',
'emptyIcon' => 'fa-solid fa-credit-card',
];
}
/**
public function subscriptions()
{
$config = $this->getSubscriptionsTableConfig();
$tableHtml = $this->renderTable($config);
$modules = $this->subscriptionService->getAllModules();
$organizations = $this->organizationModel->findAll();
return $this->renderTwig('superadmin/subscriptions/index', [
'tableHtml' => $tableHtml,
'config' => $config,
'modules' => $modules,
'organizations' => $organizations,
]);
}
/**
public function subscriptionsTable()
{
return parent::table($this->getSubscriptionsTableConfig(), '/superadmin/subscriptions');
}
/**
public function searchOrganizations()
{
$query = $this->request->getGet('q') ?? '';
$limit = 20;
$builder = $this->organizationModel->db()->table('organizations');
$builder->select('organizations.*, users.email as owner_email')
->join('organization_users', 'organization_users.organization_id = organizations.id AND organization_users.role = "owner"')
->join('users', 'users.id = organization_users.user_id')
->groupStart()
->like('organizations.name', $query)
->orLike('organizations.id', $query)
->orLike('users.email', $query)
->groupEnd()
->limit($limit);
$results = [];
foreach ($builder->get()->getResultArray() as $org) {
$results[] = [
'id' => $org['id'],
'text' => $org['name'] . ' (ID: ' . $org['id'] . ') — ' . $org['owner_email'],
];
}
return $this->response->setJSON(['results' => $results]);
}
/**
public function createSubscription()
{
$organizations = $this->organizationModel->findAll();
$modules = $this->subscriptionService->getAllModules();
return $this->renderTwig('superadmin/subscriptions/create', compact('organizations', 'modules'));
}
/**
public function storeSubscription()
{
$organizationId = (int) $this->request->getPost('organization_id');
$moduleCode = $this->request->getPost('module_code');
$durationDays = (int) $this->request->getPost('duration_days');
$status = $this->request->getPost('status') ?? 'active';
$organization = $this->organizationModel->find($organizationId);
if (!$organization) {
return redirect()->back()->withInput()->with('error', 'Организация не найдена');
}
$moduleConfig = $this->subscriptionService->getModuleConfig($moduleCode);
if (!$moduleCode || !$moduleConfig) {
return redirect()->back()->withInput()->with('error', 'Модуль не найден');
}
$this->subscriptionService->upsertSubscription(
$organizationId,
$moduleCode,
$status,
$durationDays
);
return redirect()->to('/superadmin/subscriptions')->with('success', 'Подписка создана');
}
/**
public function deleteSubscription($id)
{
$this->subscriptionService->deleteSubscription($id);
return redirect()->to('/superadmin/subscriptions')->with('success', 'Подписка удалена');
}
/**
protected function getOrganizationsTableConfig(): array
{
return [
'id' => 'organizations-table',
'url' => '/superadmin/organizations/table',
'model' => $this->organizationModel,
'columns' => [
'id' => ['label' => 'ID', 'width' => '60px'],
'name' => ['label' => 'Название'],
'owner_login' => ['label' => 'Владелец', 'width' => '150px'],
'type' => ['label' => 'Тип', 'width' => '100px'],
'user_count' => ['label' => 'Пользователей', 'width' => '100px'],
'status' => ['label' => 'Статус', 'width' => '120px'],
'created_at' => ['label' => 'Дата', 'width' => '100px'],
],
'searchable' => ['name', 'id', 'owner_login'],
'sortable' => ['id', 'name', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'scope' => function ($builder) {
$builder->resetQuery();
$builder->select('organizations.*,
(SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count,
owner_users.email as owner_login')
->join('organization_users as ou', 'ou.organization_id = organizations.id AND ou.role = "owner"')
->join('users as owner_users', 'owner_users.id = ou.user_id', 'left');
},
'actions' => ['label' => 'Действия', 'width' => '140px'],
'actionsConfig' => [
[
'label' => '',
'url' => '/superadmin/organizations/view/{id}',
'icon' => 'fa-solid fa-eye',
'class' => 'btn-outline-primary',
'title' => 'Просмотр',
],
[
'label' => '',
'url' => '/superadmin/organizations/block/{id}',
'icon' => 'fa-solid fa-ban',
'class' => 'btn-outline-warning',
'title' => 'Заблокировать',
'confirm' => 'Заблокировать организацию?',
],
[
'label' => '',
'url' => '/superadmin/organizations/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'title' => 'Удалить',
'confirm' => 'Удалить организацию? Это действие нельзя отменить!',
],
],
'emptyMessage' => 'Организации не найдены',
'emptyIcon' => 'bi bi-building',
];
}
/**
public function organizations()
{
$config = $this->getOrganizationsTableConfig();
$tableHtml = $this->renderTable($config);
return $this->renderTwig('superadmin/organizations/index', [
'tableHtml' => $tableHtml,
'config' => $config,
]);
}
/**
public function organizationsTable()
{
$config = $this->getOrganizationsTableConfig();
return $this->table($config);
}
/**
public function viewOrganization($id)
{
$organization = $this->organizationModel->find($id);
if (!$organization) {
throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена');
}
$users = $this->getOrgUserModel()->getOrganizationUsers($id);
$subscriptions = $this->subscriptionService->getOrganizationSubscriptions($id);
$allModules = $this->subscriptionService->getAllModules();
return $this->renderTwig('superadmin/organizations/view', compact(
'organization',
'users',
'subscriptions',
'allModules'
));
}
/**
public function addOrganizationSubscription($organizationId)
{
$moduleCode = $this->request->getPost('module_code');
$durationDays = (int) $this->request->getPost('duration_days');
$status = $this->request->getPost('status') ?? 'active';
if (!$moduleCode) {
return redirect()->back()->with('error', 'Модуль не выбран');
}
$this->subscriptionService->upsertSubscription(
$organizationId,
$moduleCode,
$status,
$durationDays
);
return redirect()->to("/superadmin/organizations/view/{$organizationId}")->with('success', 'Подписка добавлена');
}
/**
public function removeOrganizationSubscription($organizationId, $subscriptionId)
{
$this->subscriptionService->deleteSubscription($subscriptionId);
return redirect()->to("/superadmin/organizations/view/{$organizationId}")->with('success', 'Подписка удалена');
}
/**
public function blockOrganization($id)
{
$this->organizationModel->update($id, ['status' => 'blocked']);
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация заблокирована');
}
/**
public function unblockOrganization($id)
{
$this->organizationModel->update($id, ['status' => 'active']);
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация разблокирована');
}
/**
public function deleteOrganization($id)
{
$this->organizationModel->delete($id, true);
return redirect()->to('/superadmin/organizations')->with('success', 'Организация удалена');
}
/**
protected function getUsersTableConfig(): array
{
return [
'id' => 'users-table',
'url' => '/superadmin/users/table',
'model' => $this->userModel,
'columns' => [
'id' => ['label' => 'ID', 'width' => '60px'],
'name' => ['label' => 'Имя'],
'email' => ['label' => 'Email'],
'system_role' => ['label' => 'Роль', 'width' => '140px'],
'org_count' => ['label' => 'Организаций', 'width' => '100px'],
'status' => ['label' => 'Статус', 'width' => '120px'],
'created_at' => ['label' => 'Дата', 'width' => '100px'],
],
'searchable' => ['name', 'email', 'id'],
'sortable' => ['id', 'name', 'email', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'scope' => function ($builder) {
$builder->from('users')
->select('users.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.user_id = users.id) as org_count');
},
'actions' => ['label' => 'Действия', 'width' => '140px'],
'actionsConfig' => [
[
'label' => '',
'url' => '/superadmin/users/block/{id}',
'icon' => 'fa-solid fa-ban',
'class' => 'btn-outline-warning',
'title' => 'Заблокировать',
'confirm' => 'Заблокировать пользователя?',
],
[
'label' => '',
'url' => '/superadmin/users/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'title' => 'Удалить',
'confirm' => 'Удалить пользователя? Это действие нельзя отменить!',
],
],
'emptyMessage' => 'Пользователи не найдены',
'emptyIcon' => 'bi bi-people',
];
}
/**
public function users()
{
$config = $this->getUsersTableConfig();
$tableHtml = $this->renderTable($config);
return $this->renderTwig('superadmin/users/index', [
'tableHtml' => $tableHtml,
'config' => $config,
]);
}
/**
public function usersTable()
{
$config = $this->getUsersTableConfig();
return $this->table($config);
}
/**
public function updateUserRole($id)
{
$newRole = $this->request->getPost('system_role');
$allowedRoles = ['user', 'admin', 'superadmin'];
if (!in_array($newRole, $allowedRoles)) {
return redirect()->back()->with('error', 'Недопустимая роль');
}
$this->userModel->update($id, ['system_role' => $newRole]);
return redirect()->back()->with('success', 'Роль пользователя обновлена');
}
/**
public function blockUser($id)
{
$this->userModel->update($id, ['status' => 'blocked']);
return redirect()->back()->with('success', 'Пользователь заблокирован');
}
/**
public function unblockUser($id)
{
$this->userModel->update($id, ['status' => 'active']);
return redirect()->back()->with('success', 'Пользователь разблокирован');
}
/**
public function deleteUser($id)
{
$this->userModel->delete($id, true);
return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён');
}
/**
public function statistics()
{
$dailyStats = [];
for ($i = 29; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-{$i} days"));
$dailyStats[] = [
'date' => $date,
'users' => $this->userModel->where('DATE(created_at)', $date)->countAllResults(),
'orgs' => $this->organizationModel->where('DATE(created_at)', $date)->countAllResults(),
];
}
$moduleStats = $this->subscriptionService->getModuleStats();
return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'moduleStats'));
}
}
// app/Controllers/Profile.php
userModel = new UserModel();
$this->orgModel = new OrganizationModel();
}
/**
public function index()
{
$userId = $this->getCurrentUserId();
$user = $this->userModel->find($userId);
return $this->renderTwig('profile/index', [
'title' => 'Профиль',
'user' => $user,
'active_tab' => 'general',
]);
}
/**
public function organizations()
{
$userId = $this->getCurrentUserId();
$user = $this->userModel->find($userId);
$currentOrgId = $this->session->get('active_org_id');
$orgUserModel = $this->getOrgUserModel();
$memberships = $orgUserModel->where('user_id', $userId)->findAll();
$orgIds = array_column($memberships, 'organization_id');
$organizations = [];
if (!empty($orgIds)) {
$organizations = $this->orgModel->whereIn('id', $orgIds)->findAll();
}
$orgList = [];
foreach ($organizations as $org) {
$membership = null;
foreach ($memberships as $m) {
if ($m['organization_id'] == $org['id']) {
$membership = $m;
break;
}
}
$orgList[] = [
'id' => $org['id'],
'name' => $org['name'],
'type' => $org['type'],
'role' => $membership['role'] ?? 'guest',
'status' => $membership['status'] ?? 'active',
'joined_at' => $membership['joined_at'] ?? null,
'is_owner' => ($membership['role'] ?? '') === 'owner',
'is_current_org' => ((int) $org['id'] === (int) $currentOrgId),
];
}
return $this->renderTwig('profile/organizations', [
'title' => 'Мои организации',
'user' => $user,
'organizations' => $orgList,
'active_tab' => 'organizations',
]);
}
/**
public function security()
{
$userId = $this->getCurrentUserId();
$user = $this->userModel->find($userId);
$sessions = $this->getUserSessions($userId);
return $this->renderTwig('profile/security', [
'title' => 'Безопасность',
'user' => $user,
'active_tab' => 'security',
'sessions' => $sessions,
'currentSessionId' => session_id(),
]);
}
/**
protected function getUserSessions(int $userId): array
{
$db = \Config\Database::connect();
$rememberTokens = $db->table('remember_tokens')
->where('user_id', $userId)
->where('expires_at >', date('Y-m-d H:i:s'))
->get()
->getResultArray();
$sessions = [];
foreach ($rememberTokens as $token) {
$sessions[] = [
'id' => 'remember_' . $token['id'],
'type' => 'remember',
'device' => $this->parseUserAgent($token['user_agent'] ?? ''),
'ip_address' => $token['ip_address'] ?? 'Unknown',
'created_at' => $token['created_at'],
'expires_at' => $token['expires_at'],
'is_current' => false,
];
}
return $sessions;
}
/**
protected function parseUserAgent(string $userAgent): string
{
if (empty($userAgent)) {
return 'Неизвестное устройство';
}
$browser = 'Unknown';
if (preg_match('/Firefox\/([0-9.]+)/', $userAgent, $matches)) {
$browser = 'Firefox';
} elseif (preg_match('/Chrome\/([0-9.]+)/', $userAgent, $matches)) {
$browser = 'Chrome';
} elseif (preg_match('/Safari\/([0-9.]+)/', $userAgent, $matches)) {
$browser = 'Safari';
} elseif (preg_match('/MSIE\s+([0-9.]+)/', $userAgent, $matches) || preg_match('/Trident\/([0-9.]+)/', $userAgent, $matches)) {
$browser = 'Internet Explorer';
} elseif (preg_match('/Edg\/([0-9.]+)/', $userAgent, $matches)) {
$browser = 'Edge';
}
$os = 'Unknown OS';
if (preg_match('/Windows/', $userAgent)) {
$os = 'Windows';
} elseif (preg_match('/Mac OS X/', $userAgent)) {
$os = 'macOS';
} elseif (preg_match('/Linux/', $userAgent)) {
$os = 'Linux';
} elseif (preg_match('/Android/', $userAgent)) {
$os = 'Android';
} elseif (preg_match('/iPhone|iPad|iPod/', $userAgent)) {
$os = 'iOS';
}
return "{$browser} на {$os}";
}
/**
public function revokeSession()
{
$userId = $this->getCurrentUserId();
$sessionId = $this->request->getPost('session_id');
if (empty($sessionId)) {
return redirect()->to('/profile/security')->with('error', 'Сессия не найдена');
}
$db = \Config\Database::connect();
if (strpos($sessionId, 'remember_') === 0) {
$tokenId = (int) str_replace('remember_', '', $sessionId);
$token = $db->table('remember_tokens')
->where('id', $tokenId)
->where('user_id', $userId)
->get()
->getRowArray();
if ($token) {
$db->table('remember_tokens')->where('id', $tokenId)->delete();
log_message('info', "User {$userId} revoked remember token {$tokenId}");
}
}
return redirect()->to('/profile/security')->with('success', 'Сессия завершена');
}
/**
public function revokeAllSessions()
{
$userId = $this->getCurrentUserId();
$db = \Config\Database::connect();
$db->table('remember_tokens')->where('user_id', $userId)->delete();
$this->session->regenerate(true);
log_message('info', "User {$userId} revoked all sessions");
return redirect()->to('/profile/security')->with(
'success',
'Все сессии на других устройствах завершены. Вы остались авторизованы на текущем устройстве.'
);
}
/**
public function updateName()
{
$userId = $this->getCurrentUserId();
$name = trim($this->request->getPost('name'));
if (empty($name)) {
$this->session->setFlashdata('error', 'Имя обязательно для заполнения');
return redirect()->to('/profile');
}
if (strlen($name) < 3) {
$this->session->setFlashdata('error', 'Имя должно содержать минимум 3 символа');
return redirect()->to('/profile');
}
$this->userModel->update($userId, ['name' => $name]);
$this->session->set('name', $name);
$this->session->setFlashdata('success', 'Имя успешно обновлено');
return redirect()->to('/profile');
}
/**
public function uploadAvatar()
{
$userId = $this->getCurrentUserId();
$file = $this->request->getFile('avatar');
if (!$file || !$file->isValid()) {
$this->session->setFlashdata('error', 'Ошибка загрузки файла');
return redirect()->to('/profile');
}
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
$maxSize = 2 * 1024 * 1024;
if (!in_array($file->getMimeType(), $allowedTypes)) {
$this->session->setFlashdata('error', 'Разрешены только файлы JPG, PNG и GIF');
return redirect()->to('/profile');
}
if ($file->getSize() > $maxSize) {
$this->session->setFlashdata('error', 'Максимальный размер файла - 2 МБ');
return redirect()->to('/profile');
}
$uploadPath = ROOTPATH . 'public/uploads/avatars';
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0755, true);
}
$extension = $file->getClientExtension();
$newFileName = 'avatar_' . $userId . '_' . time() . '.' . $extension;
$file->move($uploadPath, $newFileName);
$user = $this->userModel->find($userId);
if (!empty($user['avatar']) && file_exists($uploadPath . '/' . $user['avatar'])) {
@unlink($uploadPath . '/' . $user['avatar']);
}
$this->userModel->update($userId, ['avatar' => $newFileName]);
$this->session->setFlashdata('success', 'Аватар успешно загружен');
return redirect()->to('/profile');
}
/**
public function changePassword()
{
$userId = $this->getCurrentUserId();
$user = $this->userModel->find($userId);
$currentPassword = $this->request->getPost('current_password');
$newPassword = $this->request->getPost('new_password');
$confirmPassword = $this->request->getPost('confirm_password');
if (empty($currentPassword)) {
$this->session->setFlashdata('error', 'Введите текущий пароль');
return redirect()->to('/profile/security');
}
if (empty($newPassword)) {
$this->session->setFlashdata('error', 'Введите новый пароль');
return redirect()->to('/profile/security');
}
if (strlen($newPassword) < 6) {
$this->session->setFlashdata('error', 'Новый пароль должен содержать минимум 6 символов');
return redirect()->to('/profile/security');
}
if ($newPassword !== $confirmPassword) {
$this->session->setFlashdata('error', 'Пароли не совпадают');
return redirect()->to('/profile/security');
}
if (!password_verify($currentPassword, $user['password'])) {
$this->session->setFlashdata('error', 'Неверный текущий пароль');
return redirect()->to('/profile/security');
}
$this->userModel->update($userId, ['password' => $newPassword]);
$this->endAllUserSessions($userId);
$this->session->setFlashdata('success', 'Пароль успешно изменён. Для безопасности вы будете разлогинены на всех устройствах.');
return redirect()->to('/logout');
}
/**
private function endAllUserSessions(int $userId): void
{
$db = \Config\Database::connect();
$db->table('remember_tokens')->where('user_id', $userId)->delete();
$this->session->regenerate(true);
}
}
// app/Controllers/InvitationController.php
invitationService = new InvitationService();
}
/**
public function accept(string $token)
{
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
$db = \Config\Database::connect();
$expiredInvitation = $db->table('organization_users')
->where('invite_token', $token)
->get()
->getRowArray();
if ($expiredInvitation && !empty($expiredInvitation['invite_expires_at'])) {
$expiredAt = strtotime($expiredInvitation['invite_expires_at']);
$isExpired = $expiredAt < time();
return $this->renderTwig('organizations/invitation_expired', [
'title' => $isExpired ? 'Приглашение истекло' : 'Приглашение недействительно',
'expired' => $isExpired,
'expired_at' => $expiredInvitation['invite_expires_at'] ?? null,
]);
}
return $this->renderTwig('organizations/invitation_expired', [
'title' => 'Приглашение недействительно',
]);
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($invitation['organization_id']);
$invitedByUser = null;
if ($invitation['invited_by']) {
$userModel = new UserModel();
$invitedByUser = $userModel->find($invitation['invited_by']);
}
$currentUserId = session()->get('user_id');
$isLoggedIn = !empty($currentUserId);
$emailMatches = true;
if ($isLoggedIn && $invitation['user_id']) {
$currentUser = (new UserModel())->find($currentUserId);
$emailMatches = ($currentUserId == $invitation['user_id']);
}
$roleLabels = [
'owner' => 'Владелец',
'admin' => 'Администратор',
'manager' => 'Менеджер',
'guest' => 'Гость',
];
return $this->renderTwig('organizations/invitation_accept', [
'title' => 'Приглашение в ' . ($organization['name'] ?? 'организацию'),
'token' => $token,
'organization' => $organization,
'role' => $invitation['role'],
'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'],
'invited_by' => $invitedByUser,
'invited_at' => $invitation['invited_at'],
'is_logged_in' => $isLoggedIn,
'email_matches' => $emailMatches,
'current_user_id'=> $currentUserId,
]);
}
/**
public function processAccept()
{
$token = $this->request->getPost('token');
$action = $this->request->getPost('action');
if ($action === 'decline') {
return $this->decline($token);
}
$userId = session()->get('user_id');
if (!$userId) {
return redirect()->to('/invitation/complete/' . $token);
}
$result = $this->invitationService->acceptInvitation($token, $userId);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
return redirect()->to('/invitation/accept/' . $token);
}
session()->setFlashdata('success', 'Вы приняли приглашение в организацию "' . $result['organization_name'] . '"');
session()->set('active_org_id', $result['organization_id']);
(new AccessService())->resetCache();
return redirect()->to('/');
}
/**
public function decline(string $token)
{
$result = $this->invitationService->declineInvitation($token);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
} else {
session()->setFlashdata('info', 'Приглашение отклонено');
}
return redirect()->to('/');
}
/**
public function complete(string $token)
{
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
$db = \Config\Database::connect();
$expiredInvitation = $db->table('organization_users')
->where('invite_token', $token)
->get()
->getRowArray();
if ($expiredInvitation && !empty($expiredInvitation['invite_expires_at'])) {
$expiredAt = strtotime($expiredInvitation['invite_expires_at']);
$isExpired = $expiredAt < time();
return $this->renderTwig('organizations/invitation_expired', [
'title' => $isExpired ? 'Приглашение истекло' : 'Приглашение недействительно',
'expired' => $isExpired,
'expired_at' => $expiredInvitation['invite_expires_at'] ?? null,
]);
}
return $this->renderTwig('organizations/invitation_expired', [
'title' => 'Приглашение недействительно',
]);
}
$userId = session()->get('user_id');
if ($userId && $invitation['user_id'] == $userId) {
return redirect()->to('/invitation/accept/' . $token);
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($invitation['organization_id']);
$roleLabels = [
'owner' => 'Владелец',
'admin' => 'Администратор',
'manager' => 'Менеджер',
'guest' => 'Гость',
];
return $this->renderTwig('organizations/invitation_complete', [
'title' => 'Завершение регистрации',
'token' => $token,
'email' => $invitation['user_id'] ? '' : '',
'organization' => $organization,
'role' => $invitation['role'],
'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'],
]);
}
/**
public function processComplete()
{
$token = $this->request->getPost('token');
$name = $this->request->getPost('name');
$password = $this->request->getPost('password');
$passwordConfirm = $this->request->getPost('password_confirm');
$errors = [];
if (empty($name) || strlen($name) < 2) {
$errors[] = 'Имя должно содержать минимум 2 символа';
}
if (empty($password) || strlen($password) < 8) {
$errors[] = 'Пароль должен содержать минимум 8 символов';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Пароли не совпадают';
}
if (!empty($errors)) {
return redirect()->back()->withInput()->with('errors', $errors);
}
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return redirect()->to('/');
}
$userModel = new UserModel();
if ($invitation['user_id']) {
$user = $userModel->find($invitation['user_id']);
if (!$user) {
session()->setFlashdata('error', 'Пользователь не найден');
return redirect()->to('/invitation/complete/' . $token);
}
$userModel->update($user['id'], [
'name' => $name,
'password' => $password,
]);
$userId = $user['id'];
} else {
$shadowUsers = $userModel->where('email', $userModel->getFindByEmail($invitation['organization_id']))->findAll();
session()->setFlashdata('error', 'Ошибка регистрации');
return redirect()->to('/invitation/complete/' . $token);
}
$user = $userModel->find($userId);
$this->loginUser($user);
$result = $this->invitationService->acceptInvitation($token, $userId);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
return redirect()->to('/');
}
session()->setFlashdata('success', 'Добро пожаловать! Вы успешно зарегистрировались и приняли приглашение.');
session()->set('active_org_id', $result['organization_id']);
(new AccessService())->resetCache();
return redirect()->to('/');
}
/**
protected function loginUser(array $user): void
{
session()->set([
'user_id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'logged_in' => true,
]);
}
}
// app/Controllers/Landing.php
get('isLoggedIn')) {
return redirect()->to('/organizations');
}
return $this->renderTwig('landing/index');
}
}
// app/Controllers/Organizations.php
getCurrentUserId();
$userOrgLinks = $this->getOrgUserModel()->where('user_id', $userId)->findAll();
$orgIds = array_column($userOrgLinks, 'organization_id');
$organizations = [];
if (!empty($orgIds)) {
$organizations = $orgModel->whereIn('id', $orgIds)->findAll();
}
return $this->renderTwig('organizations/index', [
'organizations' => $organizations,
'count' => count($organizations)
]);
}
public function create()
{
if ($this->request->getMethod() === 'POST') {
$orgModel = new OrganizationModel();
$rules = [
'name' => 'required|min_length[2]',
];
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$requisites = [
'inn' => trim($this->request->getPost('inn') ?? ''),
'ogrn' => trim($this->request->getPost('ogrn') ?? ''),
'kpp' => trim($this->request->getPost('kpp') ?? ''),
'legal_address' => trim($this->request->getPost('legal_address') ?? ''),
'actual_address' => trim($this->request->getPost('actual_address') ?? ''),
'phone' => trim($this->request->getPost('phone') ?? ''),
'email' => trim($this->request->getPost('email') ?? ''),
'website' => trim($this->request->getPost('website') ?? ''),
'bank_name' => trim($this->request->getPost('bank_name') ?? ''),
'bank_bik' => trim($this->request->getPost('bank_bik') ?? ''),
'checking_account' => trim($this->request->getPost('checking_account') ?? ''),
'correspondent_account' => trim($this->request->getPost('correspondent_account') ?? ''),
];
$orgId = $orgModel->insert([
'owner_id' => $this->getCurrentUserId(),
'name' => $this->request->getPost('name'),
'type' => 'business',
'requisites' => json_encode($requisites),
'settings' => json_encode([]),
]);
$this->getOrgUserModel()->insert([
'organization_id' => $orgId,
'user_id' => $this->getCurrentUserId(),
'role' => 'owner',
'status' => 'active',
'joined_at' => date('Y-m-d H:i:s'),
]);
$this->session->set('active_org_id', $orgId);
$this->session->setFlashdata('success', 'Организация успешно создана!');
return redirect()->to('/');
}
return $this->renderTwig('organizations/create');
}
/**
public function dashboard($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId);
if (!$organization) {
return $this->redirectWithError('Организация не найдена', '/organizations');
}
$stats = [
'users_total' => $this->getOrgUserModel()->where('organization_id', $orgId)->countAllResults(),
'users_active' => $this->getOrgUserModel()->where('organization_id', $orgId)->where('status', 'active')->countAllResults(),
'users_blocked' => $this->getOrgUserModel()->where('organization_id', $orgId)->where('status', 'blocked')->countAllResults(),
];
$canManageUsers = $this->access->canManageUsers();
$canEditOrg = true;
return $this->renderTwig('organizations/dashboard', [
'organization' => $organization,
'organization_id' => $orgId,
'stats' => $stats,
'current_role' => $membership['role'],
'can_manage_users' => $canManageUsers,
'can_edit_org' => $canEditOrg,
]);
}
/**
public function edit($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId);
if (!$organization) {
return $this->redirectWithError('Организация не найдена', '/organizations');
}
$requisites = json_decode($organization['requisites'] ?? '{}', true);
if ($this->request->getMethod() === 'POST') {
$rules = [
'name' => 'required|min_length[2]',
];
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$newRequisites = [
'inn' => trim($this->request->getPost('inn') ?? ''),
'ogrn' => trim($this->request->getPost('ogrn') ?? ''),
'kpp' => trim($this->request->getPost('kpp') ?? ''),
'legal_address' => trim($this->request->getPost('legal_address') ?? ''),
'actual_address' => trim($this->request->getPost('actual_address') ?? ''),
'phone' => trim($this->request->getPost('phone') ?? ''),
'email' => trim($this->request->getPost('email') ?? ''),
'website' => trim($this->request->getPost('website') ?? ''),
'bank_name' => trim($this->request->getPost('bank_name') ?? ''),
'bank_bik' => trim($this->request->getPost('bank_bik') ?? ''),
'checking_account' => trim($this->request->getPost('checking_account') ?? ''),
'correspondent_account' => trim($this->request->getPost('correspondent_account') ?? ''),
];
$orgModel->update($orgId, [
'name' => $this->request->getPost('name'),
'requisites' => json_encode($newRequisites),
]);
$this->session->setFlashdata('success', 'Организация успешно обновлена!');
return redirect()->to('/organizations');
}
return $this->renderTwig('organizations/edit', [
'organization' => $organization,
'requisites' => $requisites
]);
}
/**
public function delete($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
if (!$this->access->canDeleteOrganization()) {
return $this->redirectWithError('Только владелец может удалить организацию', '/organizations');
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId);
if (!$organization) {
return $this->redirectWithError('Организация не найдена', '/organizations');
}
if ($this->request->getMethod() === 'POST') {
$this->getOrgUserModel()->forCurrentOrg()->delete();
$orgModel->delete($orgId);
if ($this->session->get('active_org_id') == $orgId) {
$this->session->remove('active_org_id');
}
$this->session->setFlashdata('success', 'Организация "' . $organization['name'] . '" удалена');
return redirect()->to('/organizations');
}
return $this->renderTwig('organizations/delete', [
'organization' => $organization
]);
}
public function switch($orgId)
{
$userId = $this->getCurrentUserId();
$orgId = (int) $orgId;
$membership = $this->getOrgUserModel()
->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if ($membership) {
$this->access->resetCache();
$this->session->set('active_org_id', $orgId);
$this->session->setFlashdata('success', 'Организация изменена');
$referer = $this->request->getHeader('Referer');
if ($referer && strpos($referer->getValue(), '/organizations/switch') === false) {
return redirect()->to($referer->getValue());
}
return redirect()->to('/');
} else {
$this->session->setFlashdata('error', 'Доступ запрещен');
return redirect()->to('/organizations');
}
}
/**
public function users($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId);
if (!$organization) {
return $this->redirectWithError('Организация не найдена', '/organizations');
}
if (!$this->access->canManageUsers()) {
return $this->redirectWithError('У вас нет прав для управления пользователями', '/organizations/' . $orgId . '/dashboard');
}
$tableHtml = $this->renderTable($this->getUsersTableConfig($orgId));
$users = $this->getOrgUserModel()->getOrganizationUsers($orgId);
return $this->renderTwig('organizations/users', [
'organization' => $organization,
'organization_id' => $orgId,
'tableHtml' => $tableHtml,
'users' => $users,
'current_user_id' => $this->getCurrentUserId(),
'can_manage_users' => $this->access->canManageUsers(),
'current_role' => $membership['role'],
]);
}
/**
protected function getUsersTableConfig(int $orgId): array
{
$canManage = $this->access->canManageUsers();
return [
'id' => 'users-table',
'url' => '/organizations/' . $orgId . '/users/table',
'model' => $this->getOrgUserModel(),
'columns' => [
'user_email' => [
'label' => 'Пользователь',
'width' => '35%',
'type' => 'user_display',
],
'role' => [
'label' => 'Роль',
'width' => '15%',
'type' => 'role_badge',
],
'status' => [
'label' => 'Статус',
'width' => '15%',
'type' => 'status_badge',
],
'joined_at' => [
'label' => 'Дата входа',
'width' => '20%',
'type' => 'datetime',
'default' => '—',
],
],
'searchable' => ['user_email', 'user_name'],
'sortable' => ['joined_at', 'role', 'status'],
'defaultSort' => 'joined_at',
'order' => 'desc',
'actions' => true,
'actionsConfig' => [
[
'label' => 'Изменить роль',
'url' => '/organizations/users/' . $orgId . '/role/{user_id}',
'icon' => 'fa-solid fa-user-gear',
'class' => 'btn-outline-primary btn-sm',
'type' => 'edit',
],
[
'label' => 'Заблокировать',
'url' => '/organizations/users/' . $orgId . '/block/{user_id}',
'icon' => 'fa-solid fa-ban',
'class' => 'btn-outline-warning btn-sm',
'type' => 'block',
],
[
'label' => 'Разблокировать',
'url' => '/organizations/users/' . $orgId . '/unblock/{user_id}',
'icon' => 'fa-solid fa-check',
'class' => 'btn-outline-success btn-sm',
'type' => 'unblock',
],
[
'label' => 'Удалить',
'url' => '/organizations/users/' . $orgId . '/remove/{user_id}',
'icon' => 'fa-solid fa-user-xmark',
'class' => 'btn-outline-danger btn-sm',
'type' => 'delete',
],
],
'can_edit' => $canManage,
'can_delete' => $canManage,
'emptyMessage' => 'В организации пока нет участников',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => '',
'emptyActionLabel'=> '',
'emptyActionIcon' => '',
'scope' => function ($builder) use ($orgId) {
$builder->select('ou.*, u.email as user_email, u.name as user_name, u.avatar as user_avatar')
->from('organization_users ou')
->join('users u', 'u.id = ou.user_id', 'left')
->where('ou.organization_id', $orgId);
},
'searchable' => ['user_email', 'user_name'],
'fieldMap' => [
'user_email' => 'u.email',
'user_name' => 'u.name',
],
];
}
/**
public function usersTable($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->forbiddenResponse('Доступ запрещён');
}
if (!$this->access->canManageUsers()) {
return $this->forbiddenResponse('Управление пользователями недоступно');
}
return $this->table(
$this->getUsersTableConfig($orgId),
'/organizations/' . $orgId . '/users'
);
}
/**
public function inviteUser($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->response->setJSON([
'success' => false,
'message' => 'Доступ запрещен',
]);
}
if (!$this->access->canManageUsers()) {
return $this->response->setJSON([
'success' => false,
'message' => 'У вас нет прав для приглашения пользователей',
]);
}
if (!$this->request->isAJAX()) {
return redirect()->to("/organizations/users/{$orgId}");
}
$email = $this->request->getPost('email');
$role = $this->request->getPost('role');
if (empty($email) || empty($role)) {
return $this->response->setJSON([
'success' => false,
'message' => 'Email и роль обязательны',
]);
}
$availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']);
if (!in_array($role, $availableRoles)) {
return $this->response->setJSON([
'success' => false,
'message' => 'Недопустимая роль',
]);
}
$invitationService = new \App\Services\InvitationService();
$result = $invitationService->createInvitation(
$orgId,
$email,
$role,
$this->getCurrentUserId()
);
return $this->response->setJSON($result);
}
/**
public function blockUser($orgId, $userId)
{
$orgId = (int) $orgId;
$userId = (int) $userId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
if (!$this->access->canManageUsers()) {
return $this->redirectWithError('У вас нет прав для блокировки', "/organizations/users/{$orgId}");
}
$targetMembership = $this->getOrgUserModel()
->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$targetMembership) {
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
}
if ($targetMembership['role'] === 'owner') {
return $this->redirectWithError('Нельзя заблокировать владельца', "/organizations/users/{$orgId}");
}
$this->getOrgUserModel()->blockUser($targetMembership['id']);
$this->session->setFlashdata('success', 'Пользователь заблокирован');
return redirect()->to("/organizations/users/{$orgId}");
}
/**
public function unblockUser($orgId, $userId)
{
$orgId = (int) $orgId;
$userId = (int) $userId;
$membership = $this->getMembership($orgId);
if (!$membership || !$this->access->canManageUsers()) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$targetMembership = $this->getOrgUserModel()
->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$targetMembership) {
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
}
$this->getOrgUserModel()->unblockUser($targetMembership['id']);
$this->session->setFlashdata('success', 'Пользователь разблокирован');
return redirect()->to("/organizations/users/{$orgId}");
}
/**
public function leaveOrganization($orgId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership) {
if ($this->request->isAJAX()) {
return $this->response->setJSON([
'success' => false,
'message' => 'Вы не состоите в этой организации',
]);
}
return $this->redirectWithError('Вы не состоите в этой организации', '/organizations');
}
if ($membership['role'] === 'owner') {
if ($this->request->isAJAX()) {
return $this->response->setJSON([
'success' => false,
'message' => 'Владелец не может покинуть организацию. Передайте права другому администратору.',
]);
}
return $this->redirectWithError('Владелец не может покинуть организацию. Передайте права другому администратору.', "/organizations/users/{$orgId}");
}
$this->getOrgUserModel()->delete($membership['id']);
if ($this->session->get('active_org_id') == $orgId) {
$userId = $this->getCurrentUserId();
$otherOrgs = $this->getOrgUserModel()->where('user_id', $userId)->where('status', 'active')->findAll();
if (!empty($otherOrgs)) {
$this->session->set('active_org_id', $otherOrgs[0]['organization_id']);
} else {
$this->session->remove('active_org_id');
}
}
$this->access->resetCache();
if ($this->request->isAJAX()) {
return $this->response->setJSON([
'success' => true,
'message' => 'Вы покинули организацию',
]);
}
$this->session->setFlashdata('success', 'Вы покинули организацию');
return redirect()->to('/organizations');
}
/**
public function resendInvite($orgId, $invitationId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership || !$this->access->canManageUsers()) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$invitationService = new \App\Services\InvitationService();
$result = $invitationService->resendInvitation($invitationId, $orgId);
if ($result['success']) {
$this->session->setFlashdata('success', 'Приглашение отправлено повторно');
} else {
$this->session->setFlashdata('error', $result['message']);
}
return redirect()->to("/organizations/users/{$orgId}");
}
/**
public function cancelInvite($orgId, $invitationId)
{
$orgId = (int) $orgId;
$membership = $this->getMembership($orgId);
if (!$membership || !$this->access->canManageUsers()) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
$invitationService = new \App\Services\InvitationService();
$result = $invitationService->cancelInvitation($invitationId, $orgId);
if ($result['success']) {
$this->session->setFlashdata('success', 'Приглашение отозвано');
} else {
$this->session->setFlashdata('error', $result['message']);
}
return redirect()->to("/organizations/users/{$orgId}");
}
/**
public function updateUserRole($orgId, $userId)
{
$orgId = (int) $orgId;
$userId = (int) $userId;
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
if (!$this->access->canManageUsers()) {
return $this->redirectWithError('У вас нет прав для изменения ролей', "/organizations/users/{$orgId}");
}
$targetMembership = $this->getOrgUserModel()
->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$targetMembership) {
return $this->redirectWithError('Пользователь не найден в организации', "/organizations/users/{$orgId}");
}
if ($targetMembership['role'] === 'owner') {
return $this->redirectWithError('Нельзя изменить роль владельца', "/organizations/users/{$orgId}");
}
if ($this->request->getMethod() === 'POST') {
$newRole = $this->request->getPost('role');
$availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']);
if (!in_array($newRole, $availableRoles)) {
return redirect()->back()->withInput()->with('error', 'Недопустимая роль');
}
$this->getOrgUserModel()->update($targetMembership['id'], [
'role' => $newRole,
]);
$this->session->setFlashdata('success', 'Роль изменена');
return redirect()->to("/organizations/users/{$orgId}");
}
$userModel = new UserModel();
$user = $userModel->find($userId);
return $this->renderTwig('organizations/edit_user_role', [
'organization_id' => $orgId,
'user' => $user,
'current_role' => $targetMembership['role'],
'available_roles' => $availableRoles,
]);
}
/**
public function removeUser($orgId, $userId)
{
$orgId = (int) $orgId;
$userId = (int) $userId;
$currentUserId = $this->getCurrentUserId();
$membership = $this->getMembership($orgId);
if (!$membership) {
return $this->redirectWithError('Доступ запрещен', '/organizations');
}
if (!$this->access->canManageUsers()) {
return $this->redirectWithError('У вас нет прав для удаления пользователей', "/organizations/users/{$orgId}");
}
$targetMembership = $this->getOrgUserModel()
->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$targetMembership) {
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
}
if ($targetMembership['role'] === 'owner') {
return $this->redirectWithError('Нельзя удалить владельца организации', "/organizations/users/{$orgId}");
}
if ($userId === $currentUserId) {
return $this->redirectWithError('Нельзя удалить себя из организации', "/organizations/users/{$orgId}");
}
$this->getOrgUserModel()->delete($targetMembership['id']);
$this->session->setFlashdata('success', 'Пользователь удалён из организации');
return redirect()->to("/organizations/users/{$orgId}");
}
}
// app/Controllers/ForgotPassword.php
userModel = new UserModel();
$this->emailLibrary = new EmailLibrary();
try {
$this->rateLimitService = RateLimitService::getInstance();
} catch (\Exception $e) {
log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage());
$this->rateLimitService = null;
}
}
/**
protected function checkRateLimit(string $action): ?array
{
if ($this->rateLimitService === null) {
return null;
}
if ($this->rateLimitService->isBlocked($action)) {
$ttl = $this->rateLimitService->getBlockTimeLeft($action);
return [
'blocked' => true,
'message' => "Слишком много попыток. Повторите через {$ttl} секунд.",
'ttl' => $ttl,
];
}
return null;
}
/**
protected function resetRateLimit(string $action): void
{
if ($this->rateLimitService !== null) {
$this->rateLimitService->resetAttempts($action);
}
}
/**
protected function recordFailedAttempt(string $action): ?array
{
if ($this->rateLimitService === null) {
return null;
}
$result = $this->rateLimitService->recordFailedAttempt($action);
if ($result['blocked']) {
return [
'blocked' => true,
'message' => "Превышено максимальное количество попыток. Доступ заблокирован на {$result['block_ttl']} секунд.",
'ttl' => $result['block_ttl'],
];
}
return null;
}
/**
public function index()
{
if (session()->get('isLoggedIn')) {
return redirect()->to('/');
}
return $this->renderTwig('auth/forgot_password');
}
/**
public function sendResetLink()
{
if ($this->request->getMethod() !== 'POST') {
return redirect()->to('/forgot-password');
}
$rateLimitError = $this->checkRateLimit('reset');
if ($rateLimitError !== null) {
return redirect()->back()
->with('error', $rateLimitError['message'])
->withInput();
}
$email = trim($this->request->getPost('email'));
if (empty($email)) {
return redirect()->back()->with('error', 'Введите email адрес')->withInput();
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return redirect()->back()->with('error', 'Введите корректный email адрес')->withInput();
}
$user = $this->userModel->findByEmail($email);
if (!$user) {
$this->recordFailedAttempt('reset');
}
if ($user) {
$token = $this->userModel->generateResetToken($user['id']);
$this->emailLibrary->sendPasswordResetEmail(
$user['email'],
$user['name'],
$token
);
log_message('info', "Password reset link sent to {$email}");
}
$this->resetRateLimit('reset');
return redirect()->back()->with(
'success',
'Если email зарегистрирован в системе, на него будет отправлена ссылка для сброса пароля.'
);
}
/**
public function reset($token = null)
{
if (session()->get('isLoggedIn')) {
return redirect()->to('/');
}
if (empty($token)) {
return redirect()->to('/forgot-password')->with('error', 'Недействительная ссылка для сброса пароля.');
}
$user = $this->userModel->verifyResetToken($token);
if (!$user) {
return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.');
}
return $this->renderTwig('auth/reset_password', [
'token' => $token,
'email' => $user['email'],
]);
}
/**
public function updatePassword()
{
if ($this->request->getMethod() !== 'POST') {
return redirect()->to('/forgot-password');
}
$token = $this->request->getPost('token');
$password = $this->request->getPost('password');
$passwordConfirm = $this->request->getPost('password_confirm');
if (empty($token)) {
return redirect()->back()->with('error', 'Ошибка валидации токена.');
}
if (empty($password)) {
return redirect()->back()->with('error', 'Введите новый пароль')->withInput();
}
if (strlen($password) < 6) {
return redirect()->back()->with('error', 'Пароль должен содержать минимум 6 символов')->withInput();
}
if ($password !== $passwordConfirm) {
return redirect()->back()->with('error', 'Пароли не совпадают')->withInput();
}
$user = $this->userModel->verifyResetToken($token);
if (!$user) {
return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.');
}
$this->userModel->update($user['id'], ['password' => $password]);
$this->userModel->clearResetToken($user['id']);
$db = \Config\Database::connect();
$db->table('remember_tokens')->where('user_id', $user['id'])->delete();
log_message('info', "Password reset completed for user {$user['email']}");
return redirect()->to('/login')->with(
'success',
'Пароль успешно изменён. Теперь вы можете войти с новым паролем.'
);
}
}
// app/Controllers/Home.php
get('isLoggedIn')) {
return $this->renderTwig('landing/index');
}
$orgId = session()->get('active_org_id');
if (empty($orgId)){
session()->remove('active_org_id');
return redirect()->to('/organizations');
}
$data = [
'title' => 'Рабочий стол',
];
return $this->renderTwig('dashboard/index', $data);
}
}
// .gitignore
#-------------------------
# Operating Specific Junk Files
#-------------------------
# OS X
.DS_Store
.AppleDouble
.LSOverride
# OS X Thumbnails
._*
# Windows image file caches
Thumbs.db
ehthumbs.db
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
# Linux
*~
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
#-------------------------
# Environment Files
#-------------------------
# These should never be under version control,
# as it poses a security risk.
.env
.vagrant
Vagrantfile
#-------------------------
# Temporary Files
#-------------------------
writable/cache/*
!writable/cache/index.html
writable/logs/*
!writable/logs/index.html
writable/session/*
!writable/session/index.html
writable/uploads/*
!writable/uploads/index.html
writable/debugbar/*
!writable/debugbar/index.html
php_errors.log
#-------------------------
# User Guide Temp Files
#-------------------------
user_guide_src/build/*
user_guide_src/cilexer/build/*
user_guide_src/cilexer/dist/*
user_guide_src/cilexer/pycilexer.egg-info/*
#-------------------------
# Test Files
#-------------------------
tests/coverage*
# Don't save phpunit under version control.
phpunit
#-------------------------
# Composer
#-------------------------
vendor/
#-------------------------
# IDE / Development Files
#-------------------------
# Modules Testing
_modules/*
# phpenv local config
.php-version
# Jetbrains editors (PHPStorm, etc)
.idea/
*.iml
# NetBeans
/nbproject/
/build/
/nbbuild/
/dist/
/nbdist/
/nbactions.xml
/nb-configuration.xml
/.nb-gradle/
# Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
.phpintel
/api/
# Visual Studio Code
.vscode/
/results/
/phpunit*.xml
// docs/ARCHITECTURE.md
# Архитектура системы
## 1. Организации и типы пространств
### 1.1. Концепция организации в системе
Центральным понятием архитектуры системы является организация (`organization`), которая представляет собой контейнер для всех бизнес-данных и определяет границы доступа между различными пользователями и командами. Все данные системы, за исключением глобальных настроек и системных конфигураций, принадлежат конкретной организации и изолированы на уровне базы данных. Каждая запись в таблицах организационного пространства содержит поле `organization_id`, которое связывает её с конкретной организацией и обеспечивает автоматическую фильтрацию данных при работе с моделями.
Организация в системе имеет несколько ключевых характеристик, определяющих её поведение и ограничения. К таким характеристикам относятся тип организации, статус подписки на модули, список пользователей с их ролями и дата создания. Информация об организации хранится в таблице `organizations` и доступна через соответствующую модель `OrganizationModel`. При аутентификации пользователя система определяет текущую организацию из сессии и использует её контекст для всех последующих операций с данными. Связь пользователя с организацией хранится в таблице `organization_users`, которая определяет роль пользователя в данной организации.
### 1.2. Типы организаций
Система поддерживает два основных типа организаций, каждый из которых имеет свои особенности и ограничения. Первый тип — это стандартная организация, предназначенная для компаний и команд, которым необходимо совместная работа нескольких пользователей. Стандартная организация может содержать неограниченное количество пользователей с различными ролями (Owner, Admin, Manager), имеет полный доступ ко всем функциям системы и может оформлять подписки на модули для всей команды. Этот тип организации является основным сценарием использования системы для бизнес-целей.
Второй тип — личная организация (personal organization), которая создаётся для индивидуальных пользователей, таких как физические лица, индивидуальные предприниматели или самозанятые. Личная организация имеет принципиальное отличие от стандартной: в неё невозможно приглашать других пользователей. Владелец личной организации является её единственным участником и автоматически получает роль Owner. Личные организации используют ту же самую архитектуру и механизмы, что и стандартные организации, но имеют специальный флаг `is_personal = true`, который блокирует функционал приглашения пользователей и управления составом команды. Этот подход обеспечивает единообразие кода и позволяет использовать общие компоненты для обоих типов организаций.
### 1.3. Мультитенантность и изоляция данных
Изоляция данных между организациями обеспечивается на нескольких уровнях архитектуры системы, что гарантирует безопасность и конфиденциальность данных каждой организации. На уровне базы данных каждая таблица организационного пространства содержит поле `organization_id`, которое является внешним ключом к таблице организаций. Это поле имеет ограничение внешнего ключа и обеспечивает ссылочную целостность данных. При создании новой записи система автоматически устанавливает `organization_id` из текущей сессии пользователя, что гарантирует принадлежность записи к конкретной организации и исключает возможность случайного или намеренного создания записей в другой организации.
На уровне приложения изоляция обеспечивается через trait `TenantScopedModel`, который автоматически добавляет условие `WHERE organization_id = session->get('organization_id')` ко всем запросам выборки. Это происходит прозрачно для разработчика — при использовании модели с этим trait не требуется явная фильтрация по организации. Trait также перехватывает методы создания, обновления и удаления, автоматически устанавливая `organization_id` для новых записей и предотвращая модификацию записей других организаций. Попытка обновить запись с `organization_id`, отличным от текущей сессии, приведёт к исключению `OrganizationMismatchException`. Важно понимать, что `TenantScopedModel` обеспечивает только фильтрацию данных — проверка прав доступа должна выполняться отдельно через `AccessService`.
## 2. Система ролей
### 2.1. Роли и их иерархия
Система реализует иерархическую модель ролей с чётким распределением полномочий между участниками организации. Роль **Owner** (Владелец) является высшей ролью в организации и принадлежит пользователю, который создал организацию. Владелец имеет полный доступ ко всем функциям системы без ограничений, включая управление подпиской организации, удаление организации, назначение и снятие ролей с других пользователей. Владелец не может быть удалён из организации и всегда сохраняет свои полномочия независимо от изменений в подписке. Система гарантирует, что в каждой организации есть как минимум один владелец, и эта роль не может быть передана или снята последним владельцем.
Роль **Admin** (Администратор) предоставляется ключевым сотрудникам для управления повседневными операциями организации. Администратор имеет доступ ко всем функциям модулей, на которые оформлена подписка, за исключением финансовых операций и управления подпиской. Администратор может управлять пользователями организации (приглашать, удалять, изменять роли), но не может удалить владельца или изменить его роль. Администратор также не может управлять биллингом организации. Роль Admin предназначена для делегирования административных полномочий без передачи прав на изменение структуры организации или финансовых обязательств.
Роль **Manager** (Менеджер) предоставляется сотрудникам, которые работают с клиентами и сделками. Менеджер имеет доступ к функциям в рамках своих прав, определённых матрицей разрешений. Менеджер может создавать и редактировать клиентов, сделки и задачи, но не может удалять критически важные данные или управлять пользователями организации. Точный набор прав менеджера зависит от настроек конкретного модуля и может быть настроен индивидуально. Роль Manager является базовой ролью для обычных пользователей организации, которые несут ответственность за выполнение рабочих задач, но не управляют командой или системными настройками.
### 2.2. Матрица разрешений
Матрица разрешений определяет, какие действия доступны пользователям с каждой ролью для различных ресурсов системы. Матрица хранится в конфигурации сервиса `AccessService` и представляет собой ассоциативный массив, где ключом является название ресурса (например, `clients`, `deals`, `invoices`), а значением — массив допустимых действий для каждой роли. Каждое действие описывается строкой (`view`, `create`, `edit`, `delete`), которая соответствует методу проверки в сервисе доступа. При проверке прав сервис сравнивает роль пользователя и запрашиваемое действие с матрицей разрешений.
Сервис `AccessService` предоставляет набор методов для проверки разрешений: `canView($resource)`, `canCreate($resource)`, `canEdit($resource)`, `canDelete($resource)` и `isOwner()`. Метод `canView` проверяет возможность просмотра ресурса, `canCreate` — создания новых записей, `canEdit` — редактирования существующих записей, `canDelete` — удаления записей. Метод `isOwner` проверяет, является ли текущий пользователь владельцем организации, и возвращает `true` для владельцев всех организаций. При отсутствии явного разрешения в матрице для данной роли и ресурса метод возвращает `false`, что приводит к генерации исключения `AccessDeniedException` или возврату ошибки в контроллере.
## 3. Роутинг и структура URL
### 3.1. Организация маршрутов
Система использует стандартный роутинг CodeIgniter 4 с дополнительной логикой фильтрации и проверки доступа. Базовые маршруты определены в файле `app/Config/Routes.php` и следуют RESTful-паттернам с учётом специфики мультитенантности. Все маршруты, связанные с организационным пространством, проходят через фильтры аутентификации и проверки организации. Фильтр `auth` проверяет наличие авторизованного пользователя в сессии и перенаправляет на страницу входа при отсутствии. Фильтр `tenant` проверяет корректность контекста организации и валидность подписок на модули.
Структура URL строится по принципу `/{module}/{controller}/{action}/{id?}`. Модули организованы в отдельные директории внутри `app/Modules/`, что позволяет добавлять новую функциональность без изменения основного ядра. Роуты модулей регистрируются через метод `$routes->group('modules', ['filter' => 'auth'], function ($routes) {...})`, который применяет фильтр аутентификации ко всем маршрутам внутри группы. Это обеспечивает централизованную проверку авторизации и упрощает управление доступом. Вложенные группы маршрутов используются для дополнительной логики, такой как проверка подписки на модуль или применение специфичных бизнес-правил.
### 3.2. Контекст организации в URL
Роутинг в системе построен таким образом, что URL не содержит явного указания организации — контекст определяется из сессии пользователя. Это обеспечивает безопасность и предотвращает попытки получить доступ к данным чужой организации через манипуляцию URL. При переключении между организациями система обновляет значения `organization_id` и `role` в сессии пользователя, сохраняя при этом `user_id` неизменным. Это позволяет пользователю иметь доступ к нескольким организациям и работать с данными каждой из них в изолированном контексте без необходимости повторной аутентификации.
Все контроллеры организационного пространства автоматически получают доступ к `organization_id` через `TenantScopedModel` и не требуют явного указания организации в URL. При попытке доступа к данным другой организации система автоматически вернёт ошибку 403 или перенаправит на страницу ошибки. Важно понимать, что при смене организации все загруженные в память данные становятся неактуальными и должны быть перезагружены в контексте новой организации. На практике это означает, что после переключения организации пользователю может потребоваться обновить страницу или система автоматически перенаправит его на соответствующую страницу.
## 4. Типичные паттерны разработки
### 4.1. Создание модели организационного пространства
Создание новой модели организационного пространства требует следования установленным паттернам для обеспечения корректной работы мультитенантности. Модель должна наследоваться от базового класса `CodeIgniter\Model` или `App\Models\BaseModel` и использовать trait `TenantScopedModel`. В классе модели определяются защищённые свойства `$table`, `$primaryKey`, `$allowedFields` и `$useTimestamps` в соответствии со структурой таблицы базы данных. Trait `TenantScopedModel` автоматически обрабатывает поля `organization_id` и временные метки (`created_at`, `updated_at`, `deleted_at` для мягкого удаления).
При реализации модели необходимо добавить поля организации и временных меток в массив `$allowedFields`, чтобы они автоматически заполнялись при создании и обновлении записей. Trait перехватывает метод `insert()` и автоматически устанавливает значение `organization_id` из сессии. Методы `update()` и `delete()` автоматически добавляют условие `WHERE organization_id = session->get('organization_id')` для предотвращения модификации записей других организаций. При использовании метода `withDeleted()` для получения мягко удалённых записей фильтрация по организации также применяется. После создания модель готова к использованию в контроллерах с автоматической фильтрацией по организации.
### 4.2. Проверка прав доступа в контроллерах
Проверка прав доступа в контроллерах осуществляется через сервис `AccessService`, который предоставляет набор методов для проверки разрешений. Типичный паттерн проверки прав в контроллере выглядит следующим образом: в начале метода контроллера вызывается проверка соответствующего разрешения, и если проверка не проходит, генерируется исключение или возвращается ошибка. Например, метод создания нового клиента начинается с проверки `if (!$this->access->canCreate('clients')) throw new \Exception('Access denied');`. Это обеспечивает централизованный контроль доступа и предотвращает несанкционированные действия.
Все проверки прав доступа логируются через `EventManager` для аудита и отладки. При необходимости можно добавить дополнительные условия в проверку, например, проверить, что пользователь является владельцем конкретной записи, а не просто имеет право на редактирование этого типа ресурсов. Для этого используется метод `isOwner()` в сочетании с дополнительной проверкой идентификатора записи. Документация по доступным методам и ресурсам находится в файле `ACCESS_HELP.md`, а подробное описание матрицы разрешений — в исходном коде `AccessService`.
### 4.3. Проверка прав доступа в шаблонах Twig
Проверка прав доступа в шаблонах Twig осуществляется через глобальные переменные, доступные во всех шаблонах. Объект `access` автоматически передаётся в каждый шаблон и содержит все методы проверки разрешений. Это позволяет использовать проверку прав непосредственно в разметке шаблона для скрытия или отображения элементов интерфейса. Синтаксис проверки в Twig аналогичен синтаксису в контроллерах: `{% if access.canEdit('clients') %} ... {% endif %}`.
Практическое применение проверки прав в шаблонах включает скрытие кнопок редактирования и удаления для пользователей без соответствующих прав, отображение информационных сообщений о недоступных функциях, адаптацию интерфейса под роли пользователей. Например, кнопка удаления клиента оборачивается в условие `{% if access.canDelete('clients') %}{% endif %}`, и пользователи без права удаления не увидят эту кнопку. Это обеспечивает интуитивно понятный интерфейс и предотвращает попытки выполнить недоступные действия. Комбинирование проверок позволяет создавать сложные условия отображения элементов в зависимости от нескольких факторов.
## 5. Система событий
### 5.1. Типы событий
Система событий позволяет расширять функциональность модулей без модификации их исходного кода. События делятся на два типа: системные события (`systemOn`) и модульные события (`moduleOn`). Системные события срабатывают глобально для всей системы и используются для сквозной функциональности, такой как логирование, аудит, интеграции. Модульные события срабатывают только при активной подписке на соответствующий модуль, что позволяет создавать расширения для модулей, которые могут быть включены или выключены в зависимости от подписки организации.
Именование событий следует паттерну `{resource}.{action}`, например, `clients.create`, `deals.update`, `invoices.delete`. При срабатывании события обработчики получают контекст, который обычно включает модель, действие и связанные данные. Контекст события позволяет обработчикам получить доступ к изменённым данным, выполнить дополнительные операции или отменить действие (для событий, поддерживающих отмену). Система событий поддерживает приоритеты обработчиков, что позволяет контролировать порядок выполнения нескольких обработчиков одного события.
### 5.2. Подписка на события
Подписка на события осуществляется через сервис `EventManager` методом `on($eventName, $callback, $priority)`. Callback-функция получает контекст события и может выполнять произвольные операции. Пример подписки на событие создания клиента: `$events->on('clients.create', function ($context) { ... });`. События можно использовать для автоматической отправки уведомлений, синхронизации данных с внешними системами, ведения аудита действий пользователей. Подписка на события обычно выполняется в сервис-провайдерах или при инициализации модулей.
Модульные события требуют дополнительной проверки подписки на модуль. При использовании метода `moduleOn($moduleName, $eventName, $callback)` система автоматически проверяет, активна ли подписка организации на указанный модуль, и регистрирует обработчик только при наличии подписки. Это позволяет создавать расширения для модулей, которые устанавливаются отдельно и активируются только при наличии лицензии. Документация по событиям и их использованию находится в файле `EVENT_MANAGER_HELP.md`.
## 6. Компонент динамических таблиц
### 6.1. Обзор компонента
Компонент динамических таблиц (`DataTable`) позволяет создавать интерактивные таблицы данных с сортировкой, фильтрацией, пагинацией и действиями. Компонент состоит из двух частей: серверной (PHP-контроллер, возвращающий данные в стандартизированном формате) и клиентской (JavaScript-класс, выполняющий AJAX-запросы и отображение таблицы). Серверная часть должна возвращать данные в формате JSON с ключами `data` (массив записей), `recordsTotal` (общее количество записей), `recordsFiltered` (количество записей после фильтрации) и `draw` (счётчик запросов для защиты от CSRF).
Клиентская часть компонента инициализируется для каждой таблицы на странице и автоматически загружает данные при отображении. Компонент поддерживает серверную пагинацию, сортировку по колонкам, текстовый поиск и фильтрацию по дополнительным параметрам. Для каждой строки таблицы могут отображаться кнопки действий (редактирование, удаление, просмотр), которые настраиваются через конфигурацию компонента. Компонент также поддерживает массовые действия с использованием чекбоксов в первой колонке таблицы.
### 6.2. Использование в контроллере и шаблоне
Для использования компонента в контроллере необходимо подготовить данные в стандартизированном формате JSON и передать их в шаблон. Контроллер должен получить параметры запроса (номер страницы, количество записей, параметры сортировки, поисковый запрос), выполнить запрос к базе данных с применением фильтров, и вернуть результат в требуемом формате. Параметры `page` и `perPage` используются для расчёта смещения (`offset`) при пагинации, параметры `orderBy` и `orderDir` — для сортировки, параметр `search` — для текстового поиска.
В шаблоне Twig компонент подключается через макросы из файла `table.twig`, который находится в директории макросов модуля. Для отображения таблицы вызывается макрос `table.render()` с передачей конфигурационного объекта, содержащего параметры отображения и URL для загрузки данных. Конфигурация включает определение колонок (заголовок, поле данных, формат отображения), наличие чекбоксов для массовых действий, кнопки действий для каждой строки. Документация по параметрам и использованию компонента находится в файле `DATATABLE_HELP.md`.
## 7. Развитие системы
### 7.1. Создание нового модуля
Создание нового модуля требует следования установленной структуре директорий и файловой организации. Модуль размещается в директории `app/Modules/{ModuleName}/` и содержит поддиректории для контроллеров (`Controllers/`), моделей (`Models/`), представлений (`Views/`) и ресурсов (`Assets/`). Каждый модуль должен иметь файл конфигурации подписки `Modules/{ModuleName}/Config/Subscription.php`, который определяет название модуля, описание, стоимость и зависимости от других модулей. Файл конфигурации также определяет события, которые генерирует модуль, и может содержать настройки по умолчанию.
После создания структуры модуля необходимо зарегистрировать его роуты в файле `app/Config/Routes.php` внутри группы модулей с применением фильтра аутентификации. Контроллеры модуля должны наследоваться от `BaseController` для обеспечения доступа к общим сервисам и функциональности. Модели организационного пространства должны использовать trait `TenantScopedModel`. После создания модуль автоматически появится в списке доступных модулей в админке, и его можно будет активировать через систему подписок. При разработке модуля рекомендуется использовать существующие модули (Clients, Deals) в качестве образца структуры и паттернов.
### 7.2. Интеграция внешних сервисов
Интеграция внешнего сервиса осуществляется через создание адаптера в директории `app/Services/External/` и подписку на соответствующие события системы. Адаптер инкапсулирует логику взаимодействия с внешним API, включая аутентификацию, обработку ошибок и преобразование данных. После создания адаптера необходимо зарегистрировать его как сервис в `app/Config/Services.php` для удобного доступа из других частей приложения. Адаптеры должны быть независимыми от контекста организации и обрабатывать аутентификацию внешних сервисов через конфигурацию системы.
Связывание внешнего сервиса с системой осуществляется через подписку на события. Например, для отправки SMS при создании заказа подписываемся на событие `orders.create` и вызываем метод адаптера отправки SMS. Это позволяет добавлять и удалять интеграции без изменения бизнес-логики модулей. Для сложных интеграций рекомендуется создавать отдельные классы-коннекторы с методами для каждого типа операции и использовать их из обработчиков событий. Все обращения к внешним сервисам должны быть обёрнуты в try-catch блоки для корректной обработки ошибок и логирования сбоев интеграции.
### 7.3. Система подписок модулей
Система подписок управляет доступом к функциональности модулей в зависимости от оплаченного плана организации. Каждый модуль имеет файл конфигурации, который определяет его стоимость, название, описание и требуемые разрешения. При активации модуля система проверяет, что у организации достаточно средств или соответствующий план подписки, и создаёт запись в таблице подписок. Статус подписки хранится в базе данных и проверяется при каждом доступе к функциональности модуля. Информация о текущих подписках доступна через `ModuleSubscriptionService`, который предоставляет методы для проверки статуса модуля, получения списка активных подписок и управления ими.
Доступ к модульным событиям и функциональности автоматически ограничивается статусом подписки. При неактивной подписке попытка доступа к модулю возвращает ошибку 403 или перенаправляет на страницу оплаты. Система также обеспечивает автоматическое списание средств при истечении срока подписки и уведомление пользователей о необходимости продления. Для личных организаций доступен упрощённый набор модулей, оптимизированный для индивидуального использования, без функционала командной работы.
// docs/EVENTS.md
# Справка по системе событий EventManager
## Общее описание
EventManager — это сервис для работы с событиями в системе «Бизнес.Точка». Он является обёрткой над встроенной системой событий CodeIgniter 4 и предоставляет два типа подписок на события:
- **moduleOn()** — обработчик выполняется только при наличии активной подписки на модуль
- **systemOn()** — обработчик выполняется всегда, без проверки статуса подписки
EventManager используется для создания интеграций между модулями, когда действия в одном модуле должны автоматически вызывать события в другом. Например, при создании клиента в CRM может автоматически создаваться задача в модуле Tasks.
---
## Подключение EventManager
EventManager подключается как сервис через `service('eventManager')`:
```php
$eventManager = service('eventManager');
```
Доступно в контроллерах через BaseController:
```php
// В контроллере:
$em = service('eventManager');
```
---
## Основные методы
### forModule() — привязка к модулю
Метод `forModule()` привязывает все последующие вызовы `moduleOn()` к указанному модулю. Это означает, что подписки на события будут создаваться только если организация имеет активную подписку на этот модуль.
```php
$em = service('eventManager');
// Привязываем события к модулю CRM
$em->forModule('crm');
```
После вызова `forModule()` все события, добавленные через `moduleOn()`, будут проверять подписку организации на модуль CRM. Если подписка не активна — обработчик не будет выполнен.
```php
// Цепочка вызовов
service('eventManager')
->forModule('crm')
->moduleOn('client.created', function($client) {
// Этот код выполнится только если подписка на CRM активна
});
```
**Важно:** Метод `forModule()` необходимо вызвать перед `moduleOn()`, иначе будет выброшено исключение.
---
### moduleOn() — подписка с проверкой модуля
Метод `moduleOn()` создаёт подписку на событие, которая выполняется только при соблюдении условий:
1. Модуль существует в конфигурации `BusinessModules`
2. Модуль глобально включён в настройках
3. Организация имеет активную подписку на модуль
```php
$em = service('eventManager');
$em->forModule('crm');
$em->moduleOn('client.created', function($client) {
// Обработчик выполнится только если CRM подписка активна
log_message('debug', 'Клиент создан: ' . $client['name']);
});
```
**Сигнатура метода:**
```php
public function moduleOn(
string $event, // Имя события
callable $callback, // Обработчик события
int $priority = 100 // Приоритет выполнения
): bool
```
**Возвращаемое значение:**
- `true` — подписка создана, обработчик будет выполнен
- `false` — подписка не создана (модуль не активен, отключён или не существует)
**Параметр `$callback`:**
Обработчик события получает параметры, переданные при вызове события:
```php
$em->forModule('crm');
$em->moduleOn('client.created', function($client, $userId) {
echo 'Создан клиент ' . $client['name'] . ' пользователем ' . $userId;
});
// Где-то в коде:
Events::trigger('client.created', $clientData, $currentUserId);
```
**Приоритет выполнения:**
Параметр `$priority` определяет порядок выполнения обработчиков. Меньшее значение — более высокий приоритет:
```php
// Выполнится раньше (приоритет 50)
$em->moduleOn('client.created', function($client) {
// Логирование
}, 50);
// Выполнится позже (приоритет 100, значение по умолчанию)
$em->moduleOn('client.created', function($client) {
// Отправка уведомлений
});
```
---
### systemOn() — подписка без проверки модуля
Метод `systemOn()` создаёт подписку на событие без проверки статуса подписки. Обработчик будет выполнен всегда, независимо от того, какие модули активированы у организации.
Используется для системных событий, которые должны работать для всех организаций:
```php
$em = service('eventManager');
// Этот обработчик выполнится для всех организаций
$em->systemOn('user.login', function($user) {
log_message('info', 'Пользователь вошёл: ' . $user['email']);
});
// Для отправки email-уведомлений при любом действии
$em->systemOn('email.send', function($to, $subject, $body) {
// Логирование отправки
});
```
**Сигнатура метода:**
```php
public function systemOn(
string $event,
callable $callback,
int $priority = 100
): void
```
---
### off() — отписка от события
Метод `off()` удаляет подписку на событие:
```php
$em = service('eventManager');
// Удаление всех обработчиков события
$em->off('client.created');
// Удаление конкретного обработчика
$em->off('client.created', $specificCallback);
```
---
### currentModuleActive() — проверка статуса модуля
Метод `currentModuleActive()` возвращает `true` если текущий модуль (установленный через `forModule()`) активен для организации.
Используется внутри обработчиков для проверки:
```php
$em->service('eventManager');
$em->forModule('tasks');
$em->moduleOn('deal.won', function($deal) {
// Проверяем, активен ли модуль Tasks
if ($em->currentModuleActive()) {
// Создаём задачу
createTaskForDeal($deal);
}
});
```
---
### getCurrentModuleCode() — получение кода модуля
Метод `getCurrentModuleCode()` возвращает код модуля, установленного через `forModule()`:
```php
$em = service('eventManager');
$em->forModule('crm');
$code = $em->getCurrentModuleCode(); // Вернёт 'crm'
```
---
## Встроенные события системы
### События пользователя
```php
// После успешной регистрации пользователя
Events::trigger('user.registered', $user);
// После подтверждения email
Events::trigger('user.verified', $user);
// При каждом входе в систему
Events::trigger('user.login', $user);
// При выходе из системы
Events::trigger('user.logout', $user);
// При смене пароля
Events::trigger('user.passwordChanged', $user);
// При изменении профиля
Events::trigger('user.profileUpdated', $user, $oldData);
```
### События организации
```php
// При создании организации
Events::trigger('organization.created', $organization);
// При изменении данных организации
Events::trigger('organization.updated', $organization, $changes);
// При удалении организации (до удаления)
Events::trigger('organization.deleting', $organization);
// После удаления организации
Events::trigger('organization.deleted', $organizationId);
// При присоединении пользователя к организации
Events::trigger('organization.userJoined', $organization, $user, $role);
// При выходе пользователя из организации
Events::trigger('organization.userLeft', $organization, $user);
// При изменении роли пользователя
Events::trigger('organization.userRoleChanged', $organization, $user, $oldRole, $newRole);
```
### События модуля Клиенты
```php
// При создании клиента
Events::trigger('client.created', $client, $userId);
// При обновлении клиента
Events::trigger('client.updated', $client, $changes, $userId);
// При удалении клиента
Events::trigger('client.deleted', $clientId, $userId);
// При импорте клиентов
Events::trigger('client.imported', $clients, $userId);
```
---
## Примеры интеграции модулей
### Пример 1: Создание задачи при создании клиента
```php
// В модуле Tasks — файл bootstrap или Config/Events.php
service('eventManager')
->forModule('tasks')
->moduleOn('client.created', function($client, $userId) {
// Создаём задачу на первичный контакт
$taskModel = new \App\Modules\Tasks\Models\TaskModel();
$taskModel->insert([
'organization_id' => $client['organization_id'],
'title' => 'Первый контакт с клиентом: ' . $client['name'],
'description' => 'Необходимо связаться с клиентом для уточнения потребностей',
'assigned_to' => $userId,
'status' => 'todo',
'priority' => 'medium',
'due_at' => date('Y-m-d H:i:s', strtotime('+1 day')),
'created_by' => $userId,
]);
});
```
### Пример 2: Автоматический переход сделки при завершении задачи
```php
// В модуле CRM
service('eventManager')
->forModule('crm')
->moduleOn('task.completed', function($task, $userId) {
if ($task['related_type'] === 'deal' && $task['related_id']) {
$dealModel = new \App\Modules\CRM\Models\DealModel();
// Получаем сделку
$deal = $dealModel->find($task['related_id']);
if ($deal && $deal['stage'] === 'proposal') {
// Переводим сделку на следующий этап
$dealModel->update($deal['id'], [
'stage' => 'negotiation',
'updated_at' => date('Y-m-d H:i:s'),
]);
// Логируем переход
log_message('info', 'Сделка #' . $deal['id'] . ' переведена на этап переговоров после завершения задачи');
}
}
});
```
### Пример 3: Уведомление при записи на приём
```php
// В модуле Booking
service('eventManager')
->forModule('booking')
->moduleOn('booking.created', function($booking, $client, $userId) {
// Отправляем уведомление клиенту
$emailService = service('email');
$emailService->send(
$client['email'],
'Подтверждение записи',
'Уважаемый ' . $client['name'] . ',
Ваша запись на ' . $booking['service_name'] . ' подтверждена на ' .
date('d.m.Y в H:i', strtotime($booking['starts_at']))
);
// Создаём задачу для менеджера
$taskModel = new \App\Modules\Tasks\Models\TaskModel();
$taskModel->insert([
'organization_id' => $booking['organization_id'],
'title' => 'Подготовка к записи: ' . $client['name'],
'description' => 'Клиент записан на услугу ' . $booking['service_name'],
'assigned_to' => $booking['staff_id'],
'status' => 'todo',
'priority' => 'normal',
'due_at' => $booking['starts_at'],
]);
});
```
### Пример 4: Создание проекта Proof при выигрыше сделки
```php
// В модуле CRM
service('eventManager')
->forModule('crm')
->moduleOn('deal.won', function($deal, $userId) {
// Проверяем, активен ли модуль Proof
if (service('moduleSubscription')->isModuleActive('proof')) {
$projectModel = new \App\Modules\Proof\Models\ProjectModel();
$projectModel->insert([
'organization_id' => $deal['organization_id'],
'client_id' => $deal['client_id'],
'title' => 'Проект по сделке #' . $deal['id'],
'description' => 'Автоматически создан при выигрыше сделки',
'status' => 'active',
'created_by' => $userId,
]);
}
});
```
---
## Правила именования событий
Для консистентности системы используйте следующие правила именования событий:
### Формат: `сущность.действие`
| Сущность | Действия | Пример события |
|----------|----------|----------------|
| user | registered, verified, login, logout, passwordChanged, profileUpdated | `user.login` |
| organization | created, updated, deleting, deleted, userJoined, userLeft, userRoleChanged | `organization.created` |
| client | created, updated, deleted, imported | `client.created` |
| deal | created, updated, deleted, won, lost | `deal.won` |
| booking | created, updated, cancelled, completed | `booking.created` |
| task | created, updated, deleted, started, completed | `task.completed` |
| project | created, updated, deleted, archived | `project.created` |
| file | uploaded, deleted, approved, rejected | `file.uploaded` |
| email | send, sent, failed | `email.send` |
### Группы событий модулей
- **CRM:** `client.*`, `deal.*`, `pipeline.*`
- **Booking:** `booking.*`, `service.*`, `staff.*`
- **Proof:** `project.*`, `file.*`, `comment.*`
- **Tasks:** `task.*`, `board.*`, `comment.*`
---
## Вызов событий в коде
Для вызова события используйте `Events::trigger()` из CodeIgniter 4:
```php
use CodeIgniter\Events\Events;
// Простой вызов
Events::trigger('client.created', $clientData);
// С несколькими параметрами
Events::trigger('deal.won', $deal, $userId);
// С именованными параметрами (начиная с CI 4.3+)
Events::trigger('client.updated', [
'client' => $clientData,
'changes' => $changes,
'userId' => $userId,
]);
```
---
## Порядок инициализации событий
События модулей должны инициализироваться в файле `app/Config/Events.php`:
```php
forModule('crm');
$em->moduleOn('client.created', function($client, $userId) {
// Логика создания задачи
});
// Интеграция CRM → Proof
$em->moduleOn('deal.won', function($deal, $userId) {
// Логика создания проекта
});
}
}
register_crm_events();
```
---
## Логирование событий
EventManager автоматически логирует информацию о подписках и выполнении событий:
- **Подписка создана:** `"EventManager: Subscribed to event 'client.created' for module 'crm'"`
- **Модуль отключён:** `"EventManager: Module 'crm' is disabled globally"`
- **Подписка не активна:** `"EventManager: Organization subscription not active for module 'crm'"`
- **Системное событие:** `"EventManager: System event subscribed: 'user.login'"`
Уровень логирования — `debug` для информации и `error` для ошибок конфигурации.
---
## Тестирование событий
### Ручное тестирование в разработке
```php
// В контроллере для тестирования
public function testEvent()
{
$testClient = [
'id' => 999,
'name' => 'Тестовый клиент',
'email' => 'test@example.com',
'organization_id' => session()->get('active_org_id'),
];
// Вызываем событие напрямую
Events::trigger('client.created', $testClient, session()->get('user_id'));
return 'Событие вызвано, проверьте логи';
}
```
### Отладка подписок
```php
// Получение всех обработчиков события
$handlers = Events::listeners('client.created');
foreach ($handlers as $handler) {
log_message('debug', 'Handler: ' . print_r($handler, true));
}
```
---
## Типичные ошибки и их устранение
### Ошибка: "Module code not set"
```php
// Неправильно:
$em->moduleOn('client.created', $callback);
// Правильно:
$em->forModule('crm')->moduleOn('client.created', $callback);
```
### Событие не срабатывает
Возможные причины:
1. Модуль не активирован для организации
2. Модуль отключён глобально в конфигурации
3. Ошибка в имени события
4. Исключение в обработчике блокирует выполнение
Проверка:
```php
// Проверка статуса модуля
$em = service('eventManager');
$em->forModule('crm');
if ($em->currentModuleActive()) {
echo 'Модуль активен';
} else {
echo 'Модуль не активен';
}
```
### Конфликты приоритетов
При использовании нескольких обработчиков одного события убедитесь в корректном порядке выполнения:
```php
// Сначала сохраняем данные
$em->moduleOn('deal.won', function($deal) {
saveDealToArchive($deal);
}, 10); // Выполнится первым
// Затем отправляем уведомления
$em->moduleOn('deal.won', function($deal) {
sendDealWonNotification($deal);
}, 100); // Выполнится вторым
```
---
## Сводка методов
| Метод | Описание | Возвращает |
|-------|----------|------------|
| `forModule($code)` | Привязать к модулю | `$this` |
| `moduleOn($event, $callback, $priority)` | Подписка с проверкой | `bool` |
| `systemOn($event, $callback, $priority)` | Системная подписка | `void` |
| `off($event, $callback)` | Отписка | `void` |
| `currentModuleActive()` | Проверка модуля | `bool` |
| `getCurrentModuleCode()` | Код модуля | `string\|null` |
// docs/ACCESS.md
# Справка по методам проверки прав доступа
## AccessService — доступ через сервис
AccessService подключается автоматически в BaseController как `$this->access`.
```php
// В контроллере:
if (!$this->access->can('create', 'clients')) {
return $this->forbiddenResponse('Нет прав для создания клиентов');
}
```
---
## Методы проверки ролей
### Проверка конкретной роли
```php
// Одной роли
$this->access->isRole('owner');
// Нескольких ролей
$this->access->isRole(['owner', 'admin']);
```
### Удобные методы для часто используемых проверок
```php
// Владелец организации
$this->access->isOwner();
// Администратор или владелец
$this->access->isAdmin();
// Менеджер, администратор или владелец
$this->access->isManagerOrHigher();
```
### Системные роли (суперадмин)
```php
// Суперадмин (доступ к панели суперадмина)
$this->access->isSuperadmin();
// Системный админ или суперадмин
$this->access->isSystemAdmin();
// Проверка произвольной системной роли
$this->access->isSystemRole('admin');
```
---
## Методы проверки прав на действия
### Универсальный метод can()
```php
// Проверка конкретного действия над ресурсом
$this->access->can('view', 'clients'); // Просмотр клиентов
$this->access->can('create', 'clients'); // Создание клиентов
$this->access->can('edit', 'clients'); // Редактирование клиентов
$this->access->can('delete', 'clients'); // Удаление своих клиентов
$this->access->can('delete_any', 'clients'); // Удаление любых клиентов
```
### Краткие методы для действий
```php
$this->access->canView('clients'); // Эквивалент can('view', 'clients')
$this->access->canCreate('clients'); // Эквивалент can('create', 'clients')
$this->access->canEdit('clients'); // Эквивалент can('edit', 'clients')
$this->access->canDelete('clients'); // Эквивалент can('delete', 'clients')
$this->access->canDelete('clients', true); // Эквивалент can('delete_any', 'clients')
```
### Права на специальные операции
```php
// Управление пользователями организации
$this->access->canManageUsers();
// Управление модулями (подписки)
$this->access->canManageModules();
// Просмотр финансовой информации
$this->access->canViewFinance();
// Удаление организации
$this->access->canDeleteOrganization();
// Передача прав владельца
$this->access->canTransferOwnership();
```
---
## Доступные ресурсы и действия
### Стандартные ресурсы модулей
| Ресурс | Описание | Доступные действия |
|------------|----------------|-------------------------|
| `clients` | Клиенты CRM | view, create, edit, delete, delete_any |
| `deals` | Сделки CRM | view, create, edit, delete, delete_any |
| `bookings` | Записи на приём | view, create, edit, delete, delete_any |
| `projects` | Проекты Proof | view, create, edit, delete, delete_any |
| `tasks` | Задачи | view, create, edit, delete, delete_any |
| `users` | Пользователи | view, create, edit, delete |
---
## Матрица прав по ролям
| Ресурс | Владелец | Администратор | Менеджер | Гость |
|-------------|----------|---------------|----------|-------|
| Клиенты | Полный | Полный | Полный | Просмотр |
| Сделки | Полный | Полный | Полный | Просмотр |
| Записи | Полный | Полный | Полный | Просмотр |
| Проекты | Полный | Полный | Полный | Просмотр |
| Задачи | Полный | Полный | Полный | Просмотр |
| Пользователи| Полный | Просмотр, создание, редактирование | Только просмотр | Просмотр |
| Модули | Полный | Управление | — | — |
| Финансы | Полный | Просмотр | — | — |
---
## Использование в Twig-шаблонах
Хелпер `access` автоматически доступен в шаблонах через TwigGlobalsExtension.
### Проверка ролей
```twig
{# Проверка роли пользователя #}
{% if access.isRole('owner') %}
Вы владелец организации
{% endif %}
{% if access.isRole(['owner', 'admin']) %}
Вы администратор или владелец
{% endif %}
{# Удобные проверки #}
{% if access.isOwner() %}
Кнопка "Удалить организацию"
{% endif %}
{% if access.isAdmin() %}
Кнопка "Управление пользователями"
{% endif %}
```
### Проверка действий
```twig
{# Кнопка создания (видима только если есть право create) #}
{% if access.canCreate('clients') %}
Добавить клиента
{% endif %}
{# Кнопка редактирования #}
{% if access.canEdit('clients') %}
Редактировать
{% endif %}
{# Кнопка удаления #}
{% if access.canDelete('clients') %}
Удалить
{% endif %}
```
### Проверка специальных прав
```twig
{# Управление пользователями #}
{% if access.canManageUsers() %}
Управление пользователями
{% endif %}
{# Управление модулями #}
{% if access.canManageModules() %}
Управление подписками
{% endif %}
{# Удаление организации (только владелец) #}
{% if access.canDeleteOrganization() %}
{% endif %}
```
---
## Примеры использования в контроллерах
### Базовый шаблон проверки
```php
public function index()
{
// Проверка права на просмотр
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
// ... логика метода
}
```
### Проверка нескольких условий
```php
public function delete($id)
{
// Право на удаление
if (!$this->access->canDelete('clients')) {
return $this->forbiddenResponse('Нет прав для удаления');
}
// Дополнительная проверка: только владелец может удалять
if (!$this->access->isOwner()) {
return $this->forbiddenResponse('Только владелец может удалять');
}
// ... логика удаления
}
```
### Условное выполнение в зависимости от роли
```php
public function update($id, $data)
{
// Менеджер может только редактировать свои записи
// Админ и владелец — любые записи
$canEdit = $this->access->isRole('manager')
? $this->isOwnerOfRecord($id) // своя запись?
: true; // любую запись
if (!$canEdit) {
return $this->forbiddenResponse('Можно редактировать только свои записи');
}
// ... обновление
}
```
---
## Важные примечания
1. **Методы возвращают boolean** — используйте в условиях `if`
2. **Проверка всегда идёт для текущей организации** из сессии (`active_org_id`)
3. **Для личного пространства** (`type = 'personal'`) метод `canManageUsers()` возвращает `false` — в личном пространстве нет других пользователей
4. **Системные роли** (`system_role`) проверяются отдельно от ролей организации:
- `isRole()` и `isOwner()`, `isAdmin()` — для организации
- `isSuperadmin()`, `isSystemAdmin()` — для всей системы
5. **Кэширование** — `AccessService` кэширует membership в рамках одного запроса, но не между запросами. При переключении организации кэш сбрасывается автоматически.
---
## Получение текстового названия роли
```php
// В контроллере
$roleLabel = $this->access->getRoleLabel('admin'); // "Администратор"
// В шаблоне
{{ access.getRoleLabel(currentMembership.role) }}
```
---
## Список всех ролей
```php
// Получение всех ролей с описаниями
$roles = \App\Services\AccessService::getAllRoles();
// [
// 'owner' => ['label' => 'Владелец', 'description' => 'Полный доступ', 'level' => 100],
// 'admin' => ['label' => 'Администратор', 'description' => 'Управление пользователями', 'level' => 75],
// ...
// ]
```
// docs/USERGUIDE.md
# Руководство пользователя системы
Данное руководство поможет вам освоить все возможности системы — от первого входа до эффективной работы с клиентами и командой. Документ разделён на две части: для индивидуальных пользователей и для владельцев организаций.
---
## Часть 1. Для индивидуального пользователя
### 1.1. Регистрация в системе
Процесс регистрации начинается с главной страницы системы. Найдите кнопку «Регистрация» или «Создать аккаунт» и нажмите на неё. Система предложит заполнить стандартную форму, которая включает несколько обязательных полей. В поле «Имя» укажите ваше реальное имя — оно будет использоваться в письмах и уведомлениях. Поле «Email» должно содержать действующий почтовый адрес, поскольку на него придёт письмо для подтверждения регистрации. Придумайте надёжный пароль, состоящий минимум из восьми символов и содержащий буквы разного регистра и цифры.
После заполнения формы нажмите кнопку «Зарегистрироваться». Система проверит корректность введённых данных и создаст ваш аккаунт в статусе «неподтверждённый». Вы будете автоматически перенаправлены на страницу с уведомлением о необходимости подтвердить email. Обратите внимание, что без подтверждения электронной почты вы не сможете войти в систему и использовать её функции.
### 1.2. Подтверждение электронной почты
После регистрации проверьте почтовый ящик, который вы указали при создании аккаунта. В течение нескольких минут вы получите письмо от системы с заголовком, содержащим слова «подтверждение» или «верификация». Если письмо не пришло, проверьте папку «Спам» — иногда почтовые сервисы ошибочно помещают системные письма в эту папку. Также убедитесь, что вы указали правильный почтовый адрес без опечаток.
Откройте письмо и найдите кнопку или ссылку для подтверждения. Нажмите на неё — откроется страница системы с уведомлением об успешном подтверждении. Теперь ваш аккаунт активирован, и вы можете войти в систему, используя email и пароль, указанные при регистрации. Запомните или сохраните учётные данные в надёжном месте — восстановление доступа возможно, но требует времени.
### 1.3. Первый вход и выбор организации
После успешного входа система отобразит приветственную страницу и предложит выбрать организацию. Если вы регистрируетесь впервые, у вас есть два пути: создать новую организацию или присоединиться к существующей, если вы получили приглашение. Для создания новой организации нажмите соответствующую кнопку и заполните форму с названием и типом организации.
Выбор типа организации влияет на доступные функции и тарификацию. Тип «Личное» подходит для индивидуальной работы и означает, что организация создаётся для ваших личных целей. Тип «Бизнес» открывает возможности для приглашения команды, совместной работы и расширенного функционала. После создания или выбора организации вы попадёте на главную страницу личного кабинета или дашборд системы.
### 1.4. Знакомство с интерфейсом
Главный интерфейс системы состоит из нескольких ключевых элементов, которые помогут вам быстро ориентироваться. В левой части экрана расположена боковая панель (сайдбар) с основным меню навигации. Она содержит ссылки на главную страницу, разделы с клиентами и модулями системы. Сайдбар можно свернуть, нажав на иконку «гамбургер» в верхней части панели — это освободит место для основного контента.
В верхней части экрана находится навигационная панель с информацией о текущей организации. Выпадающий список позволяет быстро переключаться между организациями, если вы участник нескольких. Справа в верхней панели расположен профиль пользователя — здесь можно изменить личные настройки, просмотреть данные аккаунта или выйти из системы. Основное рабочее пространство расположено по центру экрана и меняется в зависимости от выбранного раздела.
### 1.5. Работа с модулем CRM
Модуль CRM (Customer Relationship Management) предназначен для управления клиентами и сделками. Этот инструмент позволяет систематизировать информацию о клиентах, отслеживать историю взаимодействия и контролировать процесс продаж. Доступ к модулю осуществляется через боковое меню — найдите пункт «CRM» и нажмите на него. Если модуль неактивен, обратитесь к владельцу организации для его включения.
CRM-модуль состоит из нескольких представлений, каждое из которых решает определённые задачи. Список сделок отображает все активные сделки в виде таблицы с возможностью поиска, фильтрации и сортировки. Канбан-доска визуализирует сделки по этапам продаж и позволяет перетаскивать карточки между колонками. Календарь показывает сделки с привязкой к датам, что удобно для планирования встреч и звонков. Страница управления этапами позволяет настроить воронку продаж под специфику вашего бизнеса.
### 1.6. Создание и редактирование сделки
Для создания новой сделки нажмите кнопку «Создать сделку» или «Новая сделка» в соответствующем разделе. Откроется форма с несколькими полями, которые необходимо заполнить. Поле «Название» содержит краткое описание сделки — используйте понятные формулировки, чтобы быстро находить нужные сделки в списке. Поле «Сумма» указывает планируемую или согласованную стоимость сделки и используется для расчёта статистики.
Поле «Этап» определяет положение сделки в воронке продаж и влияет на расчёт вероятности успеха. Поле «Ожидаемая дата закрытия» помогает планировать follow-up действия и контролировать сроки. Привязка к контакту и компании позволяет связать сделку с конкретным человеком и организацией клиента, что создаёт полную картину взаимодействия. После заполнения всех данных нажмите «Сохранить» — сделка появится в общем списке и будет доступна для дальнейшей работы.
### 1.7. Управление контактами
Раздел «Клиенты» содержит информацию о компаниях и организациях, с которыми вы работаете. Каждая карточка клиента может включать название, контактные данные, адрес и дополнительную информацию. Для создания нового клиента нажмите кнопку «Добавить клиента» и заполните предложенную форму. Система автоматически свяжет нового клиента с вашей текущей организацией.
К каждому клиенту можно привязать контакты — отдельных людей с их ролями, телефонами и email-адресами. Контакты создаются в карточке клиента и позволяют вести учёт всех лиц, принимающих решения в компании-клиенте. Это особенно полезно при работе с крупными организациями, где процесс согласования затрагивает несколько человек. Используйте поиск по клиентам и контактам для быстрого доступа к нужной информации.
---
## Часть 2. Для владельца организации
### 2.1. Создание организации и начало работы
После регистрации и подтверждения email вы можете создать организацию, которая станет центром вашей командной работы. На странице выбора организации нажмите «Создать новую организацию» и заполните форму. Название организации должно отражать реальное наименование вашего бизнеса — это имя будет видно всем участникам и использоваться в документах и отчётах.
Выбор типа организации определяет доступный функционал и модель использования. Для индивидуальной работы или небольшого бизнеса с минимальным количеством пользователей подойдёт тип «Личное». Для командной работы с несколькими сотрудниками выберите тип «Бизнес» — этот режим открывает возможности приглашения участников и совместной работы. После создания организации вы автоматически становитесь её владельцем и получаете полный доступ ко всем функциям.
### 2.2. Управление участниками организации
Расширение команды начинается с приглашения новых участников. Перейдите в раздел управления организацией через меню профиля или прямую ссылку. Найдите раздел «Участники» или «Команда» и нажмите кнопку «Пригласить участника». Система предложит ввести email-адрес человека, которого вы хотите пригласить, и выбрать его роль в организации.
Роли определяют уровень доступа участников к функциям системы. Роль «Владелец» даёт полный доступ ко всем функциям, включая управление участниками и настройки организации. Роль «Администратор» позволяет управлять клиентами, сделками и участниками, но не даёт доступа к финансовым настройкам и удалению организации. Роль «Менеджер» ограничивает доступ к управлению участниками, но позволяет полноценно работать с CRM-функциями. Роль «Пользователь» предоставляет базовый доступ к просмотру и редактированию данных в рамках разрешённых разделов.
### 2.3. Процесс приглашения и подтверждения
После отправки приглашения система создаёт уникальную ссылку и отправляет её на указанный email. Приглашённый пользователь получит письмо с инструкциями и кнопкой для принятия приглашения. Если пользователь уже зарегистрирован в системе, он сможет принять приглашение одним кликом. Если нет — система предложит ему зарегистрироваться, после чего он автоматически присоединится к вашей организации.
Статус приглашений можно отслеживать в разделе управления участниками. Система отображает список всех отправленных приглашений с их статусом: «Ожидает», «Принято» или «Истёк». Если приглашение не было принято в течение определённого срока, вы можете отправить его повторно или отменить. После принятия приглашения участник появится в списке команды и получит доступ к функциям согласно назначенной роли.
### 2.4. Настройка воронки продаж
Эффективная работа с CRM начинается с правильной настройки воронки продаж. Перейдите в раздел CRM, найдите пункт «Этапы сделок» и откройте страницу управления. По умолчанию система создаёт базовую воронку с этапами «Новый лид», «Квалификация», «Предложение», «Переговоры», «Успех» и «Провал». Вы можете изменить названия этапов, их цвета и порядок.
Для создания нового этапа используйте форму в верхней части страницы. Укажите название этапа, выберите цвет для визуального отличия, задайте тип и вероятность успеха. Тип «В процессе» означает, что этап является промежуточным в воронке. Тип «Успех» обозначает положительное завершение сделки, а тип «Провал» — отрицательное. Вероятность успеха используется для прогнозирования продаж и расчёта ожидаемой выручки.
### 2.5. Drag-and-drop сортировка этапов
Для изменения порядка этапов в воронке используйте функцию drag-and-drop. Наведите курсор на строку этапа — появится иконка «шеститочие» или «grip» слева от названия. Захватите строку мышкой и перетащите её на нужную позицию. Система автоматически обновит порядок и сохранит изменения. Порядок этапов определяет последовательность движения сделки от первого контакта до завершения.
После перетаскивания система отправляет запрос на сервер для сохранения нового порядка. При успешном сохранении вы увидите уведомление «Порядок сохранён». Если возникнет ошибка, система сообщит о проблеме и предложит повторить действие. Новый порядок этапов будет применён ко всем сделкам организации и виден всем участникам команды.
### 2.6. Распределение прав и доступов
Грамотное распределение прав доступа обеспечивает безопасность данных и эффективность работы команды. Владелец организации может изменять роли участников в любое время через раздел управления командой. При изменении роли участника его доступ к функциям системы обновится мгновенно. Рекомендуется регулярно проверять состав команды и актуальность ролей.
Для чувствительных операций, таких как удаление клиентов или сделок, можно дополнительно ограничить доступ. Создайте роль с минимальными правами для стажёров или внешних подрядчиков. Ограничьте доступ к финансовой информации и отчётам только для руководящего состава. Помните, что владелец организации всегда сохраняет полный доступ и может восстановить любые настройки.
### 2.7. Работа с несколькими организациями
Если вы участвуете в нескольких организациях, система позволяет легко переключаться между ними. Верхнее меню навигации содержит выпадающий список с названием текущей организации. Откройте список и выберите организацию, с которой хотите работать. Система запомнит ваш выбор и отобразит данные выбранной организации.
Переключение организации не изменяет ваши учётные данные — вы остаётесь авторизованным пользователем. Однако доступ к данным других организаций ограничен их настройками приватности. Если вы являетесь владельцем нескольких организаций, вы можете управлять каждой из них независимо. Для удобства работы рекомендуется использовать разные браузерные профили или сессии для разных организаций.
### 2.8. Мониторинг активности команды
Владелец организации имеет доступ к обзору активности команды через раздел статистики или дашборд организации. Здесь отображается количество созданных сделок, добавленных клиентов и обработанных контактов за выбранный период. Сравнивайте показатели разных участников для оценки эффективности работы и выявления лучших практик.
Регулярный мониторинг помогает своевременно выявлять проблемы в работе команды и принимать управленческие решения. Если заметите снижение активности у отдельного участника, свяжитесь с ним для выяснения причин. Высокие показатели отдельных сотрудников могут служить примером для остальных. Используйте эти данные для оптимизации процессов и повышения общей эффективности организации.
---
## Часть 3. Часто задаваемые вопросы
### 3.1. Вопросы по регистрации и входу
**Что делать, если я забыл пароль?** На странице входа нажмите ссылку «Забыли пароль?» и введите ваш email. Система отправит письмо с инструкциями по сбросу пароля. Перейдите по ссылке в письме и создайте новый пароль. Если письмо не приходит, проверьте папку «Спам» или свяжитесь с поддержкой.
**Можно ли изменить email после регистрации?** Да, изменить email можно в настройках профиля. Перейдите в раздел «Профиль» и найдите поле для изменения контактных данных. После сохранения система отправит письмо для подтверждения нового email. До подтверждения нового адреса используется старый email для входа.
**Почему я не могу войти после регистрации?** Убедитесь, что вы подтвердили email, перейдя по ссылке в письме. Проверьте правильность ввода email и пароля — обратите внимание на раскладку клавиатуры и регистр символов. Если проблема сохраняется, очистите кэш браузера или попробуйте другой браузер.
### 3.2. Вопросы по организации и участникам
**Как удалить участника организации?** Перейдите в раздел управления организацией и найдите список участников. Напротив нужного участника нажмите кнопку действий и выберите «Удалить» или «Исключить». Подтвердите действие — участник потеряет доступ к данным организации. Удалённый участник сможет создать или присоединиться к другой организации.
**Что произойдёт с данными при удалении организации?** Удаление организации необратимо и удаляет все данные: клиентов, сделки, контакты и историю. Перед удалением система предупредит вас и потребует подтверждения. Убедитесь, что данные сохранены или экспортированы, если они нужны в будущем.
**Можно ли передать права владельца другому участнику?** Да, владелец может передать права другому участнику в разделе настроек организации. После передачи прав вы станете администратором, а новый владелец получит полный контроль над организацией. Это действие необратимо — вы не сможете вернуть права владельца самостоятельно.
### 3.3. Вопросы по CRM и работе с данными
**Как экспортировать данные клиентов?** Перейдите в раздел с клиентами и найдите функцию экспорта в меню действий или настройках таблицы. Выберите формат экспорта (CSV, Excel) и поля для выгрузки. Система сформирует файл с выбранными данными и предложит его сохранить.
**Можно ли восстановить удалённую сделку?** Удалённые сделки хранятся в системе определённое время, после чего удаляются безвозвратно. Для восстановления обратитесь к владельцу организации или в службу поддержки, если удаление было недавним. Укажите примерную дату удаления и название сделки для ускорения поиска.
**Как настроить уведомления о новых сделках?** Перейдите в настройки уведомлений в профиле или настройках организации. Включите уведомления для нужных типов событий: новые сделки, изменение этапов, приближение даты закрытия. Выберите способ доставки: email, push-уведомления в браузере или внутри системы.
---
## Заключение
Данное руководство охватывает основные сценарии работы с системой как для индивидуальных пользователей, так и для владельцев организаций. Освоив базовые функции, вы сможете эффективно управлять клиентами, отслеживать сделки и координировать работу команды. Система постоянно развивается, поэтому рекомендуем периодически возвращаться к документации для изучения новых возможностей.
Если у вас возникли вопросы, не описанные в руководстве, обратитесь к разделу справки внутри системы или свяжитесь со службой поддержки. Команда разработчиков регулярно улучшает систему на основе отзывов пользователей, поэтому ваши предложения и пожелания могут быть учтены в будущих обновлениях.
// docs/DATATABLE.md
# Компонент динамических таблиц DataTable
## Общее описание
Компонент DataTable представляет собой универсальную систему для отображения интерактивных таблиц с поддержкой AJAX-загрузки данных, сортировки по столбцам, поиска и пагинации. Система построена на трёх уровнях: серверная часть (контроллер с методом `prepareTableData`), клиентская часть (JavaScript-модуль DataTable) и уровень представления (компоненты Twig).
Архитектура компонента обеспечивает бесшовную работу как при серверном рендеринге (первичная загрузка страницы), так и при AJAX-обновлениях (фильтрация, сортировка, пагинация). При серверном рендеринге таблица отображается сразу с данными, при этом JavaScript автоматически определяет наличие данных и пропускает избыточный AJAX-запрос. При любых действиях пользователя (сортировка, фильтрация, переход по страницам) данные подгружаются через AJAX, а клиентский модуль обновляет только tbody и tfoot, сохраняя заголовок таблицы неизменным.
---
## Структура компонентов
### Файловая структура
Компонент таблицы состоит из нескольких файлов, организованных по функциональному признаку. JavaScript-модуль расположен в `public/assets/js/modules/DataTable.js` и отвечает за все интерактивные взаимодействия на клиенте. Стили находятся в `public/assets/css/modules/data-table.css` и обеспечивают визуальное оформление элементов управления таблицей. Шаблоны Twig размещены в директории `app/Views/components/table/` и включают основной компонент таблицы, заголовок, пагинацию и макросы для рендеринга действий.
Основные файлы компонента:
- `table.twig` — универсальный компонент таблицы, включающий заголовок, тело и футер с пагинацией
- `table_header.twig` — переиспользуемый заголовок с поддержкой сортировки и поиска
- `pagination.twig` — компонент пагинации с навигацией по страницам
- `ajax_table.twig` — упрощённый tbody для AJAX-ответов без заголовка
- `macros.twig` — Twig-макросы для рендеринга кнопок действий
### Интеграция с BaseController
Класс BaseController предоставляет готовую инфраструктуру для работы с таблицами через методы `getTableConfig()`, `prepareTableData()`, `renderTable()` и `table()`. Метод `getTableConfig()` возвращает конфигурацию таблицы, определяющую модель данных, колонки, правила поиска и сортировки, а также действия для каждой строки. Метод `prepareTableData()` выполняет всю логику обработки параметров запроса (пагинация, сортировка, фильтрация), формирует данные и возвращает их в структурированном виде для передачи в шаблон. Метод `renderTable()` принимает конфигурацию и возвращает HTML-код таблицы. Метод `table()` является HTTP-обработчиком для AJAX-запросов, который возвращает только tbody и tfoot без заголовка.
---
## Подключение в контроллере
### Конфигурация таблицы
Каждый контроллер модуля должен определить конфигурацию таблицы через метод `getTableConfig()`. Конфигурация представляет собой ассоциативный массив с обязательными и опциональными параметрами. Обязательными параметрами являются `model` (экземпляр модели для выборки данных) и `columns` (описание колонок таблицы). Опциональные параметры позволяют настроить поведение поиска, сортировки, действий и отображения пустого состояния.
```php
class Clients extends BaseController
{
protected ClientModel $clientModel;
public function __construct()
{
$this->clientModel = new ClientModel();
}
protected function getTableConfig(): array
{
return [
'id' => 'clients-table',
'url' => '/clients/table',
'model' => $this->clientModel,
'columns' => [
'name' => ['label' => 'Имя / Название', 'width' => '40%'],
'email' => ['label' => 'Email', 'width' => '25%'],
'phone' => ['label' => 'Телефон', 'width' => '20%'],
'created_at' => ['label' => 'Создан', 'width' => '15%'],
],
'searchable' => ['name', 'email', 'phone'],
'sortable' => ['name', 'email', 'phone', 'created_at'],
'defaultSort' => 'name',
'order' => 'asc',
'actions' => ['label' => 'Действия', 'width' => '15%'],
'actionsConfig' => [
[
'label' => 'Редактировать',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary',
'type' => 'edit',
],
[
'label' => 'Удалить',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'type' => 'delete',
'confirm' => 'Вы уверены, что хотите удалить этого клиента?',
],
],
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => base_url('/clients/new'),
'emptyActionLabel' => 'Добавить клиента',
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
];
}
}
```
### Параметры конфигурации
Параметр `id` задаёт уникальный идентификатор контейнера таблицы и используется для инициализации JavaScript-модуля. Параметр `url` определяет endpoint для AJAX-загрузки данных. Параметр `model` указывает экземпляр модели CodeIgniter, которая используется для запроса данных. Модель автоматически фильтруется по организации через трейт `TenantScopedModel` при его наличии.
Параметр `columns` описывает структуру колонок таблицы. Ключ массива соответствует имени поля в данных, а значение — ассоциативному массиву с параметрами отображения. Параметр `label` задаёт заголовок колонки, параметр `width` — ширину колонки в процентах или пикселях. Опционально можно указать `placeholder` для поля поиска, `searchTitle` для tooltip-подсказки и `align` для CSS-класса выравнивания содержимого.
Параметр `searchable` определяет массив имён колонок, по которым разрешён поиск. Эти колонки получат иконку поиска в заголовке. Параметр `sortable` определяет массив имён колонок, по которым разрешена сортировка. При клике по заголовку сортируемой колонки таблица пересортируется по этому полю. Параметры `defaultSort` и `order` задают поле и направление сортировки по умолчанию.
### Методы контроллера для таблицы
Основной метод для отображения страницы с таблицей выглядит следующим образом:
```php
public function index()
{
// Проверка прав доступа
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
$config = $this->getTableConfig();
return $this->renderTwig('@Clients/index', [
'title' => 'Клиенты',
'tableHtml' => $this->renderTable($config),
'can_create' => $this->access->canCreate('clients'),
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
]);
}
```
Метод для AJAX-загрузки данных таблицы использует встроенную логику BaseController:
```php
public function table(?array $config = null, ?string $pageUrl = null)
{
// Проверка прав доступа
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра клиентов');
}
return parent::table($config, '/clients');
}
```
Метод `table()` автоматически определяет тип запроса (обычный или AJAX) и возвращает либо полную таблицу, либо только tbody и tfoot соответственно. Для определения типа запроса используется заголовок `X-Requested-With` или параметр `format=partial`.
---
## Подключение в шаблоне
### Базовое подключение
Для подключения таблицы в шаблоне Twig используется компонент `table.twig`. Компонент принимает данные из метода `renderTable()` контроллера, который возвращает полностью сформированный HTML:
```twig
{# app/Modules/Clients/Views/index.twig #}
{% extends 'layouts/base.twig' %}
{% block title %}Клиенты{% endblock %}
{% block content %}
{% endblock %}
{% block scripts %}
{{ parent() }}
{% endblock %}
```
### Прямое использование компонента
При необходимости таблицу можно подключить напрямую через `include`, передав все параметры вручную:
```twig
{% from '@components/table/macros.twig' import render_actions %}
```
### Поддержка render_cell и render_actions
В шаблонах Twig доступны глобальные функции для рендеринга ячеек и действий. Функция `render_cell()` автоматически обрабатывает значение ячейки в зависимости от типа данных:
```twig
{# Ячейка с автоматическим форматированием #}
{{ render_cell(item, 'name')|raw }}
{{ render_cell(item, 'email')|raw }}
{{ render_cell(item, 'price')|raw }}
{{ render_cell(item, 'created_at')|raw }}
{# Ячейка с кастомным классом #}
{{ render_cell(item, 'status', { class: 'badge bg-success' })|raw }}
```
Функция `render_actions()` рендерит кнопки действий для строки таблицы:
```twig
{% set actions = [
{ label: 'Ред.', url: '/edit/' ~ item.id, icon: 'fa-solid fa-pen', class: 'btn-outline-primary' },
{ label: 'Удалить', url: '/delete/' ~ item.id, icon: 'fa-solid fa-trash', class: 'btn-outline-danger' },
] %}
{{ render_actions(actions)|raw }}
```
---
## Конфигурация колонок
### Параметры колонки
Каждая колонка описывается массивом с возможными параметрами. Обязательным параметром является только `label`, остальные опциональны:
```php
'columns' => [
'name' => [
'label' => 'Название',
'width' => '40%',
'placeholder' => 'Поиск по названию',
'searchTitle' => 'Нажмите для поиска',
'align' => 'text-start',
],
'price' => [
'label' => 'Цена',
'width' => '15%',
'align' => 'text-end',
],
'status' => [
'label' => 'Статус',
'width' => '15%',
],
]
```
Параметр `width` задаёт ширину колонки и может быть указан в процентах или пикселях. Рекомендуется использовать проценты для адаптивности или комбинировать фиксированные и относительные значения. Сумма ширин всех колонок обычно должна составлять 100% с учётом колонки действий.
### Поля searchable и sortable
Массив `searchable` определяет поля, по которым разрешён поиск. При указании поля в этом массиве в заголовке колонки появится иконка поиска, при клике на которую отобразится поле ввода:
```php
'searchable' => ['name', 'email', 'phone', 'company'],
```
Массив `sortable` определяет поля, по которым разрешена сортировка. При клике по заголовку сортируемой колонки таблица пересортируется по этому полю, при повторном клике направление сортировки меняется на противоположное:
```php
'sortable' => ['name', 'email', 'phone', 'created_at', 'price'],
```
Важно: имена полей в `searchable` и `sortable` должны соответствовать ключам массива `columns` и именам полей в базе данных.
---
## Конфигурация действий
### Структура actionsConfig
Параметр `actionsConfig` определяет кнопки действий для каждой строки таблицы. Каждое действие описывается массивом с параметрами:
```php
'actionsConfig' => [
[
'label' => '',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary',
'title' => 'Редактировать',
'type' => 'edit',
'confirm' => null,
],
[
'label' => '',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger',
'title' => 'Удалить',
'type' => 'delete',
'confirm' => 'Вы уверены?',
],
],
```
Параметр `url` поддерживает подстановку `{id}` для автоматической замены на идентификатор записи. Параметр `type` используется для фильтрации действий по правам доступа: действия с `type === 'edit'` показываются только при `can_edit === true`, действия с `type === 'delete'` — только при `can_delete === true`. Параметр `confirm` добавляет подтверждение действия через стандартный `confirm()` в JavaScript.
### Кастомные действия
Помимо типовых действий редактирования и удаления, можно определять кастомные действия:
```php
'actionsConfig' => [
[
'label' => 'Просмотр',
'url' => '/clients/view/{id}',
'icon' => 'fa-solid fa-eye',
'class' => 'btn-outline-secondary',
'title' => 'Просмотр клиента',
],
[
'label' => 'Создать сделку',
'url' => '/deals/create?client_id={id}',
'icon' => 'fa-solid fa-file-contract',
'class' => 'btn-outline-success',
'title' => 'Создать сделку',
],
[
'label' => 'Записать',
'url' => '/bookings/new?client_id={id}',
'icon' => 'fa-solid fa-calendar',
'class' => 'btn-outline-primary',
'title' => 'Запись на приём',
],
],
```
---
## Клиентская инициализация
### Базовая инициализация
JavaScript-модуль DataTable инициализируется для каждой таблицы на странице. При инициализации передаются параметры конфигурации:
```javascript
document.addEventListener('DOMContentLoaded', function() {
new DataTable('clients-table', {
url: '/clients/table',
perPage: 10,
debounceTime: 300,
preserveSearchOnSort: true
});
});
```
Параметр `url` задаёт endpoint для AJAX-загрузки данных. Параметр `perPage` определяет количество записей на странице по умолчанию. Параметр `debounceTime` задаёт задержку в миллисекундах перед выполнением поиска (защита от частых запросов при вводе). Параметр `preserveSearchOnSort` определяет, сохранять ли видимость полей поиска при сортировке.
### Методы DataTable
После инициализации экземпляр DataTable предоставляет методы для программного управления таблицей:
```javascript
const table = new DataTable('my-table', options);
// Установка фильтра
table.setFilter('name', 'Поисковый запрос');
// Установка количества записей на странице
table.setPerPage(25);
// Переход на конкретную страницу
table.goToPage(3);
// Перезагрузка данных
table.loadData();
```
---
## Пустое состояние и действия
### Конфигурация пустого состояния
При отсутствии данных в таблице отображается пустое состояние с возможностью действия. Параметры конфигурации:
```php
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => base_url('/clients/new'),
'emptyActionLabel' => 'Добавить клиента',
'emptyActionIcon' => 'fa-solid fa-plus',
```
Параметр `emptyMessage` задаёт текст сообщения. Параметр `emptyIcon` указывает FontAwesome-иконку для отображения над сообщением. Параметры `emptyActionUrl`, `emptyActionLabel` и `emptyActionIcon` определяют кнопку действия при пустом состоянии.
### Условное скрытие действия
Кнопка действия при пустом состоянии отображается только если у пользователя есть право на создание:
```php
'emptyActionUrl' => $this->access->canCreate('clients')
? base_url('/clients/new')
: null,
'emptyActionLabel' => $this->access->canCreate('clients')
? 'Добавить клиента'
: null,
```
---
## Обработка special fieldMap
### Проблема несоответствия имён полей
При работе с моделями часто возникает ситуация, когда имя поля в базе данных отличается от имени свойства в Twig-шаблоне или имени параметра для фильтрации. Например, поле `client_name` в базе данных должно отображаться как «Клиент» и фильтроваться по параметру `client`. Для решения этой проблемы используется параметр `fieldMap`:
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
'columns' => [
'client_name' => ['label' => 'Клиент', 'width' => '30%'],
'title' => ['label' => 'Сделка', 'width' => '25%'],
'amount' => ['label' => 'Сумма', 'width' => '15%'],
'stage' => ['label' => 'Этап', 'width' => '15%'],
'created_at' => ['label' => 'Создан', 'width' => '15%'],
],
'searchable' => ['client_name', 'title', 'stage'],
'sortable' => ['client_name', 'title', 'amount', 'stage', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
// fieldMap для маппинга параметров фильтрации на реальные поля
'fieldMap' => [
'client' => 'client_name', // filters[client] -> client_name
'stage' => 'stage',
],
// ... остальные параметры
];
}
```
При использовании `fieldMap` параметры фильтрации из URL (`filters[client]`) автоматически маппятся на реальное поле базы данных (`client_name`). Это позволяет использовать понятные имена параметров в URL при сохранении корректных имён полей в запросе к базе данных.
---
## Кастомные scope для запросов
### Использование callable scope
Когда стандартной фильтрации недостаточно (например, нужны JOIN-ы с другими таблицами или сложные условия), можно использовать параметр `scope`. Это callable-функция, которая получает builder и полностью контролирует формирование запроса:
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
// ... columns, searchable, sortable и т.д.
// Кастомный scope для сложных запросов
'scope' => function($builder) {
$builder->resetQuery();
$builder->select('d.*, c.name as client_name, c.email as client_email')
->from('deals d')
->join('clients c', 'c.id = d.client_id', 'left')
->where('d.organization_id', session()->get('active_org_id'));
// Дополнительная фильтрация по статусу
$status = $this->request->getGet('filters[status]');
if ($status && $status !== 'all') {
$builder->where('d.status', $status);
}
// Фильтрация по диапазону дат
$dateFrom = $this->request->getGet('filters[date_from]');
$dateTo = $this->request->getGet('filters[date_to]');
if ($dateFrom) {
$builder->where('d.created_at >=', $dateFrom);
}
if ($dateTo) {
$builder->where('d.created_at <=', $dateTo . ' 23:59:59');
}
},
// fieldMap для JOIN-полей
'fieldMap' => [
'client' => 'c.name',
'client_email' => 'c.email',
],
];
}
```
При использовании `scope` параметр `model` игнорируется для построения запроса, и `scope` полностью контролирует SELECT, FROM, JOIN и WHERE. Параметры сортировки и фильтрации всё ещё применяются к builder после выполнения scope, поэтому в `fieldMap` нужно указывать полные имена полей с алиасами таблиц.
---
## Практические примеры
### Пример 1: Таблица клиентов
```php
class Clients extends BaseController
{
protected ClientModel $clientModel;
public function __construct()
{
$this->clientModel = new ClientModel();
}
public function index()
{
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав для просмотра');
}
return $this->renderTwig('@Clients/index', [
'title' => 'Клиенты',
'tableHtml' => $this->renderTable($this->getTableConfig()),
'can_create' => $this->access->canCreate('clients'),
]);
}
public function table()
{
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('Нет прав');
}
return parent::table($this->getTableConfig(), '/clients');
}
protected function getTableConfig(): array
{
return [
'id' => 'clients-table',
'url' => '/clients/table',
'model' => $this->clientModel,
'columns' => [
'name' => ['label' => 'Имя / Название', 'width' => '35%'],
'email' => ['label' => 'Email', 'width' => '25%'],
'phone' => ['label' => 'Телефон', 'width' => '20%'],
'source' => ['label' => 'Источник', 'width' => '10%'],
'created_at' => ['label' => 'Создан', 'width' => '10%'],
],
'searchable' => ['name', 'email', 'phone'],
'sortable' => ['name', 'email', 'phone', 'source', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '5%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'title' => 'Редактировать',
'type' => 'edit',
],
[
'label' => '',
'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger btn-sm',
'title' => 'Удалить',
'type' => 'delete',
'confirm' => 'Удалить клиента?',
],
],
'emptyMessage' => 'Клиентов пока нет',
'emptyIcon' => 'fa-solid fa-users',
'emptyActionUrl' => $this->access->canCreate('clients') ? '/clients/new' : null,
'emptyActionLabel' => $this->access->canCreate('clients') ? 'Добавить клиента' : null,
'emptyActionIcon' => 'fa-solid fa-plus',
'can_edit' => $this->access->canEdit('clients'),
'can_delete' => $this->access->canDelete('clients'),
];
}
}
```
### Пример 2: Таблица с кастомным рендерингом ячеек
```php
protected function getTableConfig(): array
{
return [
'id' => 'deals-table',
'url' => '/deals/table',
'model' => $this->dealModel,
'columns' => [
'client_name' => ['label' => 'Клиент', 'width' => '25%'],
'title' => ['label' => 'Сделка', 'width' => '25%'],
'amount' => ['label' => 'Сумма', 'width' => '15%'],
'stage' => ['label' => 'Этап', 'width' => '15%'],
'status' => ['label' => 'Статус', 'width' => '10%'],
'created_at' => ['label' => 'Создан', 'width' => '10%'],
],
'searchable' => ['client_name', 'title', 'stage'],
'sortable' => ['client_name', 'title', 'amount', 'stage', 'created_at'],
'defaultSort' => 'created_at',
'order' => 'desc',
'actions' => ['label' => '', 'width' => '5%'],
'actionsConfig' => [
[
'label' => '',
'url' => '/deals/edit/{id}',
'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary btn-sm',
'type' => 'edit',
],
],
'can_edit' => $this->access->canEdit('deals'),
];
}
```
В шаблоне Twig можно добавить кастомный рендеринг ячеек через Twig-фильтры:
```twig
{# В шаблоне ячейки с форматированием #}
```
---
## Проверка при создании модуля
### Чек-лист при добавлении новой таблицы
При создании нового модуля с таблицей необходимо выполнить следующие действия:
**В контроллере:**
- Определить метод `getTableConfig()` с обязательными параметрами (`id`, `url`, `model`, `columns`)
- Указать `searchable` и `sortable` массивы с корректными именами полей
- Настроить `actionsConfig` с кнопками действий и проверкой прав
- Добавить метод `table()` для AJAX-загрузки данных
- Вызвать `parent::table()` для использования встроенной логики
- Проверить права доступа перед вызовом родительского метода
**В шаблоне:**
- Подключить DataTable.js в блоке `scripts`
- Инициализировать DataTable с корректным `id` и `url`
- Передать `tableHtml` из контроллера в шаблон
- Убедиться, что CSS стили таблицы подключены
**Модель:**
- Использовать трейт `TenantScopedModel` для автоматической фильтрации по организации
- Убедиться, что модель имеет поле `organization_id`
- Проверить, что модель возвращает данные в ожидаемом формате
---
## Типичные ошибки и их устранение
### Таблица не загружается
Если данные не загружаются, проверьте следующее:
- URL в конфигурации и при инициализации DataTable должны совпадать
- Метод `table()` контроллера должен вызывать `parent::table()`
- Модель должна использовать трейт `TenantScopedModel` или обрабатывать фильтрацию вручную
- Проверьте консоль браузера на наличие ошибок JavaScript
- Убедитесь, что CSRF-токен передаётся корректно
### Сортировка не работает
Если сортировка не работает:
- Поле должно быть указано в массиве `sortable`
- Имя поля в `sortable` должно соответствовать ключу в `columns` и имени поля в базе данных
- Для JOIN-запросов используйте алиасы таблиц в `sortable` (`c.name` вместо `client_name`)
### Поиск не работает
Если поиск не работает:
- Поле должно быть указано в массиве `searchable`
- При использовании JOIN проверьте `fieldMap` для маппинга параметров
- Убедитесь, что в контроллере используется метод `like()` для фильтрации
### Действия не отображаются
Если кнопки действий не отображаются:
- Проверьте `can_edit` и `can_delete` в конфигурации
- Убедитесь, что `type` действия соответствует проверяемому праву (`'edit'` или `'delete'`)
- Проверьте параметр `actions` в конфигурации (должен быть `{label: 'Действия'}` или `true`)
---
## Сводка параметров конфигурации
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `id` | string | Да | Идентификатор контейнера таблицы |
| `url` | string | Да | URL для AJAX-загрузки |
| `model` | Model | Да | Экземпляр модели CodeIgniter |
| `columns` | array | Да | Конфигурация колонок |
| `searchable` | array | Нет | Поля для поиска |
| `sortable` | array | Нет | Поля для сортировки |
| `defaultSort` | string | Нет | Поле сортировки по умолчанию |
| `order` | string | Нет | Направление сортировки по умолчанию |
| `actions` | array\|bool | Нет | Конфигурация колонки действий |
| `actionsConfig` | array | Нет | Кнопки действий |
| `emptyMessage` | string | Нет | Сообщение при отсутствии данных |
| `emptyIcon` | string | Нет | Иконка при пустом состоянии |
| `emptyActionUrl` | string | Нет | URL действия при пустом состоянии |
| `emptyActionLabel` | string | Нет | Текст кнопки действия |
| `can_edit` | bool | Нет | Разрешено ли редактирование |
| `can_delete` | bool | Нет | Разрешено ли удаление |
| `fieldMap` | array | Нет | Маппинг параметров фильтрации |
| `scope` | callable | Нет | Кастомный запрос к базе данных |
// public/.htaccess
# Disable directory browsing
Options -Indexes
# ----------------------------------------------------------------------
# Rewrite engine
# ----------------------------------------------------------------------
# Turning on the rewrite engine is necessary for the following rules and features.
# FollowSymLinks must be enabled for this to work.
Options +FollowSymlinks
RewriteEngine On
# If you installed CodeIgniter in a subfolder, you will need to
# change the following line to match the subfolder you need.
# http://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewritebase
# RewriteBase /
# Redirect Trailing Slashes...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Rewrite "www.example.com -> example.com"
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L]
# Checks to see if the user is attempting to access a valid file,
# such as an image or css document, if this isn't true it sends the
# request to the front controller, index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
# Ensure Authorization header is passed along
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# If we don't have mod_rewrite installed, all 404's
# can be sent to index.php, and everything works as normal.
ErrorDocument 404 index.php
# Disable server signature start
ServerSignature Off
# Disable server signature end
// public/index.php
systemDirectory . '/Boot.php';
exit(Boot::bootWeb($paths));
// .env
#--------------------------------------------------------------------
# Example Environment Configuration file
#
# This file can be used as a starting point for your own
# custom .env files, and contains most of the possible settings
# available in a default install.
#
# By default, all of the settings are commented out. If you want
# to override the setting, you must un-comment it by removing the '#'
# at the beginning of the line.
#--------------------------------------------------------------------
#--------------------------------------------------------------------
# ENVIRONMENT
#--------------------------------------------------------------------
CI_ENVIRONMENT = development
#--------------------------------------------------------------------
# APP
#--------------------------------------------------------------------
app.baseURL = 'https://bp.taskms.ru'
# If you have trouble with `.`, you could also use `_`.
# app_baseURL = ''
# app.forceGlobalSecureRequests = false
# app.CSPEnabled = false
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------
database.default.hostname = localhost
database.default.database = bp_mirv_db
database.default.username = bp_mirv
database.default.password = bp_mirv_Moloko22
database.default.DBDriver = MySQLi
database.default.DBPrefix =
database.default.port = 3306
#--------------------------------------------------------------------
# ENCRYPTION
#--------------------------------------------------------------------
encryption.key = sadfonusdofuhsefiouhw9er87yhdf
#--------------------------------------------------------------------
# SMTP
#--------------------------------------------------------------------
email.protocol = 'smtp'
email.SMTPHost = 'smtp.yandex.ru'
email.SMTPCrypto = 'ssl'
email.SMTPPort = 465
email.SMTPUser = 'mirvtop@yandex.ru'
email.SMTPPass = 'azpudcybqsqbbqns'
email.fromEmail = 'mirvtop@yandex.ru'
email.fromName = 'Бизнес.Точка'
email.mailType = 'html'
#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------
# Для использования Redis в качестве хранилища сессий:
session.driver = 'CodeIgniter\Session\Handlers\RedisHandler'
session.savePath = 'tcp://127.0.0.1:6379'
# Вариант с паролем:
# session.savePath = 'tcp://127.0.0.1:6379?password=your_password'
# Вариант с выбором базы данных:
# session.savePath = 'tcp://127.0.0.1:6379?database=1'
#--------------------------------------------------------------------
# REDIS
#--------------------------------------------------------------------
redis.host = '127.0.0.1'
redis.port = 6379
redis.password = ''
redis.database = 0
redis.timeout = 2.0
redis.read_timeout = 60.0
#--------------------------------------------------------------------
# RATE LIMITING
#--------------------------------------------------------------------
# Префикс для всех ключей rate limiting в Redis
rate_limit.prefix = 'rl:'
# Авторизация - Логин
# Максимальное количество попыток в окне
rate_limit.auth.login.attempts = 5
# Окно в секундах (15 минут = 900 секунд)
rate_limit.auth.login.window = 900
# Время блокировки в секундах
rate_limit.auth.login.block = 900
# Авторизация - Регистрация
rate_limit.auth.register.attempts = 10
rate_limit.auth.register.window = 3600
rate_limit.auth.register.block = 3600
# Авторизация - Восстановление пароля
rate_limit.auth.reset.attempts = 5
rate_limit.auth.reset.window = 900
rate_limit.auth.reset.block = 900
# API - Лимиты на чтение (запросы в минуту)
rate_limit.api.read.attempts = 100
rate_limit.api.read.window = 60
# API - Лимиты на запись (запросы в минуту)
rate_limit.api.write.attempts = 30
rate_limit.api.write.window = 60
#--------------------------------------------------------------------
# LOGGER
#--------------------------------------------------------------------
# logger.threshold = 4