From 9b8d10bbfa2a919c74359f721a22b45869ea1a53 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Feb 2026 16:57:25 +0300 Subject: [PATCH] some fixes --- .../CRM/Controllers/DealsController.php | 4 +- app/Modules/CRM/Views/deals/kanban_card.twig | 14 +- .../Tasks/Controllers/TasksController.php | 2 +- .../Tasks/Views/components/task_card.twig | 58 + .../Tasks/Views/components/task_event.twig | 17 + app/Modules/Tasks/Views/tasks/calendar.twig | 130 +- app/Modules/Tasks/Views/tasks/index.twig | 23 + app/Modules/Tasks/Views/tasks/kanban.twig | 141 +- app/Views/components/kanban/kanban.twig | 4 +- bp.txt | 23541 ++++++++++++++++ 10 files changed, 23678 insertions(+), 256 deletions(-) create mode 100644 app/Modules/Tasks/Views/components/task_card.twig create mode 100644 app/Modules/Tasks/Views/components/task_event.twig create mode 100644 bp.txt diff --git a/app/Modules/CRM/Controllers/DealsController.php b/app/Modules/CRM/Controllers/DealsController.php index 813ad0d..184668e 100644 --- a/app/Modules/CRM/Controllers/DealsController.php +++ b/app/Modules/CRM/Controllers/DealsController.php @@ -391,8 +391,8 @@ class DealsController extends BaseController $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); - $dealId = $this->request->getPost('deal_id'); - $newStageId = $this->request->getPost('stage_id'); + $dealId = $this->request->getPost('id'); + $newStageId = $this->request->getPost('column_id'); $deal = $this->dealService->getDealWithJoins($dealId, $organizationId); if (!$deal) { diff --git a/app/Modules/CRM/Views/deals/kanban_card.twig b/app/Modules/CRM/Views/deals/kanban_card.twig index a60f983..319007d 100644 --- a/app/Modules/CRM/Views/deals/kanban_card.twig +++ b/app/Modules/CRM/Views/deals/kanban_card.twig @@ -9,10 +9,10 @@ style="cursor: grab;">
- + {{ item.title }} - + ₽{{ item.amount|number_format(0, ',', ' ') }}
@@ -35,11 +35,19 @@ {% endif %} {% if item.expected_close_date %} - + {{ item.expected_close_date|date('d.m') }} + {% if item.expected_close_date < date('today') %} + + {% endif %} {% endif %} + + {# Кнопка просмотра #} + + +
diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php index 3ed643d..c05601b 100644 --- a/app/Modules/Tasks/Controllers/TasksController.php +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -399,7 +399,7 @@ class TasksController extends BaseController $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); - $taskId = $this->request->getPost('task_id'); + $taskId = $this->request->getPost('id'); $newColumnId = $this->request->getPost('column_id'); $result = $this->taskService->changeColumn($taskId, $newColumnId, $userId); diff --git a/app/Modules/Tasks/Views/components/task_card.twig b/app/Modules/Tasks/Views/components/task_card.twig new file mode 100644 index 0000000..e241297 --- /dev/null +++ b/app/Modules/Tasks/Views/components/task_card.twig @@ -0,0 +1,58 @@ +{# + task_card.twig - Карточка задачи для Канбан-компонента + + Параметры: + - item: Объект задачи + - column: Объект колонки (для доступа к color и т.д.) +#} +
+
+ {# Заголовок #} +
+ + {{ item.title }} + + + {# Индикатор приоритета #} + {% if item.priority == 'urgent' %} + Срочно + {% elseif item.priority == 'high' %} + Высокий + {% endif %} +
+ + {# Описание #} + {% if item.description is defined and item.description %} + + {{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }} + + {% endif %} + + {# Нижняя панель #} +
+ {# Дата и статус просроченности #} + {% if item.due_date is defined and item.due_date %} + {% set isOverdue = item.due_date < date('now') and not item.completed_at %} + + + {{ item.due_date|date('d.m') }} + {% if item.completed_at %} + + {% elseif isOverdue %} + + {% endif %} + + {% else %} + + {% endif %} + + {# Кнопка просмотра #} + + + +
+
+
diff --git a/app/Modules/Tasks/Views/components/task_event.twig b/app/Modules/Tasks/Views/components/task_event.twig new file mode 100644 index 0000000..09fc52c --- /dev/null +++ b/app/Modules/Tasks/Views/components/task_event.twig @@ -0,0 +1,17 @@ +{# + task_event.twig - Событие задачи для Календарь-компонента + + Параметры: + - event: Объект задачи + - onEventClick: JavaScript функция при клике (опционально) +#} +{% set isOverdue = event.due_date is defined and event.due_date < date('now') and not event.completed_at %} + + {% if event.priority in ['urgent', 'high'] %} + + {% endif %} + {{ event.title }} + diff --git a/app/Modules/Tasks/Views/tasks/calendar.twig b/app/Modules/Tasks/Views/tasks/calendar.twig index 4055096..bd3f02e 100644 --- a/app/Modules/Tasks/Views/tasks/calendar.twig +++ b/app/Modules/Tasks/Views/tasks/calendar.twig @@ -51,120 +51,18 @@ -
-
- -
{{ monthName }}
- - Сегодня - -
-
-
- {# Дни недели #} -
- {% 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 %} -
- -
- {% for event in dayEvents|slice(0, 3) %} - - - {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} - {% if event.priority == 'urgent' or event.priority == 'high' %} - - {% endif %} - - {% endfor %} - - {% if dayEvents|length > 3 %} -
- +{{ dayEvents|length - 3 }} ещё -
- {% endif %} -
-
- {% endfor %} - - {# Пустые ячейки после последнего дня #} - {% set remaining = 7 - ((firstDay + daysInMonth) % 7) %} - {% if remaining < 7 %} - {% for i in 1..remaining %} -
- {% endfor %} - {% endif %} -
-
-
-
- - -{% endblock %} - -{% block scripts %} - +{# Календарь - универсальный компонент #} +{{ include('@components/calendar/calendar.twig', { + eventsByDate: eventsByDate, + currentMonth: currentMonth, + monthName: monthName, + daysInMonth: daysInMonth, + firstDayOfWeek: firstDayOfWeek, + prevMonth: base_url('/tasks/calendar?month=' ~ prevMonth), + nextMonth: base_url('/tasks/calendar?month=' ~ nextMonth), + today: today, + showNavigation: true, + showLegend: false, + eventComponent: '@Tasks/components/task_event.twig' +}) }} {% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/index.twig b/app/Modules/Tasks/Views/tasks/index.twig index 35f6b13..345cfbb 100644 --- a/app/Modules/Tasks/Views/tasks/index.twig +++ b/app/Modules/Tasks/Views/tasks/index.twig @@ -63,10 +63,33 @@
{{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }}
{% endblock %} {% block scripts %} + {% endblock %} diff --git a/app/Modules/Tasks/Views/tasks/kanban.twig b/app/Modules/Tasks/Views/tasks/kanban.twig index f8800df..20c798e 100644 --- a/app/Modules/Tasks/Views/tasks/kanban.twig +++ b/app/Modules/Tasks/Views/tasks/kanban.twig @@ -64,136 +64,13 @@ -{# Канбан доска #} -
-
- {% for column in kanbanColumns %} -
-
-
-
{{ column.name }}
- {{ column.items|length }} -
-
- - {% for item in column.items %} -
-
-
-
{{ item.title }}
- {% if item.priority == 'urgent' %} - Срочно - {% elseif item.priority == 'high' %} - Высокий - {% endif %} -
- - {% if item.description %} -

- {{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }} -

- {% endif %} - -
- {% if item.due_date %} - - - {{ item.due_date|date('d.m') }} - {% if item.due_date < date('Y-m-d') %} - ! - {% endif %} - - {% endif %} - - - -
-
-
- {% endfor %} - - {# Кнопка добавления #} - - Добавить задачу - -
-
-
- {% endfor %} -
-
- - -{% endblock %} - -{% block scripts %} - +{{ csrf_field()|raw }} +{# Канбан доска - универсальный компонент #} +{{ include('@components/kanban/kanban.twig', { + columns: kanbanColumns, + cardComponent: '@Tasks/components/task_card.twig', + moveUrl: base_url('/tasks/move-column'), + addUrl: base_url('/tasks/new'), + addLabel: 'Добавить задачу' +}) }} {% endblock %} diff --git a/app/Views/components/kanban/kanban.twig b/app/Views/components/kanban/kanban.twig index 3fb4249..901d87c 100644 --- a/app/Views/components/kanban/kanban.twig +++ b/app/Views/components/kanban/kanban.twig @@ -131,7 +131,7 @@ function handleDrop(e) { if (itemId && newColumnId) { if (moveUrl) { - console.log('Moving deal:', itemId, 'to stage:', newColumnId); + console.log('Moving item:', itemId, 'to column:', newColumnId); // Находим перетаскиваемую карточку const draggedCard = document.querySelector(`.kanban-card[data-item-id="${itemId}"]`); @@ -145,7 +145,7 @@ function handleDrop(e) { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, - body: 'deal_id=' + itemId + '&stage_id=' + newColumnId + body: 'id=' + itemId + '&column_id=' + newColumnId }) .then(response => response.json()) .then(data => { diff --git a/bp.txt b/bp.txt new file mode 100644 index 0000000..881fca0 --- /dev/null +++ b/bp.txt @@ -0,0 +1,23541 @@ +// app/Language/en/Validation.php + ['html']]), + new TwigFunction('get_current_org', [$this, 'getCurrentOrg'], ['is_safe' => ['html']]), + new TwigFunction('get_alerts', [$this, 'getAlerts'], ['is_safe' => ['html']]), + new TwigFunction('render_pager', [$this, 'renderPager'], ['is_safe' => ['html']]), + new TwigFunction('is_active_route', [$this, 'isActiveRoute'], ['is_safe' => ['html']]), + new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]), + new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]), + new TwigFunction('render_cell', [$this, 'renderCell'], ['is_safe' => ['html']]), + new TwigFunction('get_avatar_url', [$this, 'getAvatarUrl'], ['is_safe' => ['html']]), + new TwigFunction('get_avatar', [$this, 'getAvatar'], ['is_safe' => ['html']]), + new TwigFunction('can', [$this, 'can'], ['is_safe' => ['html']]), + new TwigFunction('is_role', [$this, 'isRole'], ['is_safe' => ['html']]), + new TwigFunction('is_owner', [$this, 'isOwner'], ['is_safe' => ['html']]), + new TwigFunction('is_admin', [$this, 'isAdmin'], ['is_safe' => ['html']]), + new TwigFunction('is_manager', [$this, 'isManager'], ['is_safe' => ['html']]), + new TwigFunction('current_role', [$this, 'currentRole'], ['is_safe' => ['html']]), + new TwigFunction('role_label', [$this, 'roleLabel'], ['is_safe' => ['html']]), + new TwigFunction('can_view', [$this, 'canView'], ['is_safe' => ['html']]), + new TwigFunction('can_create', [$this, 'canCreate'], ['is_safe' => ['html']]), + new TwigFunction('can_edit', [$this, 'canEdit'], ['is_safe' => ['html']]), + new TwigFunction('can_delete', [$this, 'canDelete'], ['is_safe' => ['html']]), + new TwigFunction('can_manage_users', [$this, 'canManageUsers'], ['is_safe' => ['html']]), + new TwigFunction('role_badge', [$this, 'roleBadge'], ['is_safe' => ['html']]), + new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]), + new TwigFunction('get_all_roles', [$this, 'getAllRoles'], ['is_safe' => ['html']]), + new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]), + new TwigFunction('is_system_admin', [$this, 'isSystemAdmin'], ['is_safe' => ['html']]), + new TwigFunction('get_system_role', [$this, 'getSystemRole'], ['is_safe' => ['html']]), + new TwigFunction('is_module_active', [$this, 'isModuleActive'], ['is_safe' => ['html']]), + new TwigFunction('is_module_available', [$this, 'isModuleAvailable'], ['is_safe' => ['html']]), + new TwigFunction('csrf_meta', [$this, 'csrf_meta'], ['is_safe' => ['html']]), + ]; + } + public function csrf_meta() + { + return csrf_meta(); + } + public function can(string $action, string $resource): bool + { + return service('access')->can($action, $resource); + } + public function isRole($roles): bool + { + return service('access')->isRole($roles); + } + public function isOwner(): bool + { + return service('access')->isRole(\App\Services\AccessService::ROLE_OWNER); + } + public function isAdmin(): bool + { + $role = service('access')->getCurrentRole(); + return $role === \App\Services\AccessService::ROLE_ADMIN + || $role === \App\Services\AccessService::ROLE_OWNER; + } + public function isManager(): bool + { + return service('access')->isManagerOrHigher(); + } + public function currentRole(): ?string + { + return service('access')->getCurrentRole(); + } + public function roleLabel(string $role): string + { + return service('access')->getRoleLabel($role); + } + public function canView(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_VIEW, $resource); + } + public function canCreate(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_CREATE, $resource); + } + public function canEdit(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_EDIT, $resource); + } + public function canDelete(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_DELETE, $resource); + } + public function canManageUsers(): bool + { + return service('access')->canManageUsers(); + } + public function roleBadge(string $role): string + { + $colors = [ + 'owner' => 'bg-primary', + 'admin' => 'bg-info', + 'manager' => 'bg-success', + 'guest' => 'bg-secondary', + ]; + $labels = [ + 'owner' => 'Владелец', + 'admin' => 'Администратор', + 'manager' => 'Менеджер', + 'guest' => 'Гость', + ]; + $color = $colors[$role] ?? 'bg-secondary'; + $label = $labels[$role] ?? $role; + return '' . esc($label) . ''; + } + public function isSuperadmin(): bool + { + return service('access')->isSuperadmin(); + } + public function isSystemAdmin(): bool + { + return service('access')->isSystemAdmin(); + } + public function getSystemRole(): ?string + { + return service('access')->getSystemRole(); + } + public function isModuleActive(string $moduleCode): bool + { + $orgId = session()->get('active_org_id'); + if (!$orgId) { + return false; + } + $subscriptionService = new \App\Services\ModuleSubscriptionService(); + return $subscriptionService->isModuleActive($moduleCode, $orgId); + } + public function isModuleAvailable(string $moduleCode): bool + { + $orgId = session()->get('active_org_id'); + if (!$orgId) { + return false; + } + $subscriptionService = new \App\Services\ModuleSubscriptionService(); + return $subscriptionService->isModuleAvailable($moduleCode, $orgId); + } + public function statusBadge(string $status): string + { + $colors = [ + 'active' => 'bg-success', + 'pending' => 'bg-warning text-dark', + 'blocked' => 'bg-danger', + ]; + $labels = [ + 'active' => 'Активен', + 'pending' => 'Ожидает', + 'blocked' => 'Заблокирован', + ]; + $color = $colors[$status] ?? 'bg-secondary'; + $label = $labels[$status] ?? $status; + return '' . esc($label) . ''; + } + public function getAvatarUrl($avatar = null, $size = 32): string + { + if (empty($avatar)) { + return ''; + } + return base_url('/uploads/avatars/' . $avatar); + } + public function getAvatar($user = null, $size = 32, $class = ''): string + { + if (!$user) { + $session = session(); + $userId = $session->get('user_id'); + if (!$userId) { + return ''; + } + $userModel = new \App\Models\UserModel(); + $user = $userModel->find($userId); + } + $name = $user['name'] ?? 'U'; + $avatar = $user['avatar'] ?? null; + if ($avatar) { + $url = base_url('/uploads/avatars/' . $avatar); + $style = "width: {$size}px; height: {$size}px; object-fit: cover; border-radius: 50%;"; + return '' . esc($name) . ''; + } + $colors = ['667eea', '764ba2', 'f093fb', 'f5576c', '4facfe', '00f2fe']; + $color = $colors[crc32($name) % count($colors)]; + $initial = strtoupper(substr($name, 0, 1)); + $style = "width: {$size}px; height: {$size}px; background: linear-gradient(135deg, #{$color} 0%, #{$color}dd 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: " . ($size / 2) . "px;"; + return '
' . esc($initial) . '
'; + } + public function getAllRoles(): array + { + return \App\Services\AccessService::getAllRoles(); + } + public function getSession() + { + return session(); + } + public function getCurrentOrg() + { + $session = session(); + $activeOrgId = $session->get('active_org_id'); + if ($activeOrgId) { + $orgModel = new OrganizationModel(); + return $orgModel->find($activeOrgId); + } + return null; + } + public function getAlerts(): array + { + $session = session(); + $alerts = []; + $types = ['success', 'error', 'warning', 'info']; + foreach ($types as $type) { + if ($msg = $session->getFlashdata($type)) { + $alerts[] = ['type' => $type, 'message' => $msg]; + } + } + if ($validationErrors = $session->getFlashdata('errors')) { + foreach ($validationErrors as $error) { + $alerts[] = ['type' => 'error', 'message' => $error]; + } + } + return $alerts; + } + public function renderPager($pager) + { + if (!$pager) { + return ''; + } + return $pager->links(); + } + /** + public function isActiveRoute($routes, $exact = false): bool + { + $currentRoute = $this->getCurrentRoute(); + if (is_string($routes)) { + $routes = [$routes]; + } + foreach ($routes as $route) { + if ($exact) { + if ($currentRoute === $route) { + return true; + } + } else { + if ($route === '') { + if ($currentRoute === '') { + return true; + } + } elseif (strpos($currentRoute, $route) === 0) { + return true; + } + } + } + return false; + } + /** + public function getCurrentRoute(): string + { + $uri = service('uri'); + $route = $uri->getRoutePath(); + return ltrim($route, '/'); + } + /** + public function renderActions($item, array $actions = []): string + { + if (empty($actions)) { + return ''; + } + $itemArray = $this->objectToArray($item); + log_message('debug', 'renderActions: item type = ' . gettype($item)); + log_message('debug', 'renderActions: item keys = ' . (is_array($itemArray) ? implode(', ', array_keys($itemArray)) : 'N/A')); + $html = '
'; + foreach ($actions as $action) { + $label = $action['label'] ?? 'Action'; + $urlPattern = $action['url'] ?? '#'; + $icon = $action['icon'] ?? ''; + $class = $action['class'] ?? 'btn-outline-secondary'; + $title = $action['title'] ?? $label; + $target = $action['target'] ?? ''; + $type = $action['type'] ?? ''; + if ($itemArray['role'] ?? '' === 'owner') { + continue; + } + if ($type === 'block' && ($itemArray['status'] ?? '') !== 'active') { + continue; + } + if ($type === 'unblock' && ($itemArray['status'] ?? '') !== 'blocked') { + continue; + } + $url = $this->interpolate($urlPattern, $itemArray); + $iconHtml = $icon ? ' ' : ''; + $targetAttr = $target ? ' target="' . esc($target) . '"' : ''; + $html .= '' + . $iconHtml . esc($label) + . ''; + } + $html .= '
'; + return $html; + } + /** + private function objectToArray($data): array + { + if (is_array($data)) { + return $data; + } + if (is_object($data)) { + $json = json_encode($data); + return json_decode($json, true); + } + return []; + } + /** + public function renderCell($item, string $key, array $config = []): string + { + $itemArray = $this->objectToArray($item); + $value = $itemArray[$key] ?? null; + if (($value === null || $value === '' || $value === false) && isset($config['default'])) { + return $config['default']; + } + if (isset($config['template'])) { + $template = $config['template']; + foreach ($itemArray as $k => $v) { + $template = str_replace('{' . $k . '}', esc($v ?? ''), $template); + } + return $template; + } + if (isset($config['type'])) { + switch ($config['type']) { + case 'email': + if ($value) { + return '' . esc($value) . ''; + } + return $config['default'] ?? '—'; + case 'phone': + if ($value) { + return '' . esc($value) . ''; + } + return $config['default'] ?? '—'; + case 'date': + if ($value) { + return esc(date('d.m.Y', strtotime($value))); + } + return $config['default'] ?? '—'; + case 'datetime': + if ($value) { + return esc(date('d.m.Y H:i', strtotime($value))); + } + return $config['default'] ?? '—'; + case 'boolean': + case 'bool': + if ($value) { + return 'Да'; + } + return 'Нет'; + case 'role_badge': + return $this->roleBadge((string) $value); + case 'status_badge': + return $this->statusBadge((string) $value); + case 'user_display': + $name = $itemArray['user_name'] ?? ''; + $email = $itemArray['user_email'] ?? $value; + $avatar = $itemArray['user_avatar'] ?? ''; + if (!empty($avatar)) { + $avatar = '/uploads/avatars/' . $itemArray['user_avatar']; + } + $avatarHtml = $avatar + ? '' + : '
'; + return '
' . $avatarHtml . '
' . esc($name ?: $email) . '
' . ($name ? '
' . esc($email) . '
' : '') . '
'; + case 'uppercase': + return $value ? esc(strtoupper($value)) : ($config['default'] ?? ''); + case 'lowercase': + return $value ? esc(strtolower($value)) : ($config['default'] ?? ''); + case 'truncate': + $length = $config['length'] ?? 50; + if ($value && strlen($value) > $length) { + return esc(substr($value, 0, $length)) . '...'; + } + return esc($value ?? ''); + case 'currency': + if ($value !== null && $value !== '') { + return number_format((float) $value, 0, '.', ' ') . ' ₽'; + } + return $config['default'] ?? '—'; + case 'percent': + if ($value !== null && $value !== '') { + return esc((float) $value) . '%'; + } + return $config['default'] ?? '—'; + } + } + return $value !== null && $value !== '' ? esc((string) $value) : '—'; + } + /** + private function interpolate(string $pattern, $data): string + { + $data = is_object($data) ? $this->objectToArray($data) : $data; + return preg_replace_callback('/\{(\w+)\}/', function ($matches) use ($data) { + $key = $matches[1]; + return isset($data[$key]) ? esc($data[$key]) : $matches[0]; + }, $pattern); + } +} + +// app/Libraries/RateLimitIdentifier.php +isValidToken($cookieToken)) { + $parts['c'] = $cookieToken; + } + $parts['i'] = $this->getClientIp(); + $parts['ua'] = $this->getUserAgentHash(); + if (empty($cookieToken)) { + $parts['nc'] = '1'; + } + return md5('rl:' . $action . ':' . implode('|', $parts)); + } + /** + public function ensureToken(): ?string + { + if (empty($_COOKIE[self::COOKIE_NAME])) { + $token = $this->generateToken(); + setcookie( + self::COOKIE_NAME, + $token, + [ + 'expires' => time() + self::COOKIE_TTL, + 'path' => '/', + 'secure' => true, + 'samesite' => 'Lax', + 'httponly' => true, + ] + ); + return $token; + } + return null; + } + /** + public function hasToken(): bool + { + return !empty($_COOKIE[self::COOKIE_NAME]); + } + /** + public function getJsScript(): string + { + return << 500) { + $ua = substr($ua, 0, 500); + } + return md5($ua); + } +} + +// app/Libraries/EmailLibrary.php +render('emails/verification', [ + 'name' => $name, + 'verification_url' => $verificationUrl, + 'app_name' => $emailConfig->fromName ?? 'Бизнес.Точка', + ]); + $emailer = Services::email($emailConfig); + $emailer->setTo($email); + $emailer->setFrom($emailConfig->fromEmail, $emailConfig->fromName); + $emailer->setSubject('Подтверждение регистрации'); + $emailer->setMessage($htmlBody); + try { + return $emailer->send(); + } catch (\Exception $e) { + log_message('error', 'Ошибка отправки email: ' . $e->getMessage()); + return false; + } + } + /** + public function sendWelcomeEmail(string $email, string $name): bool + { + $emailConfig = config('Email'); + $twig = Services::twig(); + $htmlBody = $twig->render('emails/welcome', [ + 'name' => $name, + 'app_name' => $emailConfig->fromName ?? 'Бизнес.Точка', + ]); + $emailer = Services::email($emailConfig); + $emailer->setTo($email); + $emailer->setFrom($emailConfig->fromEmail, $emailConfig->fromName); + $emailer->setSubject('Добро пожаловать!'); + $emailer->setMessage($htmlBody); + try { + return $emailer->send(); + } catch (\Exception $e) { + log_message('error', 'Ошибка отправки email: ' . $e->getMessage()); + return false; + } + } + /** + public function sendPasswordResetEmail(string $email, string $name, string $token): bool + { + $emailConfig = config('Email'); + $resetUrl = base_url('/forgot-password/reset/' . $token); + $twig = Services::twig(); + $htmlBody = $twig->render('emails/password_reset', [ + 'name' => $name, + 'reset_url' => $resetUrl, + 'app_name' => $emailConfig->fromName ?? 'Бизнес.Точка', + ]); + $emailer = Services::email($emailConfig); + $emailer->setTo($email); + $emailer->setFrom($emailConfig->fromEmail, $emailConfig->fromName); + $emailer->setSubject('Сброс пароля'); + $emailer->setMessage($htmlBody); + try { + return $emailer->send(); + } catch (\Exception $e) { + log_message('error', 'Ошибка отправки email: ' . $e->getMessage()); + return false; + } + } +} + +// app/Libraries/.gitkeep + +// app/Helpers/access_helper.php +can($action, $resource); + } +} +/** +if (!function_exists('canView')) { + function canView(string $resource): bool + { + return can('view', $resource); + } +} +/** +if (!function_exists('canCreate')) { + function canCreate(string $resource): bool + { + return can('create', $resource); + } +} +/** +if (!function_exists('canEdit')) { + function canEdit(string $resource): bool + { + return can('edit', $resource); + } +} +/** +if (!function_exists('canDelete')) { + function canDelete(string $resource, bool $any = false): bool + { + return can('delete', $resource) || ($any && can('delete_any', $resource)); + } +} +/** +if (!function_exists('isRole')) { + function isRole($roles): bool + { + $access = service('access'); + return $access->isRole($roles); + } +} +/** +if (!function_exists('isOwner')) { + function isOwner(): bool + { + return isRole(\App\Services\AccessService::ROLE_OWNER); + } +} +/** +if (!function_exists('isAdmin')) { + function isAdmin(): bool + { + return isRole(\App\Services\AccessService::ROLE_ADMIN); + } +} +/** +if (!function_exists('isManager')) { + function isManager(): bool + { + $access = service('access'); + return $access->isManagerOrHigher(); + } +} +/** +if (!function_exists('canManageUsers')) { + function canManageUsers(): bool + { + return can('manage_users', 'users'); + } +} +/** +if (!function_exists('canManageModules')) { + function canManageModules(): bool + { + return can('manage_modules', 'modules'); + } +} +/** +if (!function_exists('roleLabel')) { + function roleLabel(string $role): string + { + $access = service('access'); + return $access->getRoleLabel($role); + } +} +/** +if (!function_exists('currentRole')) { + function currentRole(): ?string + { + $access = service('access'); + return $access->getCurrentRole(); + } +} +/** +if (!function_exists('isAuthenticatedInOrg')) { + function isAuthenticatedInOrg(): bool + { + $access = service('access'); + return $access->isAuthenticated(); + } +} +/** +if (!function_exists('availableRolesForAssignment')) { + function availableRolesForAssignment(): array + { + $currentRole = currentRole(); + if (!$currentRole) { + return []; + } + $access = service('access'); + $roles = $access->getAvailableRolesForAssignment($currentRole); + $result = []; + foreach ($roles as $role) { + $result[$role] = roleLabel($role); + } + return $result; + } +} +/** +if (!function_exists('showAction')) { + function showAction(string $action, string $resource, bool $showForOwnerAdmin = true): bool + { + if ($action === 'view') { + return isAuthenticatedInOrg(); + } + if ($showForOwnerAdmin && isManager()) { + return true; + } + return can($action, $resource); + } +} + +// app/Helpers/.gitkeep + +// app/Helpers/crm_deals_helper.php +formatCurrency($amount, $currency); + } +} +/** +function current_organization_id(): ?int +{ + $session = session(); + $orgId = $session->get('active_org_id'); + return $orgId ? (int) $orgId : null; +} + +// app/index.html + + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + + +// app/Common.php + + + + + + {{ csrf_meta() }} + {% block title %}Бизнес.Точка{% endblock %} + + + + + + + + {% block styles %}{% endblock %} + + + +
+ + + + + +
+ + + + + + +
+ + {% include 'components/alerts.twig' %} + + {% block content %}{% endblock %} +
+ + +
+
+ + + + + +{% block scripts %}{% endblock %} + + + +// app/Views/layouts/public.twig + + + + + + Вход - Бизнес.Точка + + + + +{% include 'components/alerts.twig' %} + +{% block content %}{% endblock %} + + + + + +{% block scripts %}{% endblock %} + + + +// app/Views/superadmin/modules/index.twig +{% extends 'superadmin/layout.twig' %} + +{% block title %}Модули - Суперадмин{% endblock %} + +{% block content %} +
+

Модули системы

+
+ +
+
+ +
+
+
+ {% for code, module in modules %} +
+ +
+ {{ csrf_field()|raw }} + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ Код модуля: {{ code }}
+ Возможности: +
    + {% for feature in module.features %} +
  • {{ feature }}
  • + {% else %} +
  • Нет описания возможностей
  • + {% endfor %} +
+
+
+ +
+ +
+
+
+ {% endfor %} +
+
+
+{% endblock %} + +// app/Views/superadmin/layout.twig + + + + + + {% block title %}Панель суперадмина{% endblock %} — Бизнес.Точка + + + + {% block styles %}{% endblock %} + + + + + + {% block scripts %}{% endblock %} + + + +// app/Views/superadmin/users/index.twig +{% extends 'superadmin/layout.twig' %} + +{% block content %} +
+

Пользователи

+
+ +{% for alert in get_alerts() %} +
{{ alert.message }}
+{% endfor %} + +
+
+
+ {# Динамическая таблица #} + {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+
+ + + + +{% endblock %} + +// app/Views/superadmin/statistics.twig +{% extends 'superadmin/layout.twig' %} + +{% block content %} +
+

Статистика

+
+
+
+
+

Распределение по тарифам

+
+
+ {% if planStats is empty %} +

Нет данных о тарифах

+ {% else %} + + + + + + + + + + {% 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 %} + + + + + + {% endfor %} + +
ТарифОрганизацийДоля
{{ plan.name }}{{ plan.orgs_count }} +
+
+
+
+ {{ percent }}% +
+
+ {% 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 дней)

+
+
+ +
+
+
+ + + + + + + + + + {% for stat in dailyStats %} + + + + + + {% endfor %} + +
ДатаНовые пользователиНовые организации
{{ stat.date|date('d.m.Y') }}{{ stat.users }}{{ stat.orgs }}
+
+
+
+ + + + + +{% endblock %} + +// app/Views/superadmin/organizations/view.twig +{% extends 'superadmin/layout.twig' %} + +{% block content %} +
+

Организация: {{ organization.name }}

+ + Назад к списку + +
+ +{% for alert in get_alerts() %} +
{{ alert.message }}
+{% endfor %} + +
+
+
+
+
Подписки на модули
+
+
+ {% if subscriptions is empty %} +

У организации нет активных подписок

+ {% else %} +
+ + + + + + + + + + + + {% for sub in subscriptions %} + {% set module = allModules[sub.module_code] %} + + + + + + + + {% endfor %} + +
МодульСтатусИстекаетСозданаДействия
+ {{ module.name|default(sub.module_code) }} +
{{ module.description|default('') }}
+
+ {% if sub.status == 'active' %} + Активна + {% elseif sub.status == 'trial' %} + Триал + {% elseif sub.status == 'expired' %} + Истекла + {% else %} + {{ sub.status }} + {% endif %} + + {% if sub.expires_at %} + {{ sub.expires_at|date('d.m.Y H:i') }} + {% else %} + Бессрочно + {% endif %} + {{ sub.created_at|date('d.m.Y H:i') }} + + + +
+
+ {% endif %} +
+
+ +
+
+
Участники организации
+
+
+ {% if users is empty %} +

Участников пока нет

+ {% else %} +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
ПользовательEmailРольСтатусДата добавления
{{ user.name|default('—') }}{{ user.email }} + + {{ user.role }} + + + {% if user.status == 'active' %} + Активен + {% elseif user.status == 'blocked' %} + Заблокирован + {% else %} + {{ user.status }} + {% endif %} + {{ user.created_at|date('d.m.Y') }}
+
+ {% endif %} +
+
+
+ +
+
+
+
Добавить подписку
+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ + +
0 - бессрочно
+
+ +
+ + +
+ + +
+
+
+ +
+
+
Информация
+
+
+ + + + + + + + + + + + + + + + + + + + + +
ID{{ organization.id }}
Тип + {% if organization.type == 'business' %} + Бизнес + {% else %} + Личное + {% endif %} +
Статус + {% if organization.status == 'active' %} + Активна + {% elseif organization.status == 'blocked' %} + Заблокирована + {% else %} + {{ organization.status }} + {% endif %} +
Создана{{ organization.created_at|date('d.m.Y H:i') }}
Участников{{ users|length }}
+
+
+ +
+ {% if organization.status == 'active' %} + + Заблокировать + + {% else %} + + Разблокировать + + {% endif %} + + Удалить организацию + +
+
+
+{% endblock %} + +// app/Views/superadmin/organizations/index.twig +{% extends 'superadmin/layout.twig' %} + +{% block content %} +
+

Организации

+
+ +{% for alert in get_alerts() %} +
{{ alert.message }}
+{% endfor %} + +
+
+
+ {# Динамическая таблица #} + {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+
+ + + + + + +{% endblock %} + +// app/Views/superadmin/dashboard.twig +{% extends 'superadmin/layout.twig' %} + +{% block content %} +
+

Дашборд

+
+ Добро пожаловать, {{ session_data.name }} + {{ get_avatar(null, 40, '') }} +
+
+ +{% for alert in get_alerts() %} +
{{ alert.message }}
+{% endfor %} + +
+
+

Всего пользователей

+
{{ stats.total_users|number_format(0, '', ' ') }}
+
👥
+
+
+

Всего организаций

+
{{ stats.total_orgs|number_format(0, '', ' ') }}
+
🏢
+
+
+

Зарегистрировано сегодня

+
{{ stats.active_today|number_format(0, '', ' ') }}
+
📅
+
+
+

Всего модулей

+
{{ stats.total_modules|number_format(0, '', ' ') }}
+
📦
+
+
+ +
+
+
+

Последние организации

+ Все организации +
+
+ {% if recentOrgs is empty %} +

Организаций пока нет

+ {% else %} + + + + + + + + + + {% for org in recentOrgs %} + + + + + + {% endfor %} + +
НазваниеТипДата
+ + {{ org.name }} + + + {% if org.type == 'business' %} + Бизнес + {% else %} + Личное + {% endif %} + {{ org.created_at|date('d.m.Y') }}
+ {% endif %} +
+
+ +
+
+

Последние пользователи

+ Все пользователи +
+
+ {% if recentUsers is empty %} +

Пользователей пока нет

+ {% else %} + + + + + + + + + + {% for user in recentUsers %} + + + + + + {% endfor %} + +
ИмяEmailРоль
{{ user.name|default('—') }}{{ user.email }} + + {{ user.system_role|default('user') }} + +
+ {% endif %} +
+
+
+{% endblock %} + +// app/Views/superadmin/subscriptions/create.twig +{% extends 'superadmin/layout.twig' %} + +{% block title %}Добавить подписку - Суперадмин{% endblock %} + +{% block content %} +
+

Добавить подписку

+ + Назад к списку + +
+ +
+
+
+ {{ csrf_field()|raw }} + +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
0 - подписка без срока истечения
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Views/superadmin/subscriptions/index.twig +{% extends 'superadmin/layout.twig' %} + +{% block title %}Подписки - Суперадмин{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+

Управление подписками

+ + Добавить подписку + +
+ +{% if session.success %} + +{% endif %} + +{% if session.error %} + +{% endif %} + +
+
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} + +// app/Views/pager/bootstrap_full.php +setSurroundCount(2); +?> + + +// app/Views/profile/security.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ {% if user.avatar %} + Аватар + {% else %} +
+ {{ user.name|first|upper }} +
+ {% endif %} +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+ + +
+
+
+ +
+ +
+
+

Смена пароля

+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ + +
Минимум 6 символов
+
+ +
+ + +
+ +
+ + После смены пароля вы будете автоматически разлогинены на всех устройствах для безопасности. +
+ + +
+
+
+ + +
+
+

Активные сессии

+ {% if sessions|length > 0 %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} +
+
+ {% if sessions|length > 0 %} + {% for session in sessions %} +
+
+
+
+ {{ session.device }} + {% if session.is_current %} + Текущая сессия + {% endif %} +
+
+ {{ session.ip_address }} + {% if session.expires_at %} +  |  + истекает {{ session.expires_at|date('d.m.Y H:i') }} + {% endif %} +
+
+ {% if not session.is_current %} +
+ {{ csrf_field()|raw }} + + +
+ {% endif %} +
+
+ {% endfor %} + +
+ + Запомненные устройства: Если вы отметили "Запомнить меня" при входе, устройство будет автоматически авторизовано в течение 30 дней. Вы можете завершить эти сессии вручную. +
+ {% else %} +
+ +

Нет активных сессий на других устройствах

+
+ {% endif %} +
+
+ + +
+
+

Рекомендации по безопасности

+
+
+
    +
  • Используйте пароль длиной не менее 8 символов
  • +
  • Комбинируйте буквы, цифры и специальные символы
  • +
  • Не используйте один и тот же пароль для разных сервисов
  • +
  • Регулярно меняйте пароль
  • +
  • Не сообщайте пароль третьим лицам
  • +
+
+
+
+
+{% endblock %} + +// app/Views/profile/index.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ {% if user.avatar %} + Аватар + {% else %} +
+ {{ user.name|first|upper }} +
+ {% endif %} + + +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+ + +
+
+
+ +
+ +
+
+

Основная информация

+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
Ваше имя будет отображаться в системе
+
+ +
+ + +
Email нельзя изменить (он является вашим логином)
+
+ +
+ + +
+ + +
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Views/profile/organizations.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ {% if user.avatar %} + Аватар + {% else %} +
+ {{ user.name|first|upper }} +
+ {% endif %} +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+ + +
+
+
+ +
+ +
+
+

Мои организации

+ + Создать организацию + +
+
+ {% if organizations is empty %} +
+ +

У вас пока нет организаций

+ + Создать первую организацию + +
+ {% else %} +
+ + + + + + + + + + + + {% for org in organizations %} + + + + + + + + {% endfor %} + +
ОрганизацияТипВаша рольДата входаДействия
+
+
+ +
+
+ {{ org.name }} + {% if org.is_current_org %} + Текущая + {% endif %} +
+ ID: {{ org.id }} +
+
+
+ {% if org.type == 'personal' %} + Личное пространство + {% else %} + Бизнес + {% endif %} + + {% if org.role == 'owner' %} + Владелец + {% elseif org.role == 'admin' %} + Администратор + {% elseif org.role == 'manager' %} + Менеджер + {% else %} + Гость + {% endif %} + {{ org.joined_at ? org.joined_at|date('d.m.Y H:i') : '—' }} +
+ {# ВЛАДЕЛЕЦ #} + {% if org.is_owner %} + {# Текущая организация #} + {% if org.is_current_org %} + + + + + + + + {% else %} + {# Не текущая организация #} + + + + + + + + {% endif %} + {% elseif org.role in ['admin', 'manager'] %} + {# Админ/Менеджер #} + {% if org.is_current_org %} + + + + + {% else %} + + + + + {% endif %} + {% else %} + {# Гость (не owner, не admin, не manager) #} + {% if org.is_current_org %} + + {% else %} + + + + + {% endif %} + {% endif %} +
+
+
+ {% endif %} +
+
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Views/errors/cli/production.php +getFile()) . ':' . $exception->getLine(), 'green')); +CLI::newLine(); +$last = $exception; +while ($prevException = $last->getPrevious()) { + $last = $prevException; + CLI::write(' Caused by:'); + CLI::write(' [' . $prevException::class . ']', 'red'); + CLI::write(' ' . $prevException->getMessage()); + CLI::write(' at ' . CLI::color(clean_path($prevException->getFile()) . ':' . $prevException->getLine(), 'green')); + CLI::newLine(); +} +if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) { + $backtraces = $last->getTrace(); + if ($backtraces) { + CLI::write('Backtrace:', 'green'); + } + foreach ($backtraces as $i => $error) { + $padFile = ' '; + $padClass = ' '; + $c = str_pad($i + 1, 3, ' ', STR_PAD_LEFT); + if (isset($error['file'])) { + $filepath = clean_path($error['file']) . ':' . $error['line']; + CLI::write($c . $padFile . CLI::color($filepath, 'yellow')); + } else { + CLI::write($c . $padFile . CLI::color('[internal function]', 'yellow')); + } + $function = ''; + if (isset($error['class'])) { + $type = ($error['type'] === '->') ? '()' . $error['type'] : $error['type']; + $function .= $padClass . $error['class'] . $type . $error['function']; + } elseif (! isset($error['class']) && isset($error['function'])) { + $function .= $padClass . $error['function']; + } + $args = implode(', ', array_map(static fn ($value): string => match (true) { + is_object($value) => 'Object(' . $value::class . ')', + is_array($value) => $value !== [] ? '[...]' : '[]', + $value === null => 'null', + default => var_export($value, true), + }, array_values($error['args'] ?? []))); + $function .= '(' . $args . ')'; + CLI::write($function); + CLI::newLine(); + } +} + +// app/Views/errors/html/debug.css +:root { + --main-bg-color: #fff; + --main-text-color: #555; + --dark-text-color: #222; + --light-text-color: #c7c7c7; + --brand-primary-color: #DC4814; + --light-bg-color: #ededee; + --dark-bg-color: #404040; +} + +body { + height: 100%; + background: var(--main-bg-color); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--main-text-color); + font-weight: 300; + margin: 0; + padding: 0; +} +h1 { + font-weight: lighter; + font-size: 3rem; + color: var(--dark-text-color); + margin: 0; +} +h1.headline { + margin-top: 20%; + font-size: 5rem; +} +.text-center { + text-align: center; +} +p.lead { + font-size: 1.6rem; +} +.container { + max-width: 75rem; + margin: 0 auto; + padding: 1rem; +} +.header { + background: var(--light-bg-color); + color: var(--dark-text-color); + margin-top: 2.17rem; +} +.header .container { + padding: 1rem; +} +.header h1 { + font-size: 2.5rem; + font-weight: 500; +} +.header p { + font-size: 1.2rem; + margin: 0; + line-height: 2.5; +} +.header a { + color: var(--brand-primary-color); + margin-left: 2rem; + display: none; + text-decoration: none; +} +.header:hover a { + display: inline; +} + +.environment { + background: var(--brand-primary-color); + color: var(--main-bg-color); + text-align: center; + padding: calc(4px + 0.2083vw); + width: 100%; + top: 0; + position: fixed; +} + +.source { + background: #343434; + color: var(--light-text-color); + padding: 0.5em 1em; + border-radius: 5px; + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 0.85rem; + margin: 0; + overflow-x: scroll; +} +.source span.line { + line-height: 1.4; +} +.source span.line .number { + color: #666; +} +.source .line .highlight { + display: block; + background: var(--dark-text-color); + color: var(--light-text-color); +} +.source span.highlight .number { + color: #fff; +} + +.tabs { + list-style: none; + list-style-position: inside; + margin: 0; + padding: 0; + margin-bottom: -1px; +} +.tabs li { + display: inline; +} +.tabs a:link, +.tabs a:visited { + padding: 0 1rem; + line-height: 2.7; + text-decoration: none; + color: var(--dark-text-color); + background: var(--light-bg-color); + border: 1px solid rgba(0,0,0,0.15); + border-bottom: 0; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + display: inline-block; +} +.tabs a:hover { + background: var(--light-bg-color); + border-color: rgba(0,0,0,0.15); +} +.tabs a.active { + background: var(--main-bg-color); + color: var(--main-text-color); +} +.tab-content { + background: var(--main-bg-color); + border: 1px solid rgba(0,0,0,0.15); +} +.content { + padding: 1rem; +} +.hide { + display: none; +} + +.alert { + margin-top: 2rem; + display: block; + text-align: center; + line-height: 3.0; + background: #d9edf7; + border: 1px solid #bcdff1; + border-radius: 5px; + color: #31708f; +} + +table { + width: 100%; + overflow: hidden; +} +th { + text-align: left; + border-bottom: 1px solid #e7e7e7; + padding-bottom: 0.5rem; +} +td { + padding: 0.2rem 0.5rem 0.2rem 0; +} +tr:hover td { + background: #f1f1f1; +} +td pre { + white-space: pre-wrap; +} + +.trace a { + color: inherit; +} +.trace table { + width: auto; +} +.trace tr td:first-child { + min-width: 5em; + font-weight: bold; +} +.trace td { + background: var(--light-bg-color); + padding: 0 1rem; +} +.trace td pre { + margin: 0; +} +.args { + display: none; +} + +// app/Views/errors/html/production.php + + + + + + <?= lang('Errors.whoops') ?> + + + +
+

+

+
+ + + +// app/Views/errors/html/error_400.php + + + + + <?= lang('Errors.badRequest') ?> + + + +
+

400

+

+ + + + + +

+
+ + + +// 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') ?> + + + +
+

404

+

+ + + + + +

+
+ + + +// app/Views/errors/html/error_exception.php + + + + + + + <?= esc($title) ?> + + + + + +
+
+ Displayed at — + PHP: — + CodeIgniter: -- + Environment: +
+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+

+ getMessage())) ?> + search → +

+
+
+ +
+

at line

+ +
+ +
+ +
+
+ getPrevious()) { + $last = $prevException; + ?> +
+    Caused by:
+    getCode() ? ' #' . $prevException->getCode() : '') ?>
+    getMessage())) ?>
+    search →
+    getFile()) . ':' . $prevException->getLine()) ?>
+    
+ +
+ +
+ +
+ +
+
    + $row) : ?> +
  1. +

    + + + + + {PHP internal code} + + + +   —   + + + ( arguments ) +

    + + getParameters(); + } + foreach ($row['args'] as $key => $value) : ?> + + + + + +
    name : "#{$key}") ?>
    +
    + + () + + + +   —   () + +

    + + +
    + +
    + +
  2. + +
+
+ +
+ + +

$

+ + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + + + +

Constants

+ + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathgetUri()) ?>
HTTP MethodgetMethod()) ?>
IP AddressgetIPAddress()) ?>
Is AJAX Request?isAJAX() ? 'yes' : 'no' ?>
Is CLI Request?isCLI() ? 'yes' : 'no' ?>
Is Secure Request?isSecure() ? 'yes' : 'no' ?>
User AgentgetUserAgent()->getAgentString()) ?>
+ + + + +

$

+ + + + + + + + + $value) : ?> + + + + + + +
KeyValue
+ + + +
+ +
+ + +
+ No $_GET, $_POST, or $_COOKIE Information to show. +
+ + headers(); ?> + +

Headers

+ + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getValueLine(), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ +
+ + setStatusCode(http_response_code()); + ?> +
+ + + + + +
Response StatusgetStatusCode() . ' - ' . $response->getReasonPhrase()) ?>
+ headers(); ?> + +

Headers

+ + + + + + + + + $value) : ?> + + + + + + +
HeaderValue
+ getHeaderLine($name), 'html'); + } else { + foreach ($value as $i => $header) { + echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html'); + } + } + ?> +
+ +
+ +
+ +
    + +
  1. + +
+
+ +
+ + + + + + + + + + + + + + + +
Memory Usage
Peak Memory Usage:
Memory Limit:
+
+
+
+ + + + +// app/Views/emails/password_reset.twig + + + + + + Сброс пароля + + + +
+ +
+ + + +// app/Views/emails/verification.twig + + + + + + Подтверждение регистрации + + + +
+
+ + +

Добрый день, {{ name }}!

+ +

Спасибо за регистрацию в {{ app_name }}.

+ +

Для завершения регистрации и подтверждения вашего email адреса, пожалуйста, нажмите на кнопку ниже:

+ +

+ Подтвердить email +

+ +

Если кнопка не работает, вы можете скопировать ссылку и вставить её в адресную строку браузера:

+ +

+ {{ verification_url }} +

+ +

Ссылка действительна в течение 24 часов.

+ +

Если вы не регистрировались на {{ app_name }}, просто проигнорируйте это письмо.

+
+ + +
+ + + +// app/Views/emails/welcome.twig + + + + + + Добро пожаловать + + + +
+
+ + +

Добро пожаловать, {{ name }}!

+ +

Поздравляем! Ваш email успешно подтверждён.

+ +

Теперь вы можете:

+
    +
  • Создавать и управлять организациями
  • +
  • Приглашать сотрудников
  • +
  • Использовать все функции платформы
  • +
+ +

+ Перейти в личный кабинет +

+ +

Если у вас возникнут вопросы, наша служба поддержки всегда готова помочь.

+ +

С уважением,
Команда {{ app_name }}

+
+ + +
+ + + +// app/Views/organizations/invitation_accept.twig +{# + organizations/invitation_accept.twig - Страница принятия/отклонения приглашения +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {# Логотип организации #} +
+ {% if organization.logo %} + {{ organization.name }} + {% else %} +
+ +
+ {% endif %} +
+ +

Приглашение в организацию

+ + {# Информация об организации #} +
+

{{ organization.name }}

+ {{ role_label }} +
+ +

+ Вас пригласили присоединиться к организации "{{ organization.name }}" +

+ + {% if invited_by %} +

+ Приглашение отправил: {{ invited_by.name|default(invited_by.email) }} +

+ {% endif %} + + {# Если пользователь не авторизован #} + {% if not is_logged_in %} +
+ + Для принятия приглашения необходимо войти в аккаунт или зарегистрироваться +
+ {% endif %} + + {# Если авторизован, но email не совпадает #} + {% if is_logged_in and not email_matches %} +
+ + Внимание! Вы вошли как {{ get_session('email') }}, + а приглашение отправлено на другой email. +
+ {% endif %} + + {# Форма принятия/отклонения #} +
+ {{ csrf_field()|raw }} + + +
+ + +
+
+ + {# Ссылка на вход/регистрацию #} + {% if not is_logged_in %} + + {% endif %} + +
+ + Приглашение действительно 48 часов +
+
+
+ + {# Футер #} +
+ © {{ "now"|date("Y") }} Бизнес.Точка +
+
+
+
+
+{% endblock %} + +// app/Views/organizations/confirm_modal.twig +{# + organizations/confirm_modal.twig - Модальное окно подтверждения действия +#} + + +// app/Views/organizations/invite_modal.twig +{# + organizations/invite_modal.twig - Модальное окно приглашения пользователя +#} + + +{# Модалка с ссылкой приглашения #} + + +// app/Views/organizations/delete.twig +{% extends 'layouts/base.twig' %} + +{% block content %} +
+
+
+
Подтверждение удаления
+
+
+

Вы уверены, что хотите удалить организацию?

+ +
+
{{ organization.name }}
+ {% if organization.inn %} +

ИНН: {{ organization.inn }}

+ {% endif %} + {% if organization.type == 'personal' %} + Личное пространство + {% else %} + Организация + {% endif %} +
+ +
+ Внимание! Это действие нельзя отменить.
+ Будут удалены: +
    +
  • Все данные организации
  • +
  • Все связи с пользователями
  • +
  • Все записи, связанные с организацией
  • +
+
+ + {% from 'macros/forms.twig' import form_open, form_close %} + + {{ form_open(base_url('/organizations/delete/' ~ organization.id)) }} +
+ + + Нет, отмена + +
+ {{ form_close() }} + +
+
+
+{% endblock %} + +// app/Views/organizations/edit.twig +{% extends 'layouts/base.twig' %} + +{% block content %} +
+
+
+
Редактирование организации
+ + Назад + +
+
+ + {% from 'macros/forms.twig' import form_open, form_close %} + + {{ form_open(base_url('/organizations/edit/' ~ organization.id)) }} + + {# Основная информация #} +
Основная информация
+
+ + + {% if errors.name is defined %} +
{{ errors.name }}
+ {% endif %} +
+ +
+ + {# Регистрационные данные #} +
Регистрационные данные
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {# Адреса #} +
Адреса
+
+ + +
+
+ + +
Если совпадает с юридическим — оставьте пустым
+
+ +
+ + {# Контакты #} +
Контакты
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {# Банковские реквизиты #} +
Банковские реквизиты
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + Отмена + + + Удалить организацию + +
+ + {{ form_close() }} + +
+
+
+{% endblock %} + +// app/Views/organizations/users.twig +{# + organizations/users.twig - Страница управления пользователями организации +#} +{% extends 'layouts/base.twig' %} + +{% block title %}Участники организации - {{ parent() }}{% endblock %} + +{% block content %} +
+ {# Заголовок #} +
+
+

Участники организации

+

{{ organization.name }}

+
+ {% if can_manage_users %} + + {% endif %} +
+ + {# Статистика #} +
+
+
+
+
+
+
Всего участников
+
{{ users|length }}
+
+ +
+
+
+
+
+
+
+
+
+
Активных
+
{{ users|filter(u => u.status == 'active')|length }}
+
+ +
+
+
+
+
+
+
+
+
+
Ожидают ответа
+
{{ users|filter(u => u.status == 'pending')|length }}
+
+ +
+
+
+
+
+
+
+
+
+
Заблокировано
+
{{ users|filter(u => u.status == 'blocked')|length }}
+
+ +
+
+
+
+
+ + {# Таблица пользователей #} + {{ tableHtml|raw }} + + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} + + {# Кнопка выхода из организации #} + {% if current_role != 'owner' %} +
+ +
+ {% endif %} +
+ +{# Модалка приглашения #} +{% include 'organizations/invite_modal.twig' %} + +{# Модалка подтверждения действий #} +{% include 'organizations/confirm_modal.twig' %} + +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + + + +{% endblock %} + +// app/Views/organizations/invitation_expired.twig +{# + organizations/invitation_expired.twig - Страница истёкшего/недействительного приглашения +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+
+
+
+ +
+ +

{{ title }}

+ +

+ Это приглашение недействительно или уже было обработано.
+ Возможно, оно истекло или было отозвано отправителем. +

+ + {% if expired and expired_at %} +
+ + Приглашение истекло {{ expired_at|date("d.m.Y в H:i") }} +
+ {% endif %} + + +
+
+ + {# Футер #} +
+ © {{ "now"|date("Y") }} Бизнес.Точка +
+
+
+
+
+{% endblock %} + +// app/Views/organizations/invitation_complete.twig +{# + organizations/invitation_complete.twig - Страница завершения регистрации нового пользователя +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {# Логотип организации #} +
+ {% if organization.logo %} + {{ organization.name }} + {% else %} +
+ +
+ {% endif %} +
+ +

Завершение регистрации

+ +

+ Вы приняли приглашение в организацию "{{ organization.name }}" +
+ в роли {{ role_label }} +

+ +

+ Пожалуйста, создайте пароль для вашего аккаунта +

+ + {# Ошибки валидации #} + {% if get_alerts()|filter(a => a.type == 'error')|length > 0 %} +
+ {% for alert in get_alerts() %} + {% if alert.type == 'error' %} +
{{ alert.message }}
+ {% endif %} + {% endfor %} +
+ {% endif %} + + {# Форма регистрации #} +
+ {{ csrf_field()|raw }} + +
+ + + {% if old.name is defined and old.name is empty %} +
Имя обязательно
+ {% endif %} +
+ +
+ + +
Email подтверждён через приглашение
+
+ +
+ + +
Минимум 8 символов
+
+ +
+ + +
+ +
+ +
+
+
+
+ + {# Футер #} +
+ © {{ "now"|date("Y") }} Бизнес.Точка +
+
+
+
+
+{% endblock %} + +// app/Views/organizations/edit_role_modal.twig +{# + organizations/edit_role_modal.twig - Модальное окно изменения роли пользователя +#} + + + + +// app/Views/organizations/create.twig +{% extends 'layouts/base.twig' %} + +{% block content %} +
+
+
+
Создание организации
+ + Назад + +
+
+ + {% from 'macros/forms.twig' import form_open, form_close %} + + {{ form_open(base_url('/organizations/create')) }} + + {# Основная информация #} +
Основная информация
+
+ + + {% if errors.name is defined %} +
{{ errors.name }}
+ {% endif %} +
+ +
+ + {# Регистрационные данные #} +
Регистрационные данные
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {# Адреса #} +
Адреса
+
+ + +
+
+ + +
Если совпадает с юридическим — оставьте пустым
+
+ +
+ + {# Контакты #} +
Контакты
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {# Банковские реквизиты #} +
Банковские реквизиты
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + Отмена + +
+ + {{ form_close() }} + +
+
+
+{% endblock %} + +// app/Views/organizations/index.twig +{% extends 'layouts/base.twig' %} + +{% block content %} +
+
+

Мои организации

+ + Создать организацию + +
+ + {% if organizations is empty %} +
+
+ +
Организаций пока нет
+

Создайте свою первую организацию для начала работы

+ + Создать организацию + +
+
+ {% else %} +
+ {% for org in organizations %} + {# Декодируем JSON реквизиты #} + {% set req = org.requisites ? org.requisites|json_decode : {} %} +
+
+
+
+
+
+ {% if org.type == 'personal' %} + + {% else %} + + {% endif %} + {{ org.name }} +
+ + {% if org.type == 'personal' %} + Личное пространство + {% else %} + {% if req.inn %}ИНН: {{ req.inn }}{% endif %} + {% endif %} + +
+ {% if org.id == session.get('active_org_id') %} + Активна + {% endif %} +
+ + {# Краткая информация #} + {% if req.phone or req.email %} +
+ {% if req.phone %} + + {{ req.phone }} + + {% endif %} + {% if req.email %} + + {{ req.email }} + + {% endif %} +
+ {% endif %} + +
+ {# Кнопка выбора/переключения #} + {% if org.id != session.get('active_org_id') %} + + Выбрать + + {% endif %} + + {# Кнопка редактирования #} + + Ред. + + + {# Кнопка удаления (только для бизнес-организаций) #} + {% if org.type == 'business' %} + + Удалить + + {% endif %} +
+
+ +
+
+ {% endfor %} +
+ {% endif %} + + {# Статистика #} +
+ Всего организаций: {{ count }} +
+
+{% endblock %} + +// app/Views/organizations/dashboard.twig +{% extends 'layouts/base.twig' %} + +{% block title %}Управление организацией - {{ organization.name }} - {{ parent() }}{% endblock %} + +{% block content %} +
+ {# Заголовок #} +
+
+

Управление организацией

+

{{ organization.name }}

+
+
+ {{ role_badge(current_role) }} +
+
+ + {# Статистика #} +
+
+
+
+
{{ stats.users_total }}
+
Всего участников
+
+
+
+
+
+
+
{{ stats.users_active }}
+
Активных
+
+
+
+
+
+
+
{{ stats.users_blocked }}
+
Заблокировано
+
+
+
+
+ + {# Карточки управления #} +
+ {# Управление командой #} + {% if can_manage_users %} + + {% endif %} + + {# Редактирование организации #} + + + {# Модули организации - заглушка #} +
+
+
+
+
+ +
+
+
Модули
+

Управление подключёнными модулями и функционалом организации

+
+
+
+ +
+
+ + {# Биллинг - заглушка #} +
+
+
+
+
+ +
+
+
Биллинг и оплата
+

Просмотр счетов, история платежей и управление подпиской

+
+
+
+ +
+
+ + {# Приглашения - заглушка #} +
+
+
+
+
+ +
+
+
История приглашений
+

Просмотр отправленных и отклонённых приглашений

+
+
+
+ +
+
+ + {# Настройки безопасности - заглушка #} +
+
+
+
+
+ +
+
+
Безопасность
+

Настройки безопасности, двухфакторная аутентификация, логи

+
+
+
+ +
+
+
+
+ + +{% endblock %} +// app/Views/dashboard/index.twig +{# app/Views/dashboard/index.twig #} +{% extends 'layouts/base.twig' %} + +{% block content %} +
+ +
+
+

{% if current_org %}Добро пожаловать в {{ current_org.name }}!{% else %}Добро пожаловать!{% endif %}

+

Ваш личный кабинет для управления бизнесом.

+
+
+ +
+ +
+
+
+ +
Клиенты
+

Управление клиентами

+ Открыть +
+
+
+ +
+
+
+ +
CRM
+

Управление сделками

+ Открыть +
+
+
+ +
+
+
+ +
Booking
+

Запись на приём

+ Скоро +
+
+
+ +
+
+
+ +
Proof
+

Согласование файлов

+ Скоро +
+
+
+ +
+
+
+ +
Tasks
+

Задачи и проекты

+ Скоро +
+
+
+
+
+{% endblock %} +// app/Views/welcome_message.php + + + + + Welcome to CodeIgniter 4! + + + + + + + + +
+ +
+ +
+

About this page

+

The page you are looking at is being generated dynamically by CodeIgniter.

+

If you would like to edit this page you will find it located at:

+
app/Views/welcome_message.php
+

The corresponding controller for this page can be found at:

+
app/Controllers/Home.php
+
+
+ + + + + + + +// app/Views/landing/index.twig +{# app/Views/Landing/index.twig #} + + + + + Бизнес.Точка - Автоматизация для бизнеса + + + + + +
+ + +
+

Всё для вашего бизнеса

+

CRM, Запись клиентов, Задачи и Согласование файлов в одном месте.

+ +
+
+ + + +// app/Views/auth/register_success.twig +{% extends 'layouts/public.twig' %} + +{% block content %} +
+
+
+
+ +
+ +

Регистрация успешна!

+ +

+ Мы отправили письмо с ссылкой для подтверждения email на вашу почту. +

+ +
+ + Что делать дальше: +
    +
  1. Откройте почтовый ящик
  2. +
  3. Найдите письмо от нас
  4. +
  5. Нажмите на ссылку для подтверждения
  6. +
+
+ + + +
+ + + На главную + +
+
+
+{% endblock %} + +// app/Views/auth/resend_verification.twig +{% extends 'layouts/public.twig' %} + +{% block content %} +
+
+
+
+ +

Повторная отправка письма

+

Введите ваш email для получения новой ссылки подтверждения

+
+ + {% from 'macros/forms.twig' import form_open, form_close %} + + {{ form_open(base_url('/auth/resend-verification')) }} + +
+ + + {% if errors.email is defined %} +
{{ errors.email }}
+ {% endif %} +
+ +
+ + + На главную + +
+ + {{ form_close() }} + +
+
+
+{% endblock %} + +// app/Views/auth/reset_password.twig +{% extends 'layouts/public.twig' %} + +{% block title %}Сброс пароля - {{ parent() }}{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Сброс пароля +

+
+
+ {% if error %} +
+ + {{ error }} +
+ + {% else %} +

+ Введите новый пароль для учётной записи {{ email }} +

+ +
+ {{ csrf_field()|raw }} + + + +
+ + +
Минимум 6 символов
+
+ +
+ + +
+ +
+ + После смены пароля вы будете автоматически разлогинены на всех устройствах. +
+ +
+ +
+
+ {% endif %} +
+ +
+
+
+
+{% endblock %} + +// app/Views/auth/forgot_password.twig +{% extends 'layouts/public.twig' %} + +{% block title %}Восстановление пароля - {{ parent() }}{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Восстановление пароля +

+
+
+ {% if success %} +
+ + {{ success }} +
+ + {% else %} +

+ Введите email, на который зарегистрирована ваша учётная запись. + Мы отправим вам ссылку для сброса пароля. +

+ + {% if error %} +
+ + {{ error }} +
+ {% endif %} + +
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ +
+
+ {% endif %} +
+ +
+
+
+
+{% endblock %} + +// app/Views/auth/register.twig +{% extends 'layouts/public.twig' %} + +{% block content %} + +
+
+

Регистрация

+ + {# ИСПОЛЬЗУЕМ МАКРОС form_open. CSRF добавится АВТОМАТИЧЕСКИ #} + {{ form_open(base_url('/register'), 'class="needs-validation"') }} + +
+ + +
+
+ + +
+
+ + +
+ + + {# ЗАКРЫВАЕМ ФОРМУ МАКРОСОМ #} + {{ form_close() }} + +
+ Уже есть аккаунт? Войти +
+
+
+ +{% endblock %} +// app/Views/auth/verify_error.twig +{% extends 'layouts/public.twig' %} + +{% block content %} +
+
+
+
+ +
+ +

Ошибка подтверждения

+ +
+ {{ message|default('Недействительная ссылка для подтверждения.') }} +
+ + + +
+ + +
+
+
+{% endblock %} + +// app/Views/auth/verify_success.twig +{% extends 'layouts/public.twig' %} + +{% block content %} +
+
+
+
+ +
+ +

Email подтверждён!

+ +

+ {% if name %} + {{ name }}, спасибо за подтверждение. + {% else %} + Спасибо за подтверждение. + {% endif %} +

+ +

+ Теперь вы можете войти в систему и начать работу. +

+ + +
+
+
+{% endblock %} + +// app/Views/auth/login.twig +{% extends 'layouts/public.twig' %} + +{% block content %} +
+
+
+ +

Бизнес.Точка

+

Вход в систему

+
+ + {{ form_open(base_url('/login'), 'class="needs-validation"') }} + {{ csrf_field()|raw }} + + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + +
+
+ + +
+ +
+ + +
+ + + + {{ form_close() }} + + +
+ Нет аккаунта? Зарегистрироваться +
+
+
+ +{% 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 %} + + + {% 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 %} +
+ {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} +
+{% endif %} + +// app/Views/components/calendar/calendar.twig +{# + calendar.twig - Универсальный компонент календаря + + Параметры: + - events: Массив событий + Пример: + events: [ + { + id: 1, + title: 'Событие 1', + date: '2026-01-15', + color: '#3B82F6', + url: '/path/to/event' + } + ] + - currentMonth: Текущий месяц в формате YYYY-MM + - prevMonth: URL или параметр для предыдущего месяца + - nextMonth: URL или параметр для следующего месяца + - eventComponent: Имя Twig компонента для рендеринга событий (опционально) + - onEventClick: JavaScript функция при клике на событие (опционально) + - showLegend: Показывать легенду (опционально, по умолчанию true) + - legend: Массив для легенды (опционально) + Пример: + legend: [ + { name: 'Этап 1', color: '#3B82F6' } + ] +#} +{# Навигация по месяцам #} +{% if showNavigation|default(true) %} + +{% endif %} + +{# Календарь #} +
+
+
+ {# Дни недели #} +
+ {% 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) %} +
+ {% for action in actions %} + + {% if action.icon %}{% endif %} + + {% endfor %} +
+{% 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 }} +
+ + {# Кнопки навигации - посередине #} + + + {# Выбор количества записей - справа #} +
+ + +
+
+// app/Views/components/table/table_header.twig +{# + table_header.twig - Переиспользуемый шаблон заголовка таблицы + + Параметры: + - columns: Ассоциативный массив столбцов ['name' => ['label' => 'Name', 'width' => '40%']] + - sort: Текущий столбец сортировки + - order: Направление сортировки (asc/desc) + - filters: Текущие значения фильтров +#} + + + {% for columnKey, column in columns %} + +
+ {# Поле поиска - первым, для правильного позиционирования #} + + + {# Иконка поиска #} + + + + {# Текст заголовка #} + + {{ column.label }} + + + {# Иконка сортировки #} + +
+ + {% endfor %} + + {# Колонка действий (опционально) #} + {% if actions is defined and actions %} + + {{ actions.label|default('Действия') }} + + {% endif %} + + +// app/Views/components/table/README.md +# DataTable Components + +Переиспользуемые компоненты для отображения интерактивных таблиц с AJAX-загрузкой, сортировкой и поиском. + +## Структура компонентов + +``` +public/ +├── js/ +│ └── modules/ +│ └── DataTable.js # JS-модуль для инициализации таблиц +└── css/ + └── components/ + └── data-table.css # Стили для интерактивных таблиц + +app/Views/components/table/ +├── table.twig # Основной компонент таблицы +├── table_header.twig # Переиспользуемый заголовок +└── pagination.twig # Компонент пагинации +``` + +## Быстрый старт + +### 1. Подключение стилей и скриптов + +В вашем шаблоне добавьте: + +```twig +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + +{% endblock %} +``` + +### 2. Использование компонента таблицы + +```twig +{{ include('@components/table/table.twig', { + id: 'products-table', + url: '/products/table', + perPage: 25, + sort: sort|default(''), + order: order|default('asc'), + filters: filters|default({}), + columns: [ + { name: 'name', label: 'Название', width: '35%' }, + { name: 'sku', label: 'Артикул', width: '15%' }, + { name: 'price', label: 'Цена', width: '15%' }, + { name: 'stock', label: 'Остаток', width: '15%' } + ], + actions: { label: 'Действия', width: '20%' }, + emptyMessage: 'Товары не найдены' +}) }} +``` + +## Конфигурация столбцов + +Каждый столбец поддерживает следующие параметры: + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `name` | string | Идентификатор столбца (используется для сортировки и фильтрации) | +| `label` | string | Отображаемое название столбца | +| `width` | string | Ширина столбца (например, '35%', '200px') | +| `placeholder` | string | Текст-подсказка в поле поиска | +| `searchTitle` | string | Title для иконки поиска | +| `align` | string | CSS-класс выравнивания | + +## Конфигурация пагинации + +Компонент автоматически получает данные из объекта `pager`: + +```php +// В контроллере +$pagination = [ + 'currentPage' => $pager->getCurrentPage(), + 'totalPages' => $pager->getPageCount(), + 'totalRecords' => $pager->getTotal(), + 'perPage' => $perPage, + 'from' => (($pager->getCurrentPage() - 1) * $perPage + 1), + 'to' => min($pager->getCurrentPage() * $perPage, $pager->getTotal()) +]; +``` + +## Пример контроллера + +```php +public function table() +{ + $page = (int) ($this->request->getGet('page') ?? 1); + $perPage = (int) ($this->request->getGet('perPage') ?? 10); + $sort = $this->request->getGet('sort') ?? ''; + $order = $this->request->getGet('order') ?? 'asc'; + + // Фильтры + $filters = [ + 'name' => $this->request->getGet('filters[name]') ?? '', + ]; + + // Построение запроса + $builder = $this->model->builder(); + + // Применяем фильтры + if (!empty($filters['name'])) { + $builder->like('name', $filters['name']); + } + + // Сортировка + if (!empty($sort)) { + $builder->orderBy($sort, $order); + } + + // Пагинация + $items = $builder->paginate($perPage, 'default', $page); + + $data = [ + 'items' => $items, + 'pager' => $this->model->pager, + 'perPage' => $perPage, + 'sort' => $sort, + 'order' => $order, + 'filters' => $filters, + ]; + + return $this->renderTwig('path/to/your/_table', $data); +} +``` + +## AJAX-ответ + +Для AJAX-запросов контроллер должен возвращать только `tbody` и `tfoot`: + +```twig +{# _table.twig для модуля #} +{% set isAjax = app.request.headers.get('X-Requested-With') == 'XMLHttpRequest' %} + +{% if isAjax %} + {# AJAX: только tbody #} + + {% for item in items %} + + {{ item.name }} + {{ item.price }} + + Редактировать + + + {% endfor %} + + + {% if items is not empty and pager %} + + + + {{ include('@components/table/pagination.twig', { + pagination: paginationData, + id: 'your-table-id' + }) }} + + + + {% endif %} +{% else %} + {# Обычный запрос: полная таблица #} +
+ {{ include('@components/table/table.twig', { + id: 'your-table-id', + url: '/your-module/table', + perPage: perPage, + columns: columns, + pagination: paginationData, + actions: { label: 'Действия' }, + emptyMessage: 'Нет данных' + }) }} +
+{% 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 %} + + {% endfor %} + + {# Колонка действий #} + {% if actionsConfig is defined and actionsConfig|length > 0 %} + + {% endif %} + + {% endfor %} + {% else %} + {# Пустое состояние #} + + + + {% endif %} + + + {# Футер с пагинацией #} + + + + + +
+ {{ render_cell(item, key, column)|raw }} + + {# Фильтруем действия на основе прав доступа #} + {% 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 %} +
+ {% if emptyIcon is defined and emptyIcon %} +
+ +
+ {% endif %} +

{{ emptyMessage|default('Нет данных') }}

+ {% if emptyActionUrl is defined and emptyActionUrl %} + + {% if emptyActionIcon is defined and emptyActionIcon %} + + {% endif %} + {{ emptyActionLabel|default('Добавить') }} + + {% 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 %} + + {% endif %} + + {% endfor %} + {% else %} + {# Пустое состояние #} + + + {% if emptyIcon is defined and emptyIcon %} +
+ +
+ {% endif %} +

{{ emptyMessage|default('Нет данных') }}

+ {% if emptyActionUrl is defined and emptyActionUrl %} + + {% if emptyActionIcon is defined and emptyActionIcon %} + + {% endif %} + {{ emptyActionLabel|default('Добавить') }} + + {% endif %} + + + {% endif %} + + +{# Футер с пагинацией #} + + + + {{ include('@components/table/pagination.twig', { + pagination: pagerDetails, + id: id + }) }} + + + + +// app/Views/components/kanban/kanban.twig +{# + kanban.twig - Универсальный компонент Канбан-доски + + Параметры: + - columns: Массив колонок с данными + Пример: + columns: [ + { + id: 1, + name: 'Колонка 1', + color: '#3B82F6', + items: [...], + total: 1000, + itemLabel: 'сделка' (опционально, для грамматики) + } + ] + - cardComponent: Имя Twig компонента для рендеринга карточек (опционально) + - moveUrl: URL для API перемещения элементов (опционально) + - onMove: JavaScript функция callback при перемещении (опционально) + - emptyMessage: Сообщение при отсутствии элементов (опционально) + - addUrl: URL для добавления нового элемента (опционально) + - addLabel: Текст кнопки добавления (опционально) +#} +
+
+ {% for column in columns %} +
+ {# Заголовок колонки #} +
+
+
+
{{ column.name }}
+ {{ column.items|length }} +
+ {% if column.total is defined %} + + ₽{{ column.total|number_format(0, ',', ' ') }} + + {% endif %} +
+
+ + {# Карточки #} +
+ {% if column.items is defined and column.items|length > 0 %} + {% for item in column.items %} + {% if cardComponent is defined %} + {{ include(cardComponent, {item: item, column: column}) }} + {% else %} + {{ include('@components/kanban/default_card.twig', {item: item, column: column}) }} + {% endif %} + {% endfor %} + {% endif %} +
+ + {# Кнопка добавления #} + {% if addUrl is defined or column.addUrl is defined %} + + + {{ addLabel|default('Добавить') }} + + {% endif %} +
+ {% endfor %} +
+
+ + + +// 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 защиты #} +
+ {{ csrf_field()|raw }} +{% endmacro %} + +{% macro form_close() %} +
+{% 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}

+

Нажмите кнопку ниже, чтобы принять или отклонить приглашение:

+

+ Принять приглашение +

+

Если кнопка не работает, скопируйте ссылку и откройте в браузере:

+ +

Ссылка действительна 7 дней.

+
+ +
+ + +HTML; + $emailService->setMessage($message); + return $emailService->send(); + } +} + +// app/Services/RateLimitService.php +redis = $redis; + $this->cache = $cache; + $this->identifier = $identifier; + $this->prefix = $prefix; + $this->config = array_merge([ + 'auth_login_attempts' => 5, + 'auth_login_window' => 900, + 'auth_login_block' => 900, + 'auth_register_attempts' => 10, + 'auth_register_window' => 3600, + 'auth_register_block' => 3600, + 'auth_reset_attempts' => 5, + 'auth_reset_window' => 900, + 'auth_reset_block' => 900, + 'api_read_attempts' => 100, + 'api_read_window' => 60, + 'api_write_attempts' => 30, + 'api_write_window' => 60, + ], $config); + } + /** + public static function getInstance(): self + { + $config = self::getConfig(); + $redis = self::getRedisConnection(); + $cache = self::getCache(); + $identifier = self::getIdentifier(); + $prefix = $config['prefix'] ?? 'rl:'; + return new self($redis, $cache, $identifier, $prefix, $config); + } + /** + private static function getConfig(): array + { + return [ + 'prefix' => env('rate_limit.prefix', 'rl:'), + 'auth_login_attempts' => (int) env('rate_limit.auth.login.attempts', 5), + 'auth_login_window' => (int) env('rate_limit.auth.login.window', 900), + 'auth_login_block' => (int) env('rate_limit.auth.login.block', 900), + 'auth_register_attempts' => (int) env('rate_limit.auth.register.attempts', 10), + 'auth_register_window' => (int) env('rate_limit.auth.register.window', 3600), + 'auth_register_block' => (int) env('rate_limit.auth.register.block', 3600), + 'auth_reset_attempts' => (int) env('rate_limit.auth.reset.attempts', 5), + 'auth_reset_window' => (int) env('rate_limit.auth.reset.window', 900), + 'auth_reset_block' => (int) env('rate_limit.auth.reset.block', 900), + 'api_read_attempts' => (int) env('rate_limit.api.read.attempts', 100), + 'api_read_window' => (int) env('rate_limit.api.read.window', 60), + 'api_write_attempts' => (int) env('rate_limit.api.write.attempts', 30), + 'api_write_window' => (int) env('rate_limit.api.write.window', 60), + ]; + } + /** + private static function getRedisConnection(): ?Redis + { + $redis = new \Redis(); + $host = env('redis.host', '127.0.0.1'); + $port = (int) env('redis.port', 6379); + $password = env('redis.password', ''); + $database = (int) env('redis.database', 0); + $timeout = (float) env('redis.timeout', 2.0); + $readTimeout = (float) env('redis.read_timeout', 60.0); + try { + if (!$redis->connect($host, $port, $timeout)) { + log_message('warning', "RateLimitService: Не удалось подключиться к Redis ({$host}:{$port})"); + return null; + } + if (!empty($password)) { + if (!$redis->auth($password)) { + log_message('warning', 'RateLimitService: Ошибка аутентификации в Redis'); + return null; + } + } + $redis->select($database); + $redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout); + return $redis; + } catch (\Exception $e) { + log_message('warning', 'RateLimitService: Исключение при подключении к Redis - ' . $e->getMessage()); + return null; + } + } + /** + private static function getCache(): CacheInterface + { + $cache = cache(); + if (!$cache instanceof CacheInterface) { + throw new RuntimeException('RateLimitService: Кэш-сервис не инициализирован. Проверьте конфигурацию app/Config/Cache.php'); + } + return $cache; + } + /** + private static function getIdentifier(): RateLimitIdentifier + { + return new RateLimitIdentifier(); + } + /** + public function isRedisAvailable(): bool + { + return $this->redis !== null; + } + /** + private function getKey(string $action, string $suffix = ''): string + { + $identifier = $this->identifier->getIdentifier($action); + $key = "{$this->prefix}{$identifier}"; + if (!empty($suffix)) { + $key .= ":{$suffix}"; + } + return $key; + } + /** + private function get(string $key): string|false + { + if ($this->redis !== null) { + try { + return $this->redis->get($key) ?: false; + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (get): ' . $e->getMessage()); + $this->redis = null; + } + } + return $this->cache->get($key) ?: false; + } + /** + private function set(string $key, string $value, int $ttl): bool + { + if ($this->redis !== null) { + try { + if ($ttl > 0) { + return $this->redis->setex($key, $ttl, $value); + } + return $this->redis->set($key, $value); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (set): ' . $e->getMessage()); + $this->redis = null; + } + } + return $this->cache->save($key, $value, $ttl); + } + /** + private function incr(string $key): int|false + { + if ($this->redis !== null) { + try { + return $this->redis->incr($key); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (incr): ' . $e->getMessage()); + $this->redis = null; + } + } + $current = (int) $this->cache->get($key); + $newValue = $current + 1; + $this->cache->save($key, (string) $newValue, 3600); + return $newValue; + } + /** + private function delete(string $key): bool + { + if ($this->redis !== null) { + try { + return (bool) $this->redis->del($key); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (del): ' . $e->getMessage()); + $this->redis = null; + } + } + return $this->cache->delete($key); + } + /** + private function exists(string $key): bool + { + if ($this->redis !== null) { + try { + return (bool) $this->redis->exists($key); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (exists): ' . $e->getMessage()); + $this->redis = null; + } + } + return $this->cache->get($key) !== null; + } + /** + private function ttl(string $key): int + { + if ($this->redis !== null) { + try { + $ttl = $this->redis->ttl($key); + return $ttl !== false ? (int) $ttl : -1; + } catch (\Exception $e) { + log_message('warning', 'RateLimitService Redis error (ttl): ' . $e->getMessage()); + $this->redis = null; + } + } + return -1; + } + /** + public function isBlocked(string $type): bool + { + $blockKey = $this->getKey($type, 'block'); + return $this->exists($blockKey); + } + /** + public function getBlockTimeLeft(string $type): int + { + $blockKey = $this->getKey($type, 'block'); + $ttl = $this->ttl($blockKey); + return max(0, $ttl); + } + /** + public function checkAttempt(string $type): array + { + if ($this->isBlocked($type)) { + return [ + 'allowed' => false, + 'attempts' => 0, + 'limit' => $this->config["auth_{$type}_attempts"] ?? 0, + 'remaining' => 0, + 'blocked' => true, + 'block_ttl' => $this->getBlockTimeLeft($type), + ]; + } + $attemptsKey = $this->getKey($type, 'attempts'); + $window = $this->config["auth_{$type}_window"] ?? 900; + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + $currentAttempts = (int) $this->get($attemptsKey); + $remaining = max(0, $maxAttempts - $currentAttempts); + return [ + 'allowed' => true, + 'attempts' => $currentAttempts, + 'limit' => $maxAttempts, + 'remaining' => $remaining, + 'blocked' => false, + 'block_ttl' => 0, + ]; + } + /** + public function recordFailedAttempt(string $type): array + { + $attemptsKey = $this->getKey($type, 'attempts'); + $window = $this->config["auth_{$type}_window"] ?? 900; + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + $attempts = $this->incr($attemptsKey); + if ($attempts === 1) { + $this->set($attemptsKey, (string) $attempts, $window); + } + if ($attempts >= $maxAttempts) { + $blockTtl = $this->config["auth_{$type}_block"] ?? $window; + $blockKey = $this->getKey($type, 'block'); + $this->set($blockKey, '1', $blockTtl); + return [ + 'allowed' => false, + 'attempts' => $attempts, + 'limit' => $maxAttempts, + 'remaining' => 0, + 'blocked' => true, + 'block_ttl' => $blockTtl, + ]; + } + return [ + 'allowed' => true, + 'attempts' => $attempts, + 'limit' => $maxAttempts, + 'remaining' => $maxAttempts - $attempts, + 'blocked' => false, + 'block_ttl' => 0, + ]; + } + /** + public function resetAttempts(string $type): void + { + $attemptsKey = $this->getKey($type, 'attempts'); + $this->delete($attemptsKey); + } + /** + public function checkApiReadLimit(): array + { + return $this->checkApiLimit('read'); + } + /** + public function checkApiWriteLimit(): array + { + return $this->checkApiLimit('write'); + } + /** + private function checkApiLimit(string $type): array + { + $key = $this->getKey("api_{$type}"); + $maxAttempts = $this->config["api_{$type}_attempts"] ?? 60; + $window = $this->config["api_{$type}_window"] ?? 60; + $current = (int) $this->get($key); + $ttl = $this->ttl($key); + if ($ttl < 0) { + $this->set($key, '1', $window); + return [ + 'allowed' => true, + 'remaining' => $maxAttempts - 1, + 'reset' => $window, + ]; + } + if ($current >= $maxAttempts) { + return [ + 'allowed' => false, + 'remaining' => 0, + 'reset' => max(0, $ttl), + ]; + } + $this->incr($key); + return [ + 'allowed' => true, + 'remaining' => $maxAttempts - $current - 1, + 'reset' => max(0, $ttl), + ]; + } + /** + public function getStatus(string $type): array + { + $attemptsKey = $this->getKey($type, 'attempts'); + $blockKey = $this->getKey($type, 'block'); + $attempts = (int) $this->get($attemptsKey); + $attemptsTtl = $this->ttl($attemptsKey); + $isBlocked = $this->exists($blockKey); + $blockTtl = $isBlocked ? $this->ttl($blockKey) : 0; + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + $window = $this->config["auth_{$type}_window"] ?? 900; + return [ + 'identifier' => $this->identifier->getIdentifier($type), + 'type' => $type, + 'attempts' => $attempts, + 'attempts_ttl' => max(0, $attemptsTtl), + 'limit' => $maxAttempts, + 'window' => $window, + 'is_blocked' => $isBlocked, + 'block_ttl' => max(0, $blockTtl), + 'redis_available' => $this->isRedisAvailable(), + ]; + } + /** + public function ensureToken(): ?string + { + return $this->identifier->ensureToken(); + } + /** + public function getJsScript(): string + { + return $this->identifier->getJsScript(); + } + /** + public function isConnected(): bool + { + if ($this->redis === null) { + return false; + } + try { + return $this->redis->ping() === true || $this->redis->ping() === '+PONG'; + } catch (\Exception $e) { + return false; + } + } +} + +// app/Services/AccessService.php + 100, + self::ROLE_ADMIN => 75, + self::ROLE_MANAGER => 50, + self::ROLE_GUEST => 25, + ]; + /** + public const PERMISSION_VIEW = 'view'; + public const PERMISSION_CREATE = 'create'; + public const PERMISSION_EDIT = 'edit'; + public const PERMISSION_DELETE = 'delete'; + public const PERMISSION_DELETE_ANY = 'delete_any'; + public const PERMISSION_MANAGE_USERS = 'manage_users'; + public const PERMISSION_MANAGE_MODULES = 'manage_modules'; + public const PERMISSION_VIEW_FINANCE = 'view_finance'; + public const PERMISSION_DELETE_ORG = 'delete_org'; + public const PERMISSION_TRANSFER_OWNER = 'transfer_owner'; + /** + private const ROLE_PERMISSIONS = [ + self::ROLE_OWNER => [ + '*' => ['*'], + ], + self::ROLE_ADMIN => [ + 'clients' => ['view', 'create', 'edit', 'delete'], + 'deals' => ['view', 'create', 'edit', 'delete'], + 'bookings' => ['view', 'create', 'edit', 'delete'], + 'projects' => ['view', 'create', 'edit', 'delete'], + 'tasks' => ['view', 'create', 'edit', 'delete'], + 'users' => [self::PERMISSION_VIEW, self::PERMISSION_CREATE, self::PERMISSION_EDIT, self::PERMISSION_DELETE], + self::PERMISSION_MANAGE_MODULES => [self::PERMISSION_VIEW, self::PERMISSION_EDIT], + self::PERMISSION_VIEW_FINANCE => ['*'], + ], + self::ROLE_MANAGER => [ + 'clients' => ['view', 'create', 'edit', 'delete'], + 'deals' => ['view', 'create', 'edit', 'delete'], + 'bookings' => ['view', 'create', 'edit', 'delete'], + 'projects' => ['view', 'create', 'edit', 'delete'], + 'tasks' => ['view', 'create', 'edit', 'delete'], + 'users' => [self::PERMISSION_VIEW], + ], + self::ROLE_GUEST => [ + 'clients' => [self::PERMISSION_VIEW], + 'deals' => [self::PERMISSION_VIEW], + 'bookings' => [self::PERMISSION_VIEW], + 'projects' => [self::PERMISSION_VIEW], + 'tasks' => [self::PERMISSION_VIEW], + 'users' => [self::PERMISSION_VIEW], + ], + ]; + private ?string $cachedSystemRole = null; + public function __construct() + { + $this->orgUserModel = new OrganizationUserModel(); + } + /** + public static function getInstance(): self + { + return new self(); + } + /** + public function getCurrentMembership(): ?array + { + if ($this->currentMembership !== null) { + return $this->currentMembership; + } + $userId = session()->get('user_id'); + $orgId = session()->get('active_org_id'); + if (!$userId || !$orgId) { + return null; + } + $this->currentMembership = $this->orgUserModel + ->where('user_id', $userId) + ->where('organization_id', $orgId) + ->first(); + return $this->currentMembership; + } + /** + public function getCurrentRole(): ?string + { + $membership = $this->getCurrentMembership(); + return $membership['role'] ?? null; + } + /** + public function isAuthenticated(): bool + { + return $this->getCurrentMembership() !== null; + } + /** + public function isRole($roles): bool + { + $currentRole = $this->getCurrentRole(); + if ($currentRole === null) { + return false; + } + $roles = (array) $roles; + return in_array($currentRole, $roles, true); + } + /** + public function isOwner(): bool + { + return $this->getCurrentRole() === self::ROLE_OWNER; + } + /** + public function isAdmin(): bool + { + $role = $this->getCurrentRole(); + return $role === self::ROLE_ADMIN || $role === self::ROLE_OWNER; + } + /** + public function isManagerOrHigher(): bool + { + $role = $this->getCurrentRole(); + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true); + } + /** + public function getSystemRole(): ?string + { + if ($this->cachedSystemRole !== null) { + return $this->cachedSystemRole; + } + $userId = session()->get('user_id'); + if (!$userId) { + return null; + } + $userModel = new \App\Models\UserModel(); + $user = $userModel->find($userId); + $this->cachedSystemRole = $user['system_role'] ?? null; + return $this->cachedSystemRole; + } + /** + public function isSystemRole($roles): bool + { + $currentRole = $this->getSystemRole(); + if ($currentRole === null) { + return false; + } + $roles = (array) $roles; + return in_array($currentRole, $roles, true); + } + /** + public function isSuperadmin(): bool + { + return $this->getSystemRole() === self::SYSTEM_ROLE_SUPERADMIN; + } + /** + public function isSystemAdmin(): bool + { + $role = $this->getSystemRole(); + return in_array($role, [self::SYSTEM_ROLE_ADMIN, self::SYSTEM_ROLE_SUPERADMIN], true); + } + /** + public function resetSystemRoleCache(): void + { + $this->cachedSystemRole = null; + } + /** + public function can(string $action, string $resource): bool + { + $role = $this->getCurrentRole(); + if ($role === null) { + return false; + } + $permissions = self::ROLE_PERMISSIONS[$role] ?? []; + if (isset($permissions['*']) && in_array('*', $permissions['*'], true)) { + return true; + } + if (!isset($permissions[$resource])) { + return false; + } + $resourcePermissions = $permissions[$resource]; + if (in_array('*', $resourcePermissions, true)) { + return true; + } + return in_array($action, $resourcePermissions, true); + } + /** + public function canView(string $resource): bool + { + return $this->can(self::PERMISSION_VIEW, $resource); + } + /** + public function canCreate(string $resource): bool + { + return $this->can(self::PERMISSION_CREATE, $resource); + } + /** + public function canEdit(string $resource): bool + { + return $this->can(self::PERMISSION_EDIT, $resource); + } + /** + public function canDelete(string $resource, bool $any = false): bool + { + $action = $any ? self::PERMISSION_DELETE_ANY : self::PERMISSION_DELETE; + return $this->can($action, $resource); + } + /** + public function canManageUsers(): bool + { + $orgId = session()->get('active_org_id'); + if (!$orgId) { + return false; + } + $orgModel = new \App\Models\OrganizationModel(); + $organization = $orgModel->find($orgId); + if ($organization && $organization['type'] === 'personal') { + return false; + } + return $this->can(self::PERMISSION_MANAGE_USERS, 'users'); + } + /** + public function canManageModules(): bool + { + return $this->can(self::PERMISSION_MANAGE_MODULES, self::PERMISSION_MANAGE_MODULES); + } + /** + public function canViewFinance(): bool + { + return $this->can(self::PERMISSION_VIEW_FINANCE, self::PERMISSION_VIEW_FINANCE); + } + /** + public function canDeleteOrganization(): bool + { + return $this->isOwner(); + } + /** + public function canTransferOwnership(): bool + { + return $this->isOwner(); + } + /** + public function getRolesEligibleForOwnershipTransfer(): array + { + return [self::ROLE_ADMIN]; + } + /** + public function getRoleLevel(string $role): int + { + return self::ROLE_HIERARCHY[$role] ?? 0; + } + /** + public function hasRoleLevel(string $role, string $requiredRole): bool + { + return $this->getRoleLevel($role) >= $this->getRoleLevel($requiredRole); + } + /** + public function getAvailableRolesForAssignment(string $assignerRole): array + { + $allRoles = [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST]; + if ($assignerRole === self::ROLE_OWNER) { + return $allRoles; + } + if ($assignerRole === self::ROLE_ADMIN) { + return [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST]; + } + return []; + } + /** + public function resetCache(): void + { + $this->currentMembership = null; + $this->cachedSystemRole = null; + } + /** + public function getRoleLabel(string $role): string + { + $labels = [ + self::ROLE_OWNER => 'Владелец', + self::ROLE_ADMIN => 'Администратор', + self::ROLE_MANAGER => 'Менеджер', + self::ROLE_GUEST => 'Гость', + ]; + return $labels[$role] ?? $role; + } + /** + public static function getAllRoles(): array + { + return [ + self::ROLE_OWNER => [ + 'label' => 'Владелец', + 'description' => 'Полный доступ к организации', + 'level' => 100, + ], + self::ROLE_ADMIN => [ + 'label' => 'Администратор', + 'description' => 'Управление пользователями и модулями', + 'level' => 75, + ], + self::ROLE_MANAGER => [ + 'label' => 'Менеджер', + 'description' => 'Полный доступ к функционалу модулей', + 'level' => 50, + ], + self::ROLE_GUEST => [ + 'label' => 'Гость', + 'description' => 'Только просмотр данных', + 'level' => 25, + ], + ]; + } + /** + public static function getAllSystemRoles(): array + { + return [ + self::SYSTEM_ROLE_USER => [ + 'label' => 'Пользователь', + 'description' => 'Обычный пользователь системы', + ], + self::SYSTEM_ROLE_ADMIN => [ + 'label' => 'Администратор', + 'description' => 'Администратор системы', + ], + self::SYSTEM_ROLE_SUPERADMIN => [ + 'label' => 'Суперадмин', + 'description' => 'Полный доступ ко всем функциям системы', + ], + ]; + } +} + +// app/Services/ModuleSubscriptionService.php + [ + 'name' => 'Базовый модуль', + 'description' => 'Основные функции', + 'price_monthly' => 0, + 'price_yearly' => 0, + 'trial_days' => 0, + ], + 'crm' => [ + 'name' => 'CRM', + 'description' => 'Управление клиентами и сделками', + 'price_monthly' => 990, + 'price_yearly' => 9900, + 'trial_days' => 14, + ], + 'booking' => [ + 'name' => 'Бронирования', + 'description' => 'Управление бронированиями', + 'price_monthly' => 1490, + 'price_yearly' => 14900, + 'trial_days' => 14, + ], + 'tasks' => [ + 'name' => 'Задачи', + 'description' => 'Управление задачами', + 'price_monthly' => 790, + 'price_yearly' => 7900, + 'trial_days' => 14, + ], + 'proof' => [ + 'name' => 'Proof', + 'description' => 'Согласование документов', + 'price_monthly' => 590, + 'price_yearly' => 5900, + 'trial_days' => 14, + ], + ]; + protected $db; + protected string $moduleSettingsTable = 'module_settings'; + protected string $subscriptionsTable = 'organization_subscriptions'; + public function __construct() + { + $this->db = \Config\Database::connect(); + } + /** + public function getModuleConfig(string $moduleCode): ?array + { + return $this->modulesConfig[$moduleCode] ?? null; + } + /** + public function getAllModules(): array + { + $modules = $this->modulesConfig; + $builder = $this->db->table($this->moduleSettingsTable); + $settings = $builder->get()->getResultArray(); + foreach ($settings as $setting) { + $code = $setting['module_code']; + if (isset($modules[$code])) { + if (!empty($setting['name'])) { + $modules[$code]['name'] = $setting['name']; + } + if (isset($setting['description']) && $setting['description'] !== '') { + $modules[$code]['description'] = $setting['description']; + } + if (isset($setting['price_monthly'])) { + $modules[$code]['price_monthly'] = (int) $setting['price_monthly']; + } + if (isset($setting['price_yearly'])) { + $modules[$code]['price_yearly'] = (int) $setting['price_yearly']; + } + if (isset($setting['trial_days'])) { + $modules[$code]['trial_days'] = (int) $setting['trial_days']; + } + } + } + return $modules; + } + /** + public function isModuleActive(string $moduleCode, ?int $organizationId = null): bool + { + if ($moduleCode === 'base') { + return true; + } + $orgId = $organizationId ?? session()->get('active_org_id'); + if (!$orgId) { + return false; + } + $subscription = $this->getSubscription($orgId, $moduleCode); + if (!$subscription) { + return false; + } + return $subscription['status'] === 'active'; + } + /** + public function isModuleAvailable(string $moduleCode, ?int $organizationId = null): bool + { + if ($moduleCode === 'base') { + return true; + } + $orgId = $organizationId ?? session()->get('active_org_id'); + if (!$orgId) { + return false; + } + $subscription = $this->getSubscription($orgId, $moduleCode); + if (!$subscription) { + return false; + } + return in_array($subscription['status'], ['active', 'trial'], true); + } + /** + public function getSubscription(int $organizationId, string $moduleCode): ?array + { + $builder = $this->db->table($this->subscriptionsTable); + return $builder->where('organization_id', $organizationId) + ->where('module_code', $moduleCode) + ->get() + ->getRowArray(); + } + /** + public function getOrganizationSubscriptions(int $organizationId): array + { + $builder = $this->db->table($this->subscriptionsTable); + return $builder->where('organization_id', $organizationId) + ->orderBy('created_at', 'DESC') + ->get() + ->getResultArray(); + } + /** + public function getActiveModules(int $organizationId): array + { + $builder = $this->db->table($this->subscriptionsTable); + $subscriptions = $builder->where('organization_id', $organizationId) + ->whereIn('status', ['active', 'trial']) + ->get() + ->getResultArray(); + $modules = array_column($subscriptions, 'module_code'); + $modules[] = 'base'; + return array_unique($modules); + } + /** + public function upsertSubscription( + int $organizationId, + string $moduleCode, + string $status = 'active', + ?int $days = null + ): bool { + $existing = $this->getSubscription($organizationId, $moduleCode); + $data = [ + 'organization_id' => $organizationId, + 'module_code' => $moduleCode, + 'status' => $status, + 'expires_at' => $days > 0 ? date('Y-m-d H:i:s', strtotime("+{$days} days")) : null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($existing) { + return $this->db->table($this->subscriptionsTable) + ->where('id', $existing['id']) + ->update($data); + } + $data['created_at'] = date('Y-m-d H:i:s'); + return $this->db->table($this->subscriptionsTable)->insert($data); + } + /** + public function deleteSubscription(int $subscriptionId): bool + { + return $this->db->table($this->subscriptionsTable) + ->where('id', $subscriptionId) + ->delete(); + } + /** + public function startTrial(int $organizationId, string $moduleCode, int $trialDays = 14): bool + { + $config = $this->getModuleConfig($moduleCode); + if (!$config || $config['trial_days'] <= 0) { + return false; + } + return $this->upsertSubscription( + $organizationId, + $moduleCode, + 'trial', + $trialDays + ); + } + /** + public function activate(int $organizationId, string $moduleCode, int $months = 1): bool + { + return $this->upsertSubscription( + $organizationId, + $moduleCode, + 'active', + $months * 30 + ); + } + /** + public function cancel(int $organizationId, string $moduleCode): bool + { + $existing = $this->getSubscription($organizationId, $moduleCode); + if (!$existing) { + return false; + } + return $this->db->table($this->subscriptionsTable) + ->where('id', $existing['id']) + ->update(['status' => 'cancelled', 'updated_at' => date('Y-m-d H:i:s')]); + } + /** + public function getAllSubscriptions(): array + { + $builder = $this->db->table($this->subscriptionsTable); + return $builder + ->select('organization_subscriptions.*, organizations.name as organization_name') + ->join('organizations', 'organizations.id = organization_subscriptions.organization_id') + ->orderBy('organization_subscriptions.created_at', 'DESC') + ->get() + ->getResultArray(); + } + /** + public function getModuleStats(): array + { + $stats = []; + $modules = $this->getAllModules(); + foreach ($modules as $code => $module) { + $activeCount = $this->db->table($this->subscriptionsTable) + ->where('module_code', $code) + ->where('status', 'active') + ->countAllResults(); + $trialCount = $this->db->table($this->subscriptionsTable) + ->where('module_code', $code) + ->where('status', 'trial') + ->countAllResults(); + $stats[$code] = [ + 'name' => $module['name'], + 'active' => $activeCount, + 'trial' => $trialCount, + ]; + } + return $stats; + } + /** + public function saveModuleSettings( + string $moduleCode, + ?string $name = null, + ?string $description = null, + ?int $priceMonthly = null, + ?int $priceYearly = null, + ?int $trialDays = null + ): bool { + $existing = $this->db->table($this->moduleSettingsTable) + ->where('module_code', $moduleCode) + ->get() + ->getRowArray(); + $data = [ + 'module_code' => $moduleCode, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($name !== null) { + $data['name'] = $name; + } + if ($description !== null) { + $data['description'] = $description; + } + if ($priceMonthly !== null) { + $data['price_monthly'] = $priceMonthly; + } + if ($priceYearly !== null) { + $data['price_yearly'] = $priceYearly; + } + if ($trialDays !== null) { + $data['trial_days'] = $trialDays; + } + if ($existing) { + return $this->db->table($this->moduleSettingsTable) + ->where('id', $existing['id']) + ->update($data); + } + $data['created_at'] = date('Y-m-d H:i:s'); + $data['is_active'] = 1; + return $this->db->table($this->moduleSettingsTable)->insert($data); + } + /** + public function getModuleSettings(string $moduleCode): ?array + { + return $this->db->table($this->moduleSettingsTable) + ->where('module_code', $moduleCode) + ->get() + ->getRowArray(); + } +} + +// app/Models/UserModel.php +update($userId, [ + 'reset_token' => $token, + 'reset_expires_at' => $expiresAt, + ]); + return $token; + } + /** + public function verifyResetToken(string $token): ?array + { + $user = $this->where('reset_token', $token)->first(); + if (!$user) { + return null; + } + if (empty($user['reset_expires_at'])) { + return null; + } + if (strtotime($user['reset_expires_at']) < time()) { + return null; + } + return $user; + } + /** + public function clearResetToken(int $userId): bool + { + return $this->update($userId, [ + 'reset_token' => null, + 'reset_expires_at' => null, + ]); + } + /** + public function findByEmail(string $email): ?array + { + return $this->where('email', $email)->first(); + } +} +// app/Models/OrganizationSubscriptionModel.php +getSubscription($organizationId, $moduleCode); + if (!$subscription) { + return false; + } + return $subscription['status'] === 'active'; + } + /** + public function getSubscription(int $organizationId, string $moduleCode): ?array + { + return $this->where('organization_id', $organizationId) + ->where('module_code', $moduleCode) + ->first(); + } + /** + public function isInTrial(int $organizationId, string $moduleCode): bool + { + $subscription = $this->getSubscription($organizationId, $moduleCode); + if (!$subscription) { + return false; + } + return $subscription['status'] === 'trial'; + } + /** + public function getDaysUntilExpire(int $organizationId, string $moduleCode): ?int + { + $subscription = $this->getSubscription($organizationId, $moduleCode); + if (!$subscription || empty($subscription['expires_at'])) { + return null; + } + $expiresAt = new \DateTime($subscription['expires_at']); + $now = new \DateTime(); + $diff = $expiresAt->diff($now); + if ($expiresAt < $now) { + return 0; + } + return $diff->days; + } + /** + public function getActiveModules(int $organizationId): array + { + $subscriptions = $this->where('organization_id', $organizationId) + ->whereIn('status', ['active', 'trial']) + ->findAll(); + return array_column($subscriptions, 'module_code'); + } + /** + public function startTrial(int $organizationId, string $moduleCode, int $trialDays): bool + { + $existing = $this->getSubscription($organizationId, $moduleCode); + $expiresAt = new \DateTime(); + $expiresAt->modify("+{$trialDays} days"); + $data = [ + 'organization_id' => $organizationId, + 'module_code' => $moduleCode, + 'status' => 'trial', + 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), + 'created_at' => date('Y-m-d H:i:s'), + ]; + if ($existing) { + return $this->update($existing['id'], $data); + } + return (bool) $this->insert($data); + } + /** + public function activate(int $organizationId, string $moduleCode, int $months = 1): bool + { + $existing = $this->getSubscription($organizationId, $moduleCode); + $expiresAt = new \DateTime(); + $expiresAt->modify("+{$months} months"); + $data = [ + 'organization_id' => $organizationId, + 'module_code' => $moduleCode, + 'status' => 'active', + 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), + ]; + if ($existing) { + return $this->update($existing['id'], $data); + } + $data['created_at'] = date('Y-m-d H:i:s'); + return (bool) $this->insert($data); + } + /** + public function cancel(int $organizationId, string $moduleCode): bool + { + $existing = $this->getSubscription($organizationId, $moduleCode); + if (!$existing) { + return false; + } + return $this->update($existing['id'], ['status' => 'cancelled']); + } +} + +// app/Models/Traits/TenantScopedModel.php +get('active_org_id'); + if (empty($orgId)) { + return $this->where('1=0'); + } + $field = $this->table . '.organization_id'; + return $this->where($field, $orgId); + } + /** + public function belongsToCurrentOrg(int $id): bool + { + return $this->forCurrentOrg()->find($id) !== null; + } +} + +// app/Models/OrganizationModel.php +db(); + $builder = $db->newQuery(); + return $builder->select('o.*, ou.role, ou.status as membership_status, ou.joined_at') + ->from('organizations o') + ->join('organization_users ou', 'ou.organization_id = o.id', 'inner') + ->where('ou.user_id', $userId) + ->where('ou.status', 'active') + ->where('o.deleted_at', null) + ->orderBy('ou.joined_at', 'ASC') + ->get() + ->getResultArray(); + } +} +// app/Models/ModuleSettingsModel.php +where('module_code', $moduleCode)->first(); + } + /** + public function upsert(string $moduleCode, array $data): bool + { + $existing = $this->getByModuleCode($moduleCode); + $data['module_code'] = $moduleCode; + $data['updated_at'] = date('Y-m-d H:i:s'); + if ($existing) { + return $this->update($existing['id'], $data); + } + $data['created_at'] = date('Y-m-d H:i:s'); + return $this->insert($data); + } + /** + public function getAllActive(): array + { + return $this->where('is_active', 1)->findAll(); + } +} + +// app/Models/.gitkeep + +// app/Models/OrganizationUserModel.php +where('invite_token', $token) + ->where('status', self::STATUS_PENDING) + ->where('invite_expires_at >', date('Y-m-d H:i:s')) + ->first(); + } + /** + public function getOrganizationUsers(int $organizationId): array + { + $db = $this->db(); + $builder = $db->newQuery(); + return $builder->select('ou.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar') + ->from('organization_users ou') + ->join('users u', 'u.id = ou.user_id', 'left') + ->where('ou.organization_id', $organizationId) + ->orderBy('ou.joined_at', 'DESC') + ->get() + ->getResultArray(); + } + /** + public function hasPendingInvite(int $organizationId, int $userId): bool + { + return $this->where('organization_id', $organizationId) + ->where('user_id', $userId) + ->where('status', self::STATUS_PENDING) + ->countAllResults() > 0; + } + /** + public function getPendingInvites(int $organizationId): array + { + return $this->where('organization_id', $organizationId) + ->where('status', self::STATUS_PENDING) + ->orderBy('invited_at', 'DESC') + ->findAll(); + } + /** + public function createInvitation(array $data): int + { + $data['invited_at'] = date('Y-m-d H:i:s'); + return $this->insert($data); + } + /** + public function acceptInvitation(int $id, int $userId): bool + { + return $this->update($id, [ + 'status' => self::STATUS_ACTIVE, + 'invite_token' => null, + 'joined_at' => date('Y-m-d H:i:s'), + ]); + } + /** + public function declineInvitation(int $id): bool + { + return $this->delete($id); + } + /** + public function cancelInvitation(int $id): bool + { + return $this->delete($id); + } + /** + public function updateRole(int $id, string $role): bool + { + return $this->update($id, ['role' => $role]); + } + /** + public function blockUser(int $id): bool + { + return $this->update($id, ['status' => self::STATUS_BLOCKED]); + } + /** + public function unblockUser(int $id): bool + { + return $this->update($id, ['status' => self::STATUS_ACTIVE]); + } +} + +// app/Database/Seeds/SetSystemRoleSeeder.php +parseArg('email'); + $role = $this->parseArg('role') ?? self::ROLE_SUPERADMIN; + if (empty($email)) { + echo "Ошибка: Не указан email пользователя\n"; + echo "\n"; + echo "Использование:\n"; + echo " php spark db:seed \"App\Database\Seeds\SetSystemRoleSeeder\" -email=admin@example.com -role=superadmin\n"; + echo "\n"; + echo "Доступные роли: user, admin, superadmin\n"; + echo "По умолчанию: superadmin\n"; + return; + } + $this->assignRole($email, $role); + } + /** + protected function parseArg(string $name): ?string + { + global $argv; + foreach ($argv as $arg) { + if (preg_match("/^-{$name}=(.+)$/", $arg, $matches)) { + return $matches[1]; + } + } + return null; + } + /** + public function assignRole(string $email, string $role): bool + { + $userModel = new UserModel(); + $user = $userModel->where('email', $email)->first(); + if (!$user) { + echo "Ошибка: Пользователь с email '{$email}' не найден в базе данных\n"; + return false; + } + echo "DEBUG: Found user ID = " . $user['id'] . "\n"; + echo "DEBUG: Current system_role = " . ($user['system_role'] ?? 'NULL') . "\n"; + $validRoles = [self::ROLE_USER, self::ROLE_ADMIN, self::ROLE_SUPERADMIN]; + if (!in_array($role, $validRoles)) { + echo "Ошибка: Неизвестная роль '{$role}'. Доступные роли: " . implode(', ', $validRoles) . "\n"; + return false; + } + $db = \Config\Database::connect(); + $result = $db->table('users') + ->where('id', $user['id']) + ->set('system_role', $role) + ->update(); + if (!$result) { + echo "Ошибка обновления\n"; + return false; + } + $updatedUser = $userModel->find($user['id']); + echo "DEBUG: New system_role = " . ($updatedUser['system_role'] ?? 'NULL') . "\n"; + $roleLabels = [ + self::ROLE_USER => 'Пользователь', + self::ROLE_ADMIN => 'Администратор', + self::ROLE_SUPERADMIN => 'Суперадмин', + ]; + $roleLabel = $roleLabels[$role] ?? $role; + echo "Успех!\n"; + echo " Email: {$email}\n"; + echo " User ID: {$user['id']}\n"; + echo " Назначенная роль: {$roleLabel}\n"; + return true; + } +} +// app/Database/Seeds/.gitkeep + +// app/Database/Migrations/2026-01-19-100004_CreateTaskAssigneesTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'role' => [ + 'type' => 'ENUM', + 'constraint' => ['assignee', 'watcher'], + 'default' => 'assignee', + ], + 'assigned_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['task_id', 'user_id']); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_assignees'); + } + public function down() + { + $this->forge->dropTable('task_assignees'); + } +} + +// app/Database/Migrations/2026-01-07-053357_CreateUsersTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'unique' => true, + ], + 'password' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + ], + 'avatar' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('users'); + } + public function down() + { + $this->forge->dropTable('users'); + } +} +// app/Database/Migrations/2026-01-16-233001_AddUpdatedAtToSubscriptions.php +forge->addColumn('organization_subscriptions', [ + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + } + public function down() + { + $this->forge->dropColumn('organization_subscriptions', 'updated_at'); + } +} + +// app/Database/Migrations/2026-01-13-163701_AddTrialEndsAtToSubscriptions.php +forge->addColumn('organization_subscriptions', [ + 'trial_ends_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'status', + 'comment' => 'Дата окончания триального периода', + ], + ]); + $this->db->query(" + UPDATE organization_subscriptions + SET trial_ends_at = DATE_ADD(created_at, INTERVAL 14 DAY) + WHERE status = 'trial' AND trial_ends_at IS NULL + "); + } + public function down() + { + $this->forge->dropColumn('organization_subscriptions', 'trial_ends_at'); + } +} + +// app/Database/Migrations/2026-01-08-000001_AddEmailVerificationToUsers.php + [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'comment' => 'Токен для подтверждения email', + ], + 'email_verified' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'comment' => 'Статус подтверждения email (0 - не подтвержден, 1 - подтвержден)', + ], + 'verified_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'comment' => 'Дата и время подтверждения email', + ], + ]; + $this->forge->addColumn('users', $fields); + } + public function down() + { + $this->forge->dropColumn('users', ['verification_token', 'email_verified', 'verified_at']); + } +} + +// app/Database/Migrations/2026-01-16-210001_DropOrganizationPlanSubscriptionsTable.php +forge->dropTable('organization_plan_subscriptions', true); + } + public function down() + { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'plan_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['trial', 'active', 'expired', 'cancelled'], + 'default' => 'trial', + ], + 'trial_ends_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['organization_id', 'plan_id']); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('organization_plan_subscriptions'); + } +} + +// app/Database/Migrations/2026-01-15-000001_AddPlansTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => false, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'price' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'currency' => [ + 'type' => 'VARCHAR', + 'constraint' => 3, + 'default' => 'RUB', + ], + 'billing_period' => [ + 'type' => 'ENUM', + 'constraint' => ['monthly', 'yearly', 'quarterly'], + 'default' => 'monthly', + ], + 'max_users' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 5, + ], + 'max_clients' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 100, + ], + 'max_storage' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 10, + ], + 'features' => [ + 'type' => 'JSON', + 'null' => true, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'default' => 1, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['name']); + $this->forge->createTable('plans'); + $seedData = [ + [ + 'name' => 'Бесплатный', + 'description' => 'Базовый тариф для небольших команд', + 'price' => 0, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 3, + 'max_clients' => 50, + 'max_storage' => 5, + 'features' => json_encode([ + 'Базовые модули', + 'Email поддержка', + 'Экспорт в CSV', + ]), + 'is_active' => 1, + 'is_default' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'Старт', + 'description' => 'Тариф для растущих компаний', + 'price' => 990, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 10, + 'max_clients' => 500, + 'max_storage' => 50, + 'features' => json_encode([ + 'Все модули', + 'Приоритетная поддержка', + 'Экспорт в PDF и Excel', + 'API доступ', + ]), + 'is_active' => 1, + 'is_default' => 0, + 'created_at' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'Бизнес', + 'description' => 'Полный функционал для крупных компаний', + 'price' => 4990, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 50, + 'max_clients' => 5000, + 'max_storage' => 500, + 'features' => json_encode([ + 'Все модули', + 'Персональный менеджер', + 'Экспорт в PDF и Excel', + 'Полный API доступ', + 'Интеграции', + 'Брендинг', + ]), + 'is_active' => 1, + 'is_default' => 0, + 'created_at' => date('Y-m-d H:i:s'), + ], + ]; + $this->db->table('plans')->insertBatch($seedData); + } + public function down() + { + $this->forge->dropTable('plans'); + } +} + +// app/Database/Migrations/2026-01-08-200001_CreateOrganizationsClientsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('organizations_clients'); + } + public function down() + { + $this->forge->dropTable('organizations_clients'); + } +} + +// app/Database/Migrations/2026-01-15-000005_AddStatusToOrganizations.php +db->getFieldData('organizations'); + $existingFields = array_column($fields, 'name'); + if (!in_array('status', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organizations ADD COLUMN status ENUM('active', 'blocked') NOT NULL DEFAULT 'active' AFTER settings"); + } + } + public function down() + { + $fields = $this->db->getFieldData('organizations'); + $existingFields = array_column($fields, 'name'); + if (in_array('status', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organizations DROP COLUMN status"); + } + } +} + +// app/Database/Migrations/2026-01-19-100002_CreateTaskColumnsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'board_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'color' => [ + 'type' => 'VARCHAR', + 'constraint' => 7, + 'default' => '#6B7280', + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('board_id', 'task_boards', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_columns'); + } + public function down() + { + $this->forge->dropTable('task_columns'); + } +} + +// app/Database/Migrations/2026-01-13-200002_AddPasswordResetFieldsToUsers.php + [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'verified_at', + ], + 'reset_expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'reset_token', + ], + ]; + $this->forge->addColumn('users', $fields); + } + public function down() + { + $this->forge->dropColumn('users', ['reset_token', 'reset_expires_at']); + } +} + +// app/Database/Migrations/2026-01-07-053413_CreateOrganizationSubscriptionsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'module_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['trial', 'active', 'expired', 'cancelled'], + 'default' => 'trial', + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['organization_id', 'module_code']); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('organization_subscriptions'); + } + public function down() + { + $this->forge->dropTable('organization_subscriptions'); + } +} +// app/Database/Migrations/2026-01-15-000006_CreateDealsTables.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'color' => [ + 'type' => 'VARCHAR', + 'constraint' => 7, + 'default' => '#6B7280', + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['progress', 'won', 'lost'], + 'default' => 'progress', + ], + 'probability' => [ + 'type' => 'INT', + 'constraint' => 3, + 'unsigned' => true, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('deal_stages'); + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'contact_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'company_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '15,2', + 'default' => 0.00, + ], + 'currency' => [ + 'type' => 'CHAR', + 'constraint' => 3, + 'default' => 'RUB', + ], + 'stage_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'assigned_user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'expected_close_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addKey('stage_id'); + $this->forge->addKey('assigned_user_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('stage_id', 'deal_stages', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('assigned_user_id', 'users', 'id', 'SET NULL', 'SET NULL'); + $this->forge->createTable('deals'); + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'deal_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'action' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'field_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'old_value' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'new_value' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('deal_id'); + $this->forge->addKey('user_id'); + $this->forge->addForeignKey('deal_id', 'deals', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('deal_history'); + } + public function down() + { + $this->forge->dropTable('deal_history'); + $this->forge->dropTable('deals'); + $this->forge->dropTable('deal_stages'); + } +} + +// app/Database/Migrations/2026-01-16-220001_CreateModuleSettingsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'module_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'description' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'price_monthly' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'price_yearly' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'trial_days' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('module_code'); + $this->forge->createTable('module_settings'); + } + public function down() + { + $this->forge->dropTable('module_settings', true); + } +} + +// app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php +db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + if (!in_array('invite_token', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_token VARCHAR(64) NULL AFTER role"); + } + if (!in_array('invited_by', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_by INT UNSIGNED NULL AFTER invite_token"); + } + if (!in_array('invited_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_at DATETIME NULL AFTER invited_by"); + } + $indexes = $this->db->getIndexData('organization_users'); + $hasTokenIndex = false; + foreach ($indexes as $index) { + if ($index->name === 'idx_org_users_token') { + $hasTokenIndex = true; + break; + } + } + if (!$hasTokenIndex) { + $this->db->simpleQuery("CREATE INDEX idx_org_users_token ON organization_users(invite_token)"); + } + $this->db->simpleQuery("ALTER TABLE organization_users MODIFY COLUMN status ENUM('active', 'pending', 'invited', 'blocked') NOT NULL DEFAULT 'pending'"); + } + public function down() + { + $fields = $this->db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + $this->db->simpleQuery("DROP INDEX IF EXISTS idx_org_users_token ON organization_users"); + if (in_array('invited_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_at"); + } + if (in_array('invited_by', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_by"); + } + if (in_array('invite_token', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_token"); + } + } +} + +// app/Database/Migrations/2026-01-07-053401_CreateOrganizationsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'owner_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['business', 'personal'], + 'default' => 'business', + ], + 'logo' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'requisites' => [ + 'type' => 'JSON', + 'null' => true, + ], + 'trial_ends_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'settings' => [ + 'type' => 'JSON', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('owner_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('organizations'); + } + public function down() + { + $this->forge->dropTable('organizations'); + } +} +// app/Database/Migrations/2026-01-19-100003_CreateTasksTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'board_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'column_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'priority' => [ + 'type' => 'ENUM', + 'constraint' => ['low', 'medium', 'high', 'urgent'], + 'default' => 'medium', + ], + 'due_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'completed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('tasks'); + } + public function down() + { + $this->forge->dropTable('tasks'); + } +} + +// app/Database/Migrations/2026-01-13-000001_CreateRememberTokensTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'selector' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + ], + 'token_hash' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'user_agent' => [ + 'type' => 'VARCHAR', + 'constraint' => 500, + 'null' => true, + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('user_id'); + $this->forge->addKey('selector'); + $this->forge->addKey('expires_at'); + $this->forge->addForeignKey('user_id', 'users', 'id', false, 'CASCADE'); + $this->forge->createTable('remember_tokens'); + } + public function down() + { + $this->forge->dropTable('remember_tokens'); + } +} + +// app/Database/Migrations/2026-01-15-000003_AddTokenExpiresToUsers.php +db->getFieldData('users'); + $existingFields = array_column($fields, 'name'); + if (!in_array('token_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE users ADD COLUMN token_expires_at DATETIME NULL AFTER verification_token"); + } + } + public function down() + { + $fields = $this->db->getFieldData('users'); + $existingFields = array_column($fields, 'name'); + if (in_array('token_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE users DROP COLUMN token_expires_at"); + } + } +} + +// app/Database/Migrations/2026-01-14-000001_AddSystemRoleToUsers.php + [ + 'type' => 'ENUM', + 'constraint' => ['user', 'admin', 'superadmin'], + 'default' => 'user', + 'after' => 'password', + ], + ]; + $this->forge->addColumn('users', $fields); + } + public function down() + { + $this->forge->dropColumn('users', 'system_role'); + } +} + +// app/Database/Migrations/2026-01-15-000007_CreateContactsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'customer_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'comment' => 'Ссылка на клиента (компанию)', + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'comment' => 'Имя контакта', + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'position' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'comment' => 'Должность', + ], + 'is_primary' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'unsigned' => true, + 'default' => 0, + 'comment' => 'Основной контакт', + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addKey('customer_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('customer_id', 'organizations_clients', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('contacts'); + } + public function down() + { + $this->forge->dropTable('contacts'); + } +} + +// app/Database/Migrations/.gitkeep + +// app/Database/Migrations/2026-01-15-000004_AddInviteExpiresToOrganizationUsers.php +db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + if (!in_array('invite_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_expires_at DATETIME NULL AFTER invited_at"); + } + } + public function down() + { + $fields = $this->db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + if (in_array('invite_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_expires_at"); + } + } +} + +// app/Database/Migrations/2026-01-07-053407_CreateOrganizationUsersTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'role' => [ + 'type' => 'ENUM', + 'constraint' => ['owner', 'admin', 'manager', 'guest'], + 'default' => 'manager', + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['active', 'invited', 'blocked'], + 'default' => 'active', + ], + 'joined_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['organization_id', 'user_id']); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('organization_users'); + } + public function down() + { + $this->forge->dropTable('organization_users'); + } +} +// app/Database/Migrations/2026-01-13-200001_CreateCiSessionsTable.php +forge->addField([ + 'id' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + ], + 'timestamp' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'data' => [ + 'type' => 'BLOB', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('timestamp'); + $this->forge->createTable('ci_sessions'); + } + public function down() + { + $this->forge->dropTable('ci_sessions'); + } +} + +// app/Database/Migrations/2026-01-19-100001_CreateTaskBoardsTable.php +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_boards'); + } + public function down() + { + $this->forge->dropTable('task_boards'); + } +} + +// app/Config/Generators.php + [ + 'class' => 'CodeIgniter\Commands\Generators\Views\cell.tpl.php', + 'view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php', + ], + 'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php', + 'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php', + 'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php', + 'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php', + 'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php', + 'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php', + 'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php', + 'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php', + 'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php', + 'session:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php', + ]; +} + +// app/Config/Cookie.php + WRITEPATH . 'cache/', + 'mode' => 0640, + ]; + /** + public array $memcached = [ + 'host' => '127.0.0.1', + 'port' => 11211, + 'weight' => 1, + 'raw' => false, + ]; + /** + public array $redis = [ + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'timeout' => 0, + 'database' => 0, + ]; + /** + public array $validHandlers = [ + 'dummy' => DummyHandler::class, + 'file' => FileHandler::class, + 'memcached' => MemcachedHandler::class, + 'predis' => PredisHandler::class, + 'redis' => RedisHandler::class, + 'wincache' => WincacheHandler::class, + ]; + /** + public $cacheQueryString = false; +} + +// app/Config/Images.php + GDHandler::class, + 'imagick' => ImageMagickHandler::class, + ]; +} + +// app/Config/ContentSecurityPolicy.php + 'Windows 10', + 'windows nt 6.3' => 'Windows 8.1', + 'windows nt 6.2' => 'Windows 8', + 'windows nt 6.1' => 'Windows 7', + 'windows nt 6.0' => 'Windows Vista', + 'windows nt 5.2' => 'Windows 2003', + 'windows nt 5.1' => 'Windows XP', + 'windows nt 5.0' => 'Windows 2000', + 'windows nt 4.0' => 'Windows NT 4.0', + 'winnt4.0' => 'Windows NT 4.0', + 'winnt 4.0' => 'Windows NT', + 'winnt' => 'Windows NT', + 'windows 98' => 'Windows 98', + 'win98' => 'Windows 98', + 'windows 95' => 'Windows 95', + 'win95' => 'Windows 95', + 'windows phone' => 'Windows Phone', + 'windows' => 'Unknown Windows OS', + 'android' => 'Android', + 'blackberry' => 'BlackBerry', + 'iphone' => 'iOS', + 'ipad' => 'iOS', + 'ipod' => 'iOS', + 'os x' => 'Mac OS X', + 'ppc mac' => 'Power PC Mac', + 'freebsd' => 'FreeBSD', + 'ppc' => 'Macintosh', + 'linux' => 'Linux', + 'debian' => 'Debian', + 'sunos' => 'Sun Solaris', + 'beos' => 'BeOS', + 'apachebench' => 'ApacheBench', + 'aix' => 'AIX', + 'irix' => 'Irix', + 'osf' => 'DEC OSF', + 'hp-ux' => 'HP-UX', + 'netbsd' => 'NetBSD', + 'bsdi' => 'BSDi', + 'openbsd' => 'OpenBSD', + 'gnu' => 'GNU/Linux', + 'unix' => 'Unknown Unix OS', + 'symbian' => 'Symbian OS', + ]; + /** + public array $browsers = [ + 'OPR' => 'Opera', + 'Flock' => 'Flock', + 'Edge' => 'Spartan', + 'Edg' => 'Edge', + 'Chrome' => 'Chrome', + 'Opera.*?Version' => 'Opera', + 'Opera' => 'Opera', + 'MSIE' => 'Internet Explorer', + 'Internet Explorer' => 'Internet Explorer', + 'Trident.* rv' => 'Internet Explorer', + 'Shiira' => 'Shiira', + 'Firefox' => 'Firefox', + 'Chimera' => 'Chimera', + 'Phoenix' => 'Phoenix', + 'Firebird' => 'Firebird', + 'Camino' => 'Camino', + 'Netscape' => 'Netscape', + 'OmniWeb' => 'OmniWeb', + 'Safari' => 'Safari', + 'Mozilla' => 'Mozilla', + 'Konqueror' => 'Konqueror', + 'icab' => 'iCab', + 'Lynx' => 'Lynx', + 'Links' => 'Links', + 'hotjava' => 'HotJava', + 'amaya' => 'Amaya', + 'IBrowse' => 'IBrowse', + 'Maxthon' => 'Maxthon', + 'Ubuntu' => 'Ubuntu Web Browser', + 'Vivaldi' => 'Vivaldi', + ]; + /** + public array $mobiles = [ + 'mobileexplorer' => 'Mobile Explorer', + 'palmsource' => 'Palm', + 'palmscape' => 'Palmscape', + 'motorola' => 'Motorola', + 'nokia' => 'Nokia', + 'palm' => 'Palm', + 'iphone' => 'Apple iPhone', + 'ipad' => 'iPad', + 'ipod' => 'Apple iPod Touch', + 'sony' => 'Sony Ericsson', + 'ericsson' => 'Sony Ericsson', + 'blackberry' => 'BlackBerry', + 'cocoon' => 'O2 Cocoon', + 'blazer' => 'Treo', + 'lg' => 'LG', + 'amoi' => 'Amoi', + 'xda' => 'XDA', + 'mda' => 'MDA', + 'vario' => 'Vario', + 'htc' => 'HTC', + 'samsung' => 'Samsung', + 'sharp' => 'Sharp', + 'sie-' => 'Siemens', + 'alcatel' => 'Alcatel', + 'benq' => 'BenQ', + 'ipaq' => 'HP iPaq', + 'mot-' => 'Motorola', + 'playstation portable' => 'PlayStation Portable', + 'playstation 3' => 'PlayStation 3', + 'playstation vita' => 'PlayStation Vita', + 'hiptop' => 'Danger Hiptop', + 'nec-' => 'NEC', + 'panasonic' => 'Panasonic', + 'philips' => 'Philips', + 'sagem' => 'Sagem', + 'sanyo' => 'Sanyo', + 'spv' => 'SPV', + 'zte' => 'ZTE', + 'sendo' => 'Sendo', + 'nintendo dsi' => 'Nintendo DSi', + 'nintendo ds' => 'Nintendo DS', + 'nintendo 3ds' => 'Nintendo 3DS', + 'wii' => 'Nintendo Wii', + 'open web' => 'Open Web', + 'openweb' => 'OpenWeb', + 'android' => 'Android', + 'symbian' => 'Symbian', + 'SymbianOS' => 'SymbianOS', + 'elaine' => 'Palm', + 'series60' => 'Symbian S60', + 'windows ce' => 'Windows CE', + 'obigo' => 'Obigo', + 'netfront' => 'Netfront Browser', + 'openwave' => 'Openwave Browser', + 'mobilexplorer' => 'Mobile Explorer', + 'operamini' => 'Opera Mini', + 'opera mini' => 'Opera Mini', + 'opera mobi' => 'Opera Mobile', + 'fennec' => 'Firefox Mobile', + 'digital paths' => 'Digital Paths', + 'avantgo' => 'AvantGo', + 'xiino' => 'Xiino', + 'novarra' => 'Novarra Transcoder', + 'vodafone' => 'Vodafone', + 'docomo' => 'NTT DoCoMo', + 'o2' => 'O2', + 'mobile' => 'Generic Mobile', + 'wireless' => 'Generic Mobile', + 'j2me' => 'Generic Mobile', + 'midp' => 'Generic Mobile', + 'cldc' => 'Generic Mobile', + 'up.link' => 'Generic Mobile', + 'up.browser' => 'Generic Mobile', + 'smartphone' => 'Generic Mobile', + 'cellphone' => 'Generic Mobile', + ]; + /** + public array $robots = [ + 'googlebot' => 'Googlebot', + 'msnbot' => 'MSNBot', + 'baiduspider' => 'Baiduspider', + 'bingbot' => 'Bing', + 'slurp' => 'Inktomi Slurp', + 'yahoo' => 'Yahoo', + 'ask jeeves' => 'Ask Jeeves', + 'fastcrawler' => 'FastCrawler', + 'infoseek' => 'InfoSeek Robot 1.0', + 'lycos' => 'Lycos', + 'yandex' => 'YandexBot', + 'mediapartners-google' => 'MediaPartners Google', + 'CRAZYWEBCRAWLER' => 'Crazy Webcrawler', + 'adsbot-google' => 'AdsBot Google', + 'feedfetcher-google' => 'Feedfetcher Google', + 'curious george' => 'Curious George', + 'ia_archiver' => 'Alexa Crawler', + 'MJ12bot' => 'Majestic-12', + 'Uptimebot' => 'Uptimebot', + ]; +} + +// app/Config/Constants.php + APPPATH, + 'App\Modules' => APPPATH . 'Modules', + 'App\Libraries\Twig' => APPPATH . 'Libraries/Twig', + ]; + /** + public $classmap = []; + /** + public $files = []; + /** + public $helpers = []; +} + +// app/Config/Exceptions.php +getMessage()); + return null; + } + } + /** + public static function moduleSubscription(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('moduleSubscription'); + } + return new \App\Services\ModuleSubscriptionService(); + } + /** + public static function eventManager(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('eventManager'); + } + return new EventManager(); + } +} + +// app/Config/Session.php + JSONFormatter::class, + 'application/xml' => XMLFormatter::class, + 'text/xml' => XMLFormatter::class, + ]; + /** + public array $formatterOptions = [ + 'application/json' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + 'application/xml' => 0, + 'text/xml' => 0, + ]; +} + +// app/Config/Cors.php + [], + /** + 'allowedOriginsPatterns' => [], + /** + 'supportsCredentials' => false, + /** + 'allowedHeaders' => [], + /** + 'exposedHeaders' => [], + /** + 'allowedMethods' => [], + /** + 'maxAge' => 7200, + ]; +} + +// app/Config/Honeypot.php +{label}'; + /** + public string $container = '
{template}
'; + /** + public string $containerId = 'hpc'; +} + +// app/Config/Mimes.php + [ + 'application/mac-binhex40', + 'application/mac-binhex', + 'application/x-binhex40', + 'application/x-mac-binhex40', + ], + 'cpt' => 'application/mac-compactpro', + 'csv' => [ + 'text/csv', + 'text/x-comma-separated-values', + 'text/comma-separated-values', + 'application/vnd.ms-excel', + 'application/x-csv', + 'text/x-csv', + 'application/csv', + 'application/excel', + 'application/vnd.msexcel', + 'text/plain', + ], + 'bin' => [ + 'application/macbinary', + 'application/mac-binary', + 'application/octet-stream', + 'application/x-binary', + 'application/x-macbinary', + ], + 'dms' => 'application/octet-stream', + 'lha' => 'application/octet-stream', + 'lzh' => 'application/octet-stream', + 'exe' => [ + 'application/octet-stream', + 'application/vnd.microsoft.portable-executable', + 'application/x-dosexec', + 'application/x-msdownload', + ], + 'class' => 'application/octet-stream', + 'psd' => [ + 'application/x-photoshop', + 'image/vnd.adobe.photoshop', + ], + 'so' => 'application/octet-stream', + 'sea' => 'application/octet-stream', + 'dll' => 'application/octet-stream', + 'oda' => 'application/oda', + 'pdf' => [ + 'application/pdf', + 'application/force-download', + 'application/x-download', + ], + 'ai' => [ + 'application/pdf', + 'application/postscript', + ], + 'eps' => 'application/postscript', + 'ps' => 'application/postscript', + 'smi' => 'application/smil', + 'smil' => 'application/smil', + 'mif' => 'application/vnd.mif', + 'xls' => [ + 'application/vnd.ms-excel', + 'application/msexcel', + 'application/x-msexcel', + 'application/x-ms-excel', + 'application/x-excel', + 'application/x-dos_ms_excel', + 'application/xls', + 'application/x-xls', + 'application/excel', + 'application/download', + 'application/vnd.ms-office', + 'application/msword', + ], + 'ppt' => [ + 'application/vnd.ms-powerpoint', + 'application/powerpoint', + 'application/vnd.ms-office', + 'application/msword', + ], + 'pptx' => [ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ], + 'wbxml' => 'application/wbxml', + 'wmlc' => 'application/wmlc', + 'dcr' => 'application/x-director', + 'dir' => 'application/x-director', + 'dxr' => 'application/x-director', + 'dvi' => 'application/x-dvi', + 'gtar' => 'application/x-gtar', + 'gz' => 'application/x-gzip', + 'gzip' => 'application/x-gzip', + 'php' => [ + 'application/x-php', + 'application/x-httpd-php', + 'application/php', + 'text/php', + 'text/x-php', + 'application/x-httpd-php-source', + ], + 'php4' => 'application/x-httpd-php', + 'php3' => 'application/x-httpd-php', + 'phtml' => 'application/x-httpd-php', + 'phps' => 'application/x-httpd-php-source', + 'js' => [ + 'application/x-javascript', + 'text/plain', + ], + 'swf' => 'application/x-shockwave-flash', + 'sit' => 'application/x-stuffit', + 'tar' => 'application/x-tar', + 'tgz' => [ + 'application/x-tar', + 'application/x-gzip-compressed', + ], + 'z' => 'application/x-compress', + 'xhtml' => 'application/xhtml+xml', + 'xht' => 'application/xhtml+xml', + 'zip' => [ + 'application/x-zip', + 'application/zip', + 'application/x-zip-compressed', + 'application/s-compressed', + 'multipart/x-zip', + ], + 'rar' => [ + 'application/vnd.rar', + 'application/x-rar', + 'application/rar', + 'application/x-rar-compressed', + ], + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mpga' => 'audio/mpeg', + 'mp2' => 'audio/mpeg', + 'mp3' => [ + 'audio/mpeg', + 'audio/mpg', + 'audio/mpeg3', + 'audio/mp3', + ], + 'aif' => [ + 'audio/x-aiff', + 'audio/aiff', + ], + 'aiff' => [ + 'audio/x-aiff', + 'audio/aiff', + ], + 'aifc' => 'audio/x-aiff', + 'ram' => 'audio/x-pn-realaudio', + 'rm' => 'audio/x-pn-realaudio', + 'rpm' => 'audio/x-pn-realaudio-plugin', + 'ra' => 'audio/x-realaudio', + 'rv' => 'video/vnd.rn-realvideo', + 'wav' => [ + 'audio/x-wav', + 'audio/wave', + 'audio/wav', + ], + 'bmp' => [ + 'image/bmp', + 'image/x-bmp', + 'image/x-bitmap', + 'image/x-xbitmap', + 'image/x-win-bitmap', + 'image/x-windows-bmp', + 'image/ms-bmp', + 'image/x-ms-bmp', + 'application/bmp', + 'application/x-bmp', + 'application/x-win-bitmap', + ], + 'gif' => 'image/gif', + 'jpg' => [ + 'image/jpeg', + 'image/pjpeg', + ], + 'jpeg' => [ + 'image/jpeg', + 'image/pjpeg', + ], + 'jpe' => [ + 'image/jpeg', + 'image/pjpeg', + ], + 'jp2' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'j2k' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'jpf' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'jpg2' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'jpx' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'jpm' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'mj2' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'mjp2' => [ + 'image/jp2', + 'video/mj2', + 'image/jpx', + 'image/jpm', + ], + 'png' => [ + 'image/png', + 'image/x-png', + ], + 'webp' => 'image/webp', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'css' => [ + 'text/css', + 'text/plain', + ], + 'html' => [ + 'text/html', + 'text/plain', + ], + 'htm' => [ + 'text/html', + 'text/plain', + ], + 'shtml' => [ + 'text/html', + 'text/plain', + ], + 'txt' => 'text/plain', + 'text' => 'text/plain', + 'log' => [ + 'text/plain', + 'text/x-log', + ], + 'rtx' => 'text/richtext', + 'rtf' => 'text/rtf', + 'xml' => [ + 'application/xml', + 'text/xml', + 'text/plain', + ], + 'xsl' => [ + 'application/xml', + 'text/xsl', + 'text/xml', + ], + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpe' => 'video/mpeg', + 'qt' => 'video/quicktime', + 'mov' => 'video/quicktime', + 'avi' => [ + 'video/x-msvideo', + 'video/msvideo', + 'video/avi', + 'application/x-troff-msvideo', + ], + 'movie' => 'video/x-sgi-movie', + 'doc' => [ + 'application/msword', + 'application/vnd.ms-office', + ], + 'docx' => [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/zip', + 'application/msword', + 'application/x-zip', + ], + 'dot' => [ + 'application/msword', + 'application/vnd.ms-office', + ], + 'dotx' => [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/zip', + 'application/msword', + ], + 'xlsx' => [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/zip', + 'application/vnd.ms-excel', + 'application/msword', + 'application/x-zip', + ], + 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', + 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12', + 'word' => [ + 'application/msword', + 'application/octet-stream', + ], + 'xl' => 'application/excel', + 'eml' => 'message/rfc822', + 'json' => [ + 'application/json', + 'text/json', + ], + 'pem' => [ + 'application/x-x509-user-cert', + 'application/x-pem-file', + 'application/octet-stream', + ], + 'p10' => [ + 'application/x-pkcs10', + 'application/pkcs10', + ], + 'p12' => 'application/x-pkcs12', + 'p7a' => 'application/x-pkcs7-signature', + 'p7c' => [ + 'application/pkcs7-mime', + 'application/x-pkcs7-mime', + ], + 'p7m' => [ + 'application/pkcs7-mime', + 'application/x-pkcs7-mime', + ], + 'p7r' => 'application/x-pkcs7-certreqresp', + 'p7s' => 'application/pkcs7-signature', + 'crt' => [ + 'application/x-x509-ca-cert', + 'application/x-x509-user-cert', + 'application/pkix-cert', + ], + 'crl' => [ + 'application/pkix-crl', + 'application/pkcs-crl', + ], + 'der' => 'application/x-x509-ca-cert', + 'kdb' => 'application/octet-stream', + 'pgp' => 'application/pgp', + 'gpg' => 'application/gpg-keys', + 'sst' => 'application/octet-stream', + 'csr' => 'application/octet-stream', + 'rsa' => 'application/x-pkcs7', + 'cer' => [ + 'application/pkix-cert', + 'application/x-x509-ca-cert', + ], + '3g2' => 'video/3gpp2', + '3gp' => [ + 'video/3gp', + 'video/3gpp', + ], + 'mp4' => 'video/mp4', + 'm4a' => 'audio/x-m4a', + 'f4v' => [ + 'video/mp4', + 'video/x-f4v', + ], + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'aac' => 'audio/x-acc', + 'm4u' => 'application/vnd.mpegurl', + 'm3u' => 'text/plain', + 'xspf' => 'application/xspf+xml', + 'vlc' => 'application/videolan', + 'wmv' => [ + 'video/x-ms-wmv', + 'video/x-ms-asf', + ], + 'au' => 'audio/x-au', + 'ac3' => 'audio/ac3', + 'flac' => 'audio/x-flac', + 'ogg' => [ + 'audio/ogg', + 'video/ogg', + 'application/ogg', + ], + 'kmz' => [ + 'application/vnd.google-earth.kmz', + 'application/zip', + 'application/x-zip', + ], + 'kml' => [ + 'application/vnd.google-earth.kml+xml', + 'application/xml', + 'text/xml', + ], + 'ics' => 'text/calendar', + 'ical' => 'text/calendar', + 'zsh' => 'text/x-scriptzsh', + '7zip' => [ + 'application/x-compressed', + 'application/x-zip-compressed', + 'application/zip', + 'multipart/x-zip', + ], + 'cdr' => [ + 'application/cdr', + 'application/coreldraw', + 'application/x-cdr', + 'application/x-coreldraw', + 'image/cdr', + 'image/x-cdr', + 'zz-application/zz-winassoc-cdr', + ], + 'wma' => [ + 'audio/x-ms-wma', + 'video/x-ms-asf', + ], + 'jar' => [ + 'application/java-archive', + 'application/x-java-application', + 'application/x-jar', + 'application/x-compressed', + ], + 'svg' => [ + 'image/svg+xml', + 'image/svg', + 'application/xml', + 'text/xml', + ], + 'vcf' => 'text/x-vcard', + 'srt' => [ + 'text/srt', + 'text/plain', + ], + 'vtt' => [ + 'text/vtt', + 'text/plain', + ], + 'ico' => [ + 'image/x-icon', + 'image/x-ico', + 'image/vnd.microsoft.icon', + ], + 'stl' => [ + 'application/sla', + 'application/vnd.ms-pki.stl', + 'application/x-navistyle', + 'model/stl', + 'application/octet-stream', + ], + ]; + /** + public static function guessTypeFromExtension(string $extension) + { + $extension = trim(strtolower($extension), '. '); + if (! array_key_exists($extension, static::$mimes)) { + return null; + } + return is_array(static::$mimes[$extension]) ? static::$mimes[$extension][0] : static::$mimes[$extension]; + } + /** + public static function guessExtensionFromType(string $type, ?string $proposedExtension = null) + { + $type = trim(strtolower($type), '. '); + $proposedExtension = trim(strtolower($proposedExtension ?? '')); + if ( + $proposedExtension !== '' + && array_key_exists($proposedExtension, static::$mimes) + && in_array($type, (array) static::$mimes[$proposedExtension], true) + ) { + return $proposedExtension; + } + foreach (static::$mimes as $ext => $types) { + if (in_array($type, (array) $types, true)) { + return $ext; + } + } + return null; + } +} + +// app/Config/Migrations.php + 'App\Views\pager\bootstrap_full', + 'default_simple' => 'CodeIgniter\Pager\Views\default_simple', + 'default_head' => 'CodeIgniter\Pager\Views\default_head', + ]; + /** + public int $perPage = 20; +} + +// app/Config/Feature.php + '*', + FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; +} + +// app/Config/ForeignCharacters.php +get('/', 'Home::index'); +$routes->get('login', 'Auth::login'); +$routes->post('login', 'Auth::login'); +$routes->get('register', 'Auth::register'); +$routes->post('register', 'Auth::register'); +$routes->get('register/success', 'Auth::registerSuccess'); +$routes->get('logout', 'Auth::logout'); +$routes->get('auth/verify/(:any)', 'Auth::verify/$1'); +$routes->get('auth/resend-verification', 'Auth::resendVerification'); +$routes->post('auth/resend-verification', 'Auth::resendVerification'); +$routes->get('forgot-password', 'ForgotPassword::index'); +$routes->post('forgot-password/send', 'ForgotPassword::sendResetLink'); +$routes->get('forgot-password/reset/(:any)', 'ForgotPassword::reset/$1'); +$routes->post('forgot-password/update', 'ForgotPassword::updatePassword'); +$routes->group('invitation', static function ($routes) { + $routes->get('accept/(:any)', 'InvitationController::accept/$1'); + $routes->post('accept/(:any)', 'InvitationController::processAccept'); + $routes->post('decline/(:any)', 'InvitationController::decline/$1'); + $routes->match(['GET', 'POST'], 'complete/(:any)', 'InvitationController::complete/$1'); +}); +$routes->group('', ['filter' => 'auth'], static function ($routes) { + $routes->get('profile', 'Profile::index'); + $routes->get('profile/organizations', 'Profile::organizations'); + $routes->get('profile/security', 'Profile::security'); + $routes->post('profile/update-name', 'Profile::updateName'); + $routes->post('profile/upload-avatar', 'Profile::uploadAvatar'); + $routes->post('profile/change-password', 'Profile::changePassword'); + $routes->post('profile/session/revoke', 'Profile::revokeSession'); + $routes->post('profile/sessions/revoke-all', 'Profile::revokeAllSessions'); + $routes->post('profile/leave-org/(:num)', 'Profile::leaveOrganization/$1'); + $routes->get('organizations', 'Organizations::index'); + $routes->get('organizations/create', 'Organizations::create'); + $routes->post('organizations/create', 'Organizations::create'); + $routes->get('organizations/switch/(:num)', 'Organizations::switch/$1'); +}); +$routes->group('', ['filter' => 'auth'], static function ($routes) { + $routes->group('', ['filter' => 'org'], static function ($routes) { + $routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1'); + $routes->get('organizations/edit/(:num)', 'Organizations::edit/$1'); + $routes->post('organizations/edit/(:num)', 'Organizations::edit/$1'); + $routes->get('organizations/delete/(:num)', 'Organizations::delete/$1'); + $routes->post('organizations/delete/(:num)', 'Organizations::delete/$1'); + $routes->get('organizations/(:num)/users', 'Organizations::users/$1'); + $routes->get('organizations/(:num)/users/table', 'Organizations::usersTable/$1'); + $routes->post('organizations/(:num)/users/invite', 'Organizations::inviteUser/$1'); + $routes->post('organizations/(:num)/users/role', 'Organizations::updateUserRole/$1'); + $routes->post('organizations/(:num)/users/(:num)/block', 'Organizations::blockUser/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/unblock', 'Organizations::unblockUser/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/remove', 'Organizations::removeUser/$1/$2'); + $routes->post('organizations/(:num)/leave', 'Organizations::leaveOrganization/$1'); + $routes->post('organizations/(:num)/users/leave', 'Organizations::leaveOrganization/$1'); + $routes->post('organizations/(:num)/users/(:num)/resend', 'Organizations::resendInvite/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/cancel', 'Organizations::cancelInvite/$1/$2'); + }); +}); +$routes->group('', ['filter' => 'auth'], static function ($routes) { + require_once APPPATH . 'Modules/Clients/Config/Routes.php'; + require_once APPPATH . 'Modules/CRM/Config/Routes.php'; + require_once APPPATH . 'Modules/Tasks/Config/Routes.php'; +}); +$routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) { + $routes->get('/', 'Superadmin::index'); + $routes->get('modules', 'Superadmin::modules'); + $routes->post('modules/update', 'Superadmin::updateModule'); + $routes->get('subscriptions', 'Superadmin::subscriptions'); + $routes->get('subscriptions/table', 'Superadmin::subscriptionsTable'); + $routes->get('subscriptions/create', 'Superadmin::createSubscription'); + $routes->post('subscriptions/store', 'Superadmin::storeSubscription'); + $routes->get('subscriptions/delete/(:num)', 'Superadmin::deleteSubscription/$1'); + $routes->get('organizations/search', 'Superadmin::searchOrganizations'); + $routes->get('organizations', 'Superadmin::organizations'); + $routes->get('organizations/table', 'Superadmin::organizationsTable'); + $routes->get('organizations/view/(:num)', 'Superadmin::viewOrganization/$1'); + $routes->post('organizations/(:num)/add-subscription', 'Superadmin::addOrganizationSubscription/$1'); + $routes->get('organizations/(:num)/remove-subscription/(:num)', 'Superadmin::removeOrganizationSubscription/$1/$2'); + $routes->get('organizations/block/(:num)', 'Superadmin::blockOrganization/$1'); + $routes->get('organizations/unblock/(:num)', 'Superadmin::unblockOrganization/$1'); + $routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1'); + $routes->get('users', 'Superadmin::users'); + $routes->get('users/table', 'Superadmin::usersTable'); + $routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1'); + $routes->get('users/block/(:num)', 'Superadmin::blockUser/$1'); + $routes->get('users/unblock/(:num)', 'Superadmin::unblockUser/$1'); + $routes->get('users/delete/(:num)', 'Superadmin::deleteUser/$1'); + $routes->get('statistics', 'Superadmin::statistics'); +}); + +// app/Config/Database.php + '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => '', + 'DBDriver' => 'MySQLi', + 'DBPrefix' => '', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8mb4', + 'DBCollat' => 'utf8mb4_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 3306, + 'numberNative' => false, + 'foundRows' => false, + 'dateFormat' => [ + 'date' => 'Y-m-d', + 'datetime' => 'Y-m-d H:i:s', + 'time' => 'H:i:s', + ], + ]; + /** + public array $tests = [ + 'DSN' => '', + 'hostname' => '127.0.0.1', + 'username' => '', + 'password' => '', + 'database' => ':memory:', + 'DBDriver' => 'SQLite3', + 'DBPrefix' => 'db_', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => '', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 3306, + 'foreignKeys' => true, + 'busyTimeout' => 1000, + 'synchronous' => null, + 'dateFormat' => [ + 'date' => 'Y-m-d', + 'datetime' => 'Y-m-d H:i:s', + 'time' => 'H:i:s', + ], + ]; + public function __construct() + { + parent::__construct(); + if (ENVIRONMENT === 'testing') { + $this->defaultGroup = 'tests'; + } + } +} + +// app/Config/Paths.php + [ + 'handles' => [ + 'critical', + 'alert', + 'emergency', + 'debug', + 'error', + 'info', + 'notice', + 'warning', + ], + /* + 'fileExtension' => '', + /* + 'filePermissions' => 0644, + /* + 'path' => '', + ], + /* + /* + ]; +} + +// app/Config/Events.php + 0) { + ob_end_flush(); + } + ob_start(static fn ($buffer) => $buffer); + } + /* + if (CI_DEBUG && ! is_cli()) { + Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect'); + service('toolbar')->respond(); + if (ENVIRONMENT === 'development') { + service('routes')->get('__hot-reload', static function (): void { + (new HotReloader())->run(); + }); + } + } +}); +/* + +// app/Config/DocTypes.php + ' ' ' ' ' '', + 'html4-strict' => ' ' ' ' ' ' ' ' ' ' ' ' ' [ + 'name' => 'Базовый модуль', + 'description' => 'Основные функции управления клиентами', + 'price_monthly' => 0, + 'price_yearly' => 0, + 'trial_days' => 0, + 'features' => [ + 'Управление клиентами', + 'Базовая история взаимодействий', + ], + ], + 'crm' => [ + 'name' => 'CRM', + 'description' => 'Полноценная CRM-система с воронками продаж', + 'price_monthly' => 990, + 'price_yearly' => 9900, + 'trial_days' => 14, + 'features' => [ + 'Воронки продаж', + 'Управление контактами', + 'Этапы сделок', + 'Drag-n-drop сортировка', + 'Автоматизация', + ], + ], + 'booking' => [ + 'name' => 'Бронирования', + 'description' => 'Управление бронированиями и расписанием', + 'price_monthly' => 1490, + 'price_yearly' => 14900, + 'trial_days' => 14, + 'features' => [ + 'Календарь бронирований', + 'Управление ресурсами', + 'Уведомления клиентам', + ], + ], + 'tasks' => [ + 'name' => 'Задачи', + 'description' => 'Управление задачами и проектами', + 'price_monthly' => 790, + 'price_yearly' => 7900, + 'trial_days' => 14, + 'features' => [ + 'Доски задач', + 'Назначение ответственных', + 'Сроки и дедлайны', + ], + ], + 'proof' => [ + 'name' => 'Proof', + 'description' => 'Система согласования документов', + 'price_monthly' => 590, + 'price_yearly' => 5900, + 'trial_days' => 14, + 'features' => [ + 'Согласование документов', + 'Комментарии и версии', + 'Утверждение', + ], + ], + ]; + /** + public function exists(string $moduleCode): bool + { + return isset($this->modules[$moduleCode]); + } + /** + public function getModule(string $moduleCode): ?array + { + return $this->modules[$moduleCode] ?? null; + } + /** + public function getPrice(string $moduleCode, string $period = 'monthly'): int + { + $module = $this->getModule($moduleCode); + if (!$module) { + return 0; + } + return $period === 'yearly' ? $module['price_yearly'] : $module['price_monthly']; + } + /** + public function getAllModuleCodes(): array + { + return array_keys($this->modules); + } + /** + public function getPaidModules(): array + { + return array_filter($this->modules, function ($module) { + return $module['trial_days'] > 0; + }); + } +} + +// app/Config/Security.php + CSRF::class, + 'toolbar' => DebugToolbar::class, + 'honeypot' => Honeypot::class, + 'invalidchars' => InvalidChars::class, + 'secureheaders' => SecureHeaders::class, + 'cors' => Cors::class, + 'forcehttps' => ForceHTTPS::class, + 'pagecache' => PageCache::class, + 'performance' => PerformanceMetrics::class, + 'org' => \App\Filters\OrganizationFilter::class, + 'role' => \App\Filters\RoleFilter::class, + 'auth' => \App\Filters\AuthFilter::class, + 'subscription' => \App\Filters\ModuleSubscriptionFilter::class, + ]; + /** + public array $required = [ + 'before' => [ + 'forcehttps', + 'pagecache', + ], + 'after' => [ + 'pagecache', + 'performance', + 'toolbar', + ], + ]; + /** + public array $globals = [ + 'before' => [ + 'csrf', + ], + 'after' => [ + ], + ]; + /** + public array $methods = []; + /** + public array $filters = []; +} + +// app/Config/App.php +]+\z/iu' + | + | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! + | + public string $permittedURIChars = 'a-z 0-9~%.:_\-'; + /** + public string $defaultLocale = 'en'; + /** + public bool $negotiateLocale = false; + /** + public array $supportedLocales = ['en']; + /** + public string $appTimezone = 'UTC'; + /** + public string $charset = 'UTF-8'; + /** + public bool $forceGlobalSecureRequests = false; + /** + public array $proxyIPs = []; + /** + public bool $CSPEnabled = false; +} + +// app/Config/Validation.php + 'CodeIgniter\Validation\Views\list', + 'single' => 'CodeIgniter\Validation\Views\single', + ]; +} + +// app/Config/Modules.php + +
+
+

{{ title }}

+ + Назад + +
+ + {# Сообщения об ошибках #} + {% if errors is defined and errors|length > 0 %} + + {% endif %} + +
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ Отмена + +
+
+
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Modules/CRM/Views/deals/stages.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

Настройка воронки продаж. Перетаскивайте этажи для изменения порядка.

+
+ + К сделкам + +
+ +{# Сообщения #} +{% if success is defined %} + +{% endif %} + +{% if error is defined %} + +{% endif %} + +{# Форма добавления этапа #} +
+
+
Добавить этап
+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{# Список этапов #} +
+
+
Этапы
+ Перетаскивайте строки для изменения порядка +
+
+
+ + + + + + + + + + + + {% for stage in stages %} + + + + + + + + {% endfor %} + +
ЭтапТипВероятностьДействия
+ + +
+ + {{ stage.name }} +
+
+ + {{ stage.type_label }} + + {{ stage.probability }}% + + {% if not stage.is_final %} +
+ {{ csrf_field()|raw }} + + +
+ {% else %} + + {% endif %} +
+
+
+
+ +{# Модальное окно редактирования #} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + +{% endblock %} + +// app/Modules/CRM/Views/deals/calendar_event.twig +{# + calendar_event.twig - Событие календаря для сделки +#} + + {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} + + +// app/Modules/CRM/Views/deals/kanban.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

+ Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽ | + Успешно: {{ stats.won_count }} на {{ stats.won_total|number_format(0, ',', ' ') }} ₽ +

+
+ + Новая сделка + +
+ +{# Переключатель видов #} + +{{ csrf_field()|raw }} +{# Канбан доска #} +{{ include('@components/kanban/kanban.twig', { + columns: kanbanColumns, + moveUrl: site_url('/crm/deals/move-stage'), + addUrl: site_url('/crm/deals/new'), + addLabel: 'Добавить', + cardComponent: '@CRM/deals/kanban_card.twig' +}) }} +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +// app/Modules/CRM/Views/deals/kanban_card.twig +{# + kanban_card.twig - Карточка сделки для Канбана + + Используется как кастомный компонент карточки в kanban.twig +#} +
+
+
+ + {{ item.title }} + + + ₽{{ item.amount|number_format(0, ',', ' ') }} + +
+ + {% if item.contact_name or item.client_name %} + + + {{ item.contact_name|default(item.client_name) }} + + {% endif %} + +
+ {% if item.assigned_user_name %} + + + {{ item.assigned_user_name }} + + {% else %} + + {% endif %} + + {% if item.expected_close_date %} + + + {{ item.expected_close_date|date('d.m') }} + + {% endif %} +
+
+
+ +// app/Modules/CRM/Views/deals/show.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ deal.title }}{% endblock %} + +{% block content %} + + +
+ {# Основная информация #} +
+ {# Заголовок и статус #} +
+
+
+
+ + {{ deal.stage_name|default('Без этапа') }} + +

{{ deal.title }}

+
+
+
{{ deal.amount|number_format(0, ',', ' ') }} {{ deal.currency }}
+ Сумма сделки +
+
+ + {% if deal.description %} +
+
Описание
+

{{ deal.description }}

+
+ {% endif %} + +
+
+ Дата создания: + {{ deal.created_at|date('d.m.Y H:i') }} +
+
+ Ожидаемое закрытие: + + {{ deal.expected_close_date ? deal.expected_close_date|date('d.m.Y') : '—' }} + +
+
+
+
+ + {# История изменений #} +
+
+
История изменений
+
+
+ {% if history is defined and history|length > 0 %} +
+ {% for item in history %} +
+
+ {{ item.user_name|default('С')|slice(0, 2) }} +
+
+
+
+ {{ item.user_name|default('Система') }} + {{ item.created_at|date('d.m.Y H:i') }} +
+
+

+ {{ item.action_label }} + {% if item.change_description %} + — {{ item.change_description }} + {% endif %} +

+
+
+ {% endfor %} +
+ {% else %} +

Нет записей в истории

+ {% endif %} +
+
+
+ + {# Боковая панель #} +
+ {# Клиент #} +
+
+
Клиент
+
+
+ {% if deal.contact_name %} +
+
+ {{ deal.contact_name|slice(0, 2) }} +
+
+ {{ deal.contact_name }} + {% if deal.contact_email %} + {{ deal.contact_email }} + {% endif %} +
+
+ {% elseif deal.client_name %} +
+
+ {{ deal.client_name|slice(0, 2) }} +
+ +
+ {% else %} +

Клиент не указан

+ {% endif %} +
+
+ + {# Ответственный #} +
+
+
Ответственный
+
+
+ {% if deal.assigned_user_name %} +
+
+ {{ deal.assigned_user_name|slice(0, 2) }} +
+
+
{{ deal.assigned_user_name }}
+ {% if deal.assigned_user_email %} + {{ deal.assigned_user_email }} + {% endif %} +
+
+ {% else %} +

Не назначен

+ {% endif %} +
+
+ + {# Вероятность #} + {% if deal.stage_probability is defined and deal.stage_probability > 0 %} +
+
+
Вероятность закрытия
+
+
+
+
+
+
+ {{ deal.stage_probability }}% +
+
+
+ {% endif %} + + {# Действия #} +
+
+ + Редактировать + +
+ {{ csrf_field()|raw }} + + +
+
+
+
+
+{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +// app/Modules/CRM/Views/deals/index.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

+ Всего: {{ items|length }} | + Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽ +

+
+ + Новая сделка + +
+ +{# Переключатель видов #} + + +
+
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + +{% endblock %} + +// app/Modules/CRM/Views/deals/calendar.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

План закрытия сделок

+
+ + Новая сделка + +
+ +{# Переключатель видов #} + + +{# Календарь #} +{{ include('@components/calendar/calendar.twig', { + eventsByDate: eventsByDate, + currentMonth: currentMonth, + monthName: monthName, + daysInMonth: daysInMonth, + firstDayOfWeek: firstDayOfWeek, + today: today, + prevMonth: site_url('/crm/deals/calendar?month=' ~ prevMonth), + nextMonth: site_url('/crm/deals/calendar?month=' ~ nextMonth), + showNavigation: true, + showLegend: true, + legend: calendarLegend, + eventComponent: '@CRM/deals/calendar_event.twig' +}) }} +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +// app/Modules/CRM/Views/contacts/form.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+

{{ title }}

+ + К списку + +
+ + {# Сообщения об ошибках #} + {% if errors is defined and errors|length > 0 %} + + {% endif %} + +
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ +
+ Отмена + +
+
+
+
+
+
+{% endblock %} + +// app/Modules/CRM/Views/contacts/index.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} + + +{# Сообщения #} +{% if session.success %} + +{% endif %} + +{% if session.error %} + +{% endif %} + +
+
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} + +// app/Modules/CRM/Views/dashboard.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

CRM

+

Управление продажами и клиентами

+
+
+ +{# Статистика #} +
+
+
+
+
{{ stats.open_count }}
+
Открытых сделок
+
{{ stats.open_total|number_format(0, ',', ' ') }} ₽
+
+
+
+
+
+
+
{{ stats.won_count }}
+
Успешных сделок
+
{{ stats.won_total|number_format(0, ',', ' ') }} ₽
+
+
+
+
+
+
+
{{ counts.clients }}
+
Клиентов
+
+
+
+
+
+
+
{{ counts.contacts }}
+
Контактов
+
+
+
+
+ +{# Меню #} + +{% endblock %} + +// app/Modules/CRM/Services/DealStageService.php +stageModel = new DealStageModel(); + } + /** + public function createStage(array $data): int + { + $data['order_index'] = $this->stageModel->getNextOrderIndex($data['organization_id']); + return $this->stageModel->insert($data); + } + /** + public function updateStage(int $stageId, array $data): bool + { + return $this->stageModel->update($stageId, $data); + } + /** + public function deleteStage(int $stageId): bool + { + return $this->stageModel->delete($stageId); + } + /** + public function getStage(int $stageId): ?array + { + return $this->stageModel->find($stageId); + } + /** + public function getOrganizationStages(int $organizationId): array + { + return $this->stageModel->getStagesByOrganization($organizationId); + } + /** + public function reorderStages(int $organizationId, array $stageOrders): bool + { + foreach ($stageOrders as $order => $stageId) { + $this->stageModel->update($stageId, ['order_index' => $order]); + } + return true; + } + /** + public function canDeleteStage(int $stageId): bool + { + $dealModel = new DealModel(); + $count = $dealModel->where('stage_id', $stageId) + ->where('deleted_at', null) + ->countAllResults(); + return $count === 0; + } + /** + public function initializeDefaultStages(int $organizationId): array + { + return $this->stageModel->createDefaultStages($organizationId); + } + /** + public function getStagesList(int $organizationId): array + { + return $this->stageModel->getStagesList($organizationId); + } +} + +// app/Modules/CRM/Services/DealService.php +dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + } + /** + public function createDeal(array $data, int $userId): int + { + $data['created_by'] = $userId; + $dealId = $this->dealModel->insert($data); + if ($dealId) { + $deal = $this->dealModel->find($dealId); + Events::trigger('deal.created', [ + 'deal_id' => $dealId, + 'deal' => $deal, + 'user_id' => $userId, + ]); + } + return $dealId; + } + /** + public function updateDeal(int $dealId, array $data, int $userId): bool + { + $oldDeal = $this->dealModel->find($dealId); + if (!$oldDeal) { + return false; + } + $result = $this->dealModel->update($dealId, $data); + if ($result) { + $newDeal = $this->dealModel->find($dealId); + Events::trigger('deal.updated', [ + 'deal_id' => $dealId, + 'old_deal' => $oldDeal, + 'new_deal' => $newDeal, + 'changes' => $data, + 'user_id' => $userId, + ]); + } + return $result; + } + /** + public function changeStage(int $dealId, int $newStageId, int $userId): bool + { + $deal = $this->dealModel->find($dealId); + if (!$deal) { + return false; + } + $newStage = $this->stageModel->find($newStageId); + if (!$newStage) { + return false; + } + $oldStageId = $deal['stage_id']; + $result = $this->dealModel->update($dealId, ['stage_id' => $newStageId]); + if ($result) { + $updatedDeal = $this->dealModel->find($dealId); + Events::trigger('deal.stage_changed', [ + 'deal_id' => $dealId, + 'deal' => $updatedDeal, + 'old_stage_id' => $oldStageId, + 'new_stage_id' => $newStageId, + 'old_stage' => $this->stageModel->find($oldStageId), + 'new_stage' => $newStage, + 'user_id' => $userId, + ]); + } + return $result; + } + /** + public function deleteDeal(int $dealId, int $userId): bool + { + $deal = $this->dealModel->find($dealId); + if (!$deal) { + return false; + } + $result = $this->dealModel->delete($dealId); + if ($result) { + Events::trigger('deal.deleted', [ + 'deal_id' => $dealId, + 'deal' => $deal, + 'user_id' => $userId, + ]); + } + return $result; + } + /** + public function restoreDeal(int $dealId, int $userId): bool + { + return $this->dealModel->delete($dealId, false); + } + /** + public function getDeal(int $dealId): ?array + { + return $this->dealModel->find($dealId); + } + /** + public function getDeals( + int $organizationId, + ?int $stageId = null, + ?int $assignedUserId = null, + ?string $search = null, + ?string $dateFrom = null, + ?string $dateTo = null + ): array { + return $this->dealModel->getDealsByOrganization( + $organizationId, + $stageId, + $assignedUserId, + $search, + $dateFrom, + $dateTo + ); + } + /** + public function getDealsForKanban(int $organizationId): array + { + return $this->dealModel->getDealsGroupedByStage($organizationId); + } + /** + public function getDealsForCalendar(int $organizationId, string $month): array + { + return $this->dealModel->getDealsForCalendar($organizationId, $month); + } + /** + public function getStats(int $organizationId): array + { + return $this->dealModel->getDealStats($organizationId); + } + /** + public function getDealWithJoins(int $dealId, int $organizationId): ?array + { + return $this->dealModel->getWithJoins($dealId, $organizationId); + } +} + +// app/Modules/CRM/Models/ContactModel.php +where('customer_id', $customerId)->findAll(); + } + /** + public function getPrimaryContact(int $customerId): ?object + { + return $this->where('customer_id', $customerId) + ->where('is_primary', true) + ->first(); + } + /** + public function getContactsList(int $organizationId): array + { + $contacts = $this->where('organization_id', $organizationId) + ->orderBy('name', 'ASC') + ->findAll(); + $list = []; + foreach ($contacts as $contact) { + $list[$contact->id] = $contact->name . ($contact->email ? " ({$contact->email})" : ''); + } + return $list; + } +} + +// app/Modules/CRM/Models/DealModel.php +select(' + deals.id, + deals.title, + deals.amount, + deals.currency, + deals.expected_close_date, + deals.created_at, + deals.deleted_at, + ds.name as stage_name, + ds.color as stage_color, + ds.type as stage_type, + ds.probability as stage_probability, + c.name as contact_name, + c.email as contact_email, + oc.name as client_name, + au.name as assigned_user_name, + au.email as assigned_user_email, + cb.name as created_by_name + ') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('users cb', 'deals.created_by = cb.id', 'left') + ->where('deals.organization_id', $organizationId) + ->orderBy('deals.created_at', 'DESC') + ->findAll(); + } + /** + public function getDealsByOrganization( + int $organizationId, + ?int $stageId = null, + ?int $assignedUserId = null, + ?string $search = null, + ?string $dateFrom = null, + ?string $dateTo = null + ): array { + $builder = $this->where('organization_id', $organizationId); + if ($stageId) { + $builder->where('stage_id', $stageId); + } + if ($assignedUserId) { + $builder->where('assigned_user_id', $assignedUserId); + } + if ($search) { + $builder->groupStart() + ->like('title', $search) + ->orLike('description', $search) + ->groupEnd(); + } + if ($dateFrom) { + $builder->where('expected_close_date >=', $dateFrom); + } + if ($dateTo) { + $builder->where('expected_close_date <=', $dateTo); + } + return $builder->orderBy('created_at', 'DESC')->findAll(); + } + /** + public function getDealsGroupedByStage(int $organizationId): array + { + $deals = $this->select('deals.*, ds.name as stage_name, ds.color as stage_color, ds.type as stage_type, au.name as assigned_user_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null) + ->orderBy('ds.order_index', 'ASC') + ->orderBy('deals.created_at', 'DESC') + ->findAll(); + $grouped = []; + foreach ($deals as $deal) { + $stageId = $deal['stage_id'] ?? 0; + if (!isset($grouped[$stageId])) { + $grouped[$stageId] = [ + 'stage_name' => $deal['stage_name'] ?? 'Без этапа', + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + 'stage_type' => $deal['stage_type'] ?? 'progress', + 'deals' => [], + 'total_amount' => 0, + ]; + } + $grouped[$stageId]['deals'][] = $deal; + $grouped[$stageId]['total_amount'] += (float) $deal['amount']; + } + return $grouped; + } + /** + public function getDealsForCalendar(int $organizationId, string $month): array + { + return $this->select('deals.*, ds.color as stage_color, ds.name as stage_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null) + ->where('deals.expected_close_date >=', date('Y-m-01', strtotime($month))) + ->where('deals.expected_close_date <=', date('Y-m-t', strtotime($month))) + ->orderBy('expected_close_date', 'ASC') + ->findAll(); + } + /** + public function getDealStats(int $organizationId): array + { + $openDeals = $this->select('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->whereIn('type', ['progress']); + }) + ->get() + ->getRow(); + $wonDeals = $this->select('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->where('type', 'won'); + }) + ->get() + ->getRow(); + $lostDeals = $this->select('COUNT(*) as count') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->where('type', 'lost'); + }) + ->get() + ->getRow(); + return [ + 'open_count' => $openDeals->count, + 'open_total' => $openDeals->total, + 'won_count' => $wonDeals->count, + 'won_total' => $wonDeals->total, + 'lost_count' => $lostDeals->count, + ]; + } + /** + public function getWithJoins(int $dealId, int $organizationId): ?array + { + return $this->select(' + deals.*, + ds.name as stage_name, + ds.color as stage_color, + ds.type as stage_type, + ds.probability as stage_probability, + c.name as contact_name, + c.email as contact_email, + oc.name as client_name, + au.name as assigned_user_name, + au.email as assigned_user_email + ') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->where('deals.id', $dealId) + ->where('deals.organization_id', $organizationId) + ->first(); + } +} + +// app/Modules/CRM/Models/DealStageModel.php +where('organization_id', $organizationId) + ->orderBy('order_index', 'ASC') + ->findAll(); + } + /** + public function getNextOrderIndex(int $organizationId): int + { + $max = $this->selectMax('order_index') + ->where('organization_id', $organizationId) + ->first(); + return ($max['order_index'] ?? 0) + 1; + } + /** + public function createDefaultStages(int $organizationId): array + { + $defaultStages = [ + [ + 'organization_id' => $organizationId, + 'name' => 'Новый лид', + 'color' => '#6B7280', + 'order_index' => 1, + 'type' => 'progress', + 'probability' => 10, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Квалификация', + 'color' => '#3B82F6', + 'order_index' => 2, + 'type' => 'progress', + 'probability' => 25, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Предложение', + 'color' => '#F59E0B', + 'order_index' => 3, + 'type' => 'progress', + 'probability' => 50, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Переговоры', + 'color' => '#8B5CF6', + 'order_index' => 4, + 'type' => 'progress', + 'probability' => 75, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Успех', + 'color' => '#10B981', + 'order_index' => 5, + 'type' => 'won', + 'probability' => 100, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Провал', + 'color' => '#EF4444', + 'order_index' => 6, + 'type' => 'lost', + 'probability' => 0, + ], + ]; + return $this->insertBatch($defaultStages); + } + /** + public function getStagesList(int $organizationId): array + { + $stages = $this->getStagesByOrganization($organizationId); + $list = []; + foreach ($stages as $stage) { + $list[$stage['id']] = $stage['name']; + } + return $list; + } +} + +// app/Modules/CRM/Config/Routes.php +group('crm', ['filter' => ['org', 'subscription:crm'], 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) { + $routes->get('/', 'DashboardController::index'); + $routes->get('contacts', 'ContactsController::index'); + $routes->get('contacts/table', 'ContactsController::contactsTable'); + $routes->get('contacts/create', 'ContactsController::create'); + $routes->post('contacts', 'ContactsController::store'); + $routes->get('contacts/(:num)/edit', 'ContactsController::edit/$1'); + $routes->post('contacts/(:num)', 'ContactsController::update/$1'); + $routes->get('contacts/(:num)/delete', 'ContactsController::destroy/$1'); + $routes->post('contacts/list/(:num)', 'ContactsController::ajaxList/$1'); + $routes->post('contacts/store', 'ContactsController::ajaxStore'); + $routes->post('contacts/update/(:num)', 'ContactsController::ajaxUpdate/$1'); + $routes->post('contacts/delete/(:num)', 'ContactsController::ajaxDelete/$1'); + $routes->group('deals', static function ($routes) { + $routes->get('/', 'DealsController::index'); + $routes->get('table', 'DealsController::table'); + $routes->get('kanban', 'DealsController::kanban'); + $routes->get('calendar', 'DealsController::calendar'); + $routes->get('new', 'DealsController::create'); + $routes->get('create', 'DealsController::create'); + $routes->post('/', 'DealsController::store'); + $routes->get('(:num)', 'DealsController::show/$1'); + $routes->get('(:num)/edit', 'DealsController::edit/$1'); + $routes->post('(:num)', 'DealsController::update/$1'); + $routes->get('(:num)/delete', 'DealsController::destroy/$1'); + $routes->post('move-stage', 'DealsController::moveStage'); + $routes->get('contacts-by-client', 'DealsController::getContactsByClient'); + $routes->get('stages', 'DealsController::stages'); + $routes->post('stages', 'DealsController::storeStage'); + $routes->post('stages/reorder', 'DealsController::reorderStages'); + $routes->post('stages/(:num)', 'DealsController::updateStage/$1'); + $routes->get('stages/(:num)/delete', 'DealsController::destroyStage/$1'); + }); +}); + +// app/Modules/CRM/Entities/Contact.php + null, + 'organization_id' => null, + 'customer_id' => null, + 'name' => null, + 'email' => null, + 'phone' => null, + 'position' => null, + 'is_primary' => false, + 'notes' => null, + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + ]; + protected $casts = [ + 'id' => 'integer', + 'organization_id' => 'integer', + 'customer_id' => 'integer', + 'name' => 'string', + 'email' => 'string', + 'phone' => 'string', + 'position' => 'string', + 'is_primary' => 'boolean', + 'notes' => 'string', + ]; + /** + public function getCustomer() + { + return model(\App\Modules\Clients\Models\ClientModel::class)->find($this->customer_id); + } + /** + public function getCustomerName(): ?string + { + $customer = $this->getCustomer(); + return $customer ? $customer->name : null; + } +} + +// app/Modules/CRM/Controllers/DealsController.php +dealService = new DealService(); + $this->stageService = new DealStageService(); + $this->dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + $this->contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + /** + public function index() + { + $organizationId = $this->requireActiveOrg(); + return $this->renderTwig('@CRM/deals/index', [ + 'title' => 'Сделки', + 'tableHtml' => $this->renderTable($this->getTableConfig()), + 'stats' => $this->dealService->getStats($organizationId), + ]); + } + /** + public function table(?array $config = null, ?string $pageUrl = null) + { + return parent::table($this->getTableConfig(), '/crm/deals'); + } + /** + protected function getTableConfig(): array + { + $organizationId = $this->getActiveOrgId(); + return [ + 'id' => 'deals-table', + 'url' => '/crm/deals/table', + 'model' => $this->dealModel, + 'columns' => [ + 'title' => [ + 'label' => 'Сделка', + 'width' => '30%', + ], + 'stage_name' => [ + 'label' => 'Этап', + 'width' => '15%', + ], + 'amount' => [ + 'label' => 'Сумма', + 'width' => '15%', + ], + 'client_name' => [ + 'label' => 'Клиент', + 'width' => '20%', + ], + 'expected_close_date' => [ + 'label' => 'Срок', + 'width' => '10%', + ], + ], + 'searchable' => ['title', 'stage_name', 'client_name', 'amount'], + 'sortable' => ['title', 'amount', 'expected_close_date', 'created_at', 'stage_name'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'actions' => ['label' => '', 'width' => '10%'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/crm/deals/{id}', + 'icon' => 'fa-solid fa-eye', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Просмотр', + ], + [ + 'label' => '', + 'url' => '/crm/deals/{id}/edit', + 'icon' => 'fa-solid fa-pen', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Редактировать', + 'type' => 'edit', + ], + ], + 'emptyMessage' => 'Сделок пока нет', + 'emptyIcon' => 'fa-solid fa-file-contract', + 'emptyActionUrl' => '/crm/deals/new', + 'emptyActionLabel' => 'Создать сделку', + 'emptyActionIcon' => 'fa-solid fa-plus', + 'can_edit' => true, + 'can_delete' => true, + 'fieldMap' => [ + 'stage_name' => 'ds.name', + 'client_name' => 'oc.name', + 'amount' => 'deals.amount', + ], + 'scope' => function($builder) use ($organizationId) { + $builder->from('deals') + ->select('deals.id, deals.title, deals.amount, deals.currency, deals.expected_close_date, deals.created_at, deals.deleted_at, ds.name as stage_name, ds.color as stage_color, c.name as contact_name, oc.name as client_name, au.name as assigned_user_name, cb.name as created_by_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('users cb', 'deals.created_by = cb.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null); + }, + ]; + } + /** + public function kanban() + { + $organizationId = $this->requireActiveOrg(); + $stages = $this->stageService->getOrganizationStages($organizationId); + $kanbanData = $this->dealService->getDealsForKanban($organizationId); + $kanbanColumns = []; + foreach ($stages as $stage) { + $stageDeals = $kanbanData[$stage['id']]['deals'] ?? []; + $kanbanColumns[] = [ + 'id' => $stage['id'], + 'name' => $stage['name'], + 'color' => $stage['color'], + 'items' => $stageDeals, + 'total' => $kanbanData[$stage['id']]['total_amount'] ?? 0, + ]; + } + return $this->renderTwig('@CRM/deals/kanban', [ + 'title' => 'Сделки — Канбан', + 'kanbanColumns' => $kanbanColumns, + 'stats' => $this->dealService->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; + $deals = $this->dealService->getDealsForCalendar($organizationId, $month); + $eventsByDate = []; + foreach ($deals as $deal) { + if ($deal['expected_close_date']) { + $dateKey = date('Y-m-d', strtotime($deal['expected_close_date'])); + $eventsByDate[$dateKey][] = [ + 'id' => $deal['id'], + 'title' => $deal['title'], + 'date' => $deal['expected_close_date'], + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + 'url' => '/crm/deals/' . $deal['id'], + ]; + } + } + $stages = $this->stageService->getOrganizationStages($organizationId); + $calendarLegend = array_map(function ($stage) { + return [ + 'name' => $stage['name'], + 'color' => $stage['color'], + ]; + }, $stages); + return $this->renderTwig('@CRM/deals/calendar', [ + 'title' => 'Сделки — Календарь', + 'calendarEvents' => array_map(function ($deal) { + return [ + 'id' => $deal['id'], + 'title' => $deal['title'], + 'date' => $deal['expected_close_date'], + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + ]; + }, $deals), + 'eventsByDate' => $eventsByDate, + 'calendarLegend' => $calendarLegend, + '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'), + ]); + } + /** + public function create() + { + $organizationId = $this->requireActiveOrg(); + $stageId = $this->request->getGet('stage_id'); + $orgUserModel = new \App\Models\OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + return $this->renderTwig('@CRM/deals/form', [ + 'title' => 'Новая сделка', + 'actionUrl' => '/crm/deals', + 'stages' => $this->stageService->getOrganizationStages($organizationId), + 'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(), + 'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(), + 'users' => $users, + 'stageId' => $stageId, + 'currentUserId' => $this->getCurrentUserId(), + ]); + } + /** + public function store() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + $data = [ + 'organization_id' => $organizationId, + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'amount' => $this->request->getPost('amount') ?? 0, + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'stage_id' => $this->request->getPost('stage_id'), + 'contact_id' => $this->request->getPost('contact_id') ?: null, + 'company_id' => $this->request->getPost('company_id') ?: null, + 'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null, + 'expected_close_date' => $this->request->getPost('expected_close_date') ?: null, + ]; + $dealId = $this->dealService->createDeal($data, $userId); + if ($dealId) { + return redirect()->to('/crm/deals')->with('success', 'Сделка успешно создана'); + } + return redirect()->back()->with('error', 'Ошибка при создании сделки')->withInput(); + } + /** + public function show(int $id) + { + $organizationId = $this->requireActiveOrg(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + return $this->renderTwig('@CRM/deals/show', [ + 'title' => $deal['title'], + 'deal' => (object) $deal, + ]); + } + /** + public function edit(int $id) + { + $organizationId = $this->requireActiveOrg(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + $orgUserModel = new \App\Models\OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + return $this->renderTwig('@CRM/deals/form', [ + 'title' => 'Редактирование сделки', + 'actionUrl' => "/crm/deals/{$id}", + 'deal' => (object) $deal, + 'stages' => $this->stageService->getOrganizationStages($organizationId), + 'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(), + 'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(), + 'users' => $users, + 'currentUserId' => $this->getCurrentUserId(), + ]); + } + /** + public function update(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + $data = [ + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'amount' => $this->request->getPost('amount') ?? 0, + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'stage_id' => $this->request->getPost('stage_id'), + 'contact_id' => $this->request->getPost('contact_id') ?: null, + 'company_id' => $this->request->getPost('company_id') ?: null, + 'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null, + 'expected_close_date' => $this->request->getPost('expected_close_date') ?: null, + ]; + $result = $this->dealService->updateDeal($id, $data, $userId); + if ($result) { + return redirect()->to("/crm/deals/{$id}")->with('success', 'Сделка обновлена'); + } + return redirect()->back()->with('error', 'Ошибка при обновлении сделки')->withInput(); + } + /** + public function destroy(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + $this->dealService->deleteDeal($id, $userId); + return redirect()->to('/crm/deals')->with('success', 'Сделка удалена'); + } + /** + public function moveStage() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + $dealId = $this->request->getPost('deal_id'); + $newStageId = $this->request->getPost('stage_id'); + $deal = $this->dealService->getDealWithJoins($dealId, $organizationId); + if (!$deal) { + return $this->response->setJSON(['success' => false, 'message' => 'Сделка не найдена']); + } + $result = $this->dealService->changeStage($dealId, $newStageId, $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 stages() + { + $organizationId = $this->requireActiveOrg(); + $stages = $this->stageService->getOrganizationStages($organizationId); + return $this->renderTwig('@CRM/deals/stages', [ + 'title' => 'Этапы сделок', + 'stages' => $stages, + ]); + } + /** + public function storeStage() + { + $organizationId = $this->requireActiveOrg(); + $data = [ + 'organization_id' => $organizationId, + 'name' => $this->request->getPost('name'), + 'color' => $this->request->getPost('color') ?? '#6B7280', + 'type' => $this->request->getPost('type') ?? 'progress', + 'probability' => $this->request->getPost('probability') ?? 0, + ]; + $stageId = $this->stageService->createStage($data); + if ($stageId) { + return redirect()->to('/crm/deals/stages')->with('success', 'Этап создан'); + } + return redirect()->back()->with('error', 'Ошибка при создании этапа')->withInput(); + } + /** + public function updateStage(int $id) + { + $organizationId = $this->requireActiveOrg(); + $stage = $this->stageService->getStage($id); + if (!$stage || $stage['organization_id'] !== $organizationId) { + return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден'); + } + $data = [ + 'name' => $this->request->getPost('name'), + 'color' => $this->request->getPost('color'), + 'type' => $this->request->getPost('type'), + 'probability' => $this->request->getPost('probability'), + ]; + $this->stageService->updateStage($id, $data); + return redirect()->to('/crm/deals/stages')->with('success', 'Этап обновлён'); + } + /** + public function destroyStage(int $id) + { + $organizationId = $this->requireActiveOrg(); + $stage = $this->stageService->getStage($id); + if (!$stage || $stage['organization_id'] !== $organizationId) { + return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден'); + } + if (!$this->stageService->canDeleteStage($id)) { + return redirect()->to('/crm/deals/stages')->with('error', 'Нельзя удалить этап, на котором есть сделки'); + } + $this->stageService->deleteStage($id); + return redirect()->to('/crm/deals/stages')->with('success', 'Этап удалён'); + } + /** + public function reorderStages() + { + $organizationId = $this->requireActiveOrg(); + $stageOrders = $this->request->getPost('stages'); + if (empty($stageOrders) || !is_array($stageOrders)) { + return $this->response + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setHeader('X-CSRF-HASH', csrf_token()) + ->setJSON([ + 'success' => false, + 'message' => 'Не передан список этапов', + ])->setStatusCode(422); + } + $stageOrders = array_map('intval', $stageOrders); + foreach ($stageOrders as $stageId) { + $stage = $this->stageService->getStage($stageId); + if (!$stage || intval($stage['organization_id'] ?? 0) !== intval($organizationId)) { + return $this->response + ->setJSON([ + 'success' => false, + 'message' => 'Этап не найден или принадлежит другой организации', + 'debug' => [ + 'stageId' => $stageId, + 'stage' => $stage, + 'organizationId' => $organizationId, + ], + ])->setStatusCode(422); + } + } + $this->stageService->reorderStages($organizationId, $stageOrders); + return $this->response + ->setJSON([ + 'success' => true, + 'message' => 'Порядок этапов обновлён', + 'csrf_token' => csrf_hash(), + 'csrf_hash' => csrf_token(), + ]); + } + /** + public function getContactsByClient() + { + $organizationId = $this->requireActiveOrg(); + $clientId = $this->request->getGet('client_id'); + if (!$clientId) { + return $this->response->setJSON(['success' => true, 'contacts' => []]); + } + $client = $this->clientModel->where('organization_id', $organizationId)->find($clientId); + if (!$client) { + return $this->response->setJSON(['success' => false, 'message' => 'Клиент не найден']); + } + $contacts = $this->contactModel + ->where('organization_id', $organizationId) + ->where('customer_id', $clientId) + ->findAll(); + return $this->response->setJSON([ + 'success' => true, + 'contacts' => array_map(function($contact) { + return [ + 'id' => $contact->id, + 'name' => $contact->name, + 'email' => $contact->email, + 'phone' => $contact->phone, + ]; + }, $contacts) + ]); + } +} + +// app/Modules/CRM/Controllers/DashboardController.php +dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + $this->contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + /** + public function index() + { + $organizationId = $this->requireActiveOrg(); + $stats = $this->dealModel->getDealStats($organizationId); + $contactsCount = $this->contactModel->where('organization_id', $organizationId)->countAllResults(); + $clientsCount = $this->clientModel->where('organization_id', $organizationId)->countAllResults(); + $stagesCount = $this->stageModel->where('organization_id', $organizationId)->countAllResults(); + return $this->renderTwig('@CRM/dashboard', [ + 'title' => 'CRM - Панель управления', + 'stats' => $stats, + 'counts' => [ + 'contacts' => $contactsCount, + 'clients' => $clientsCount, + 'stages' => $stagesCount, + ], + ]); + } +} + +// app/Modules/CRM/Controllers/ContactsController.php +contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + /** + protected function getContactsTableConfig(): array + { + $organizationId = $this->requireActiveOrg(); + return [ + 'id' => 'contacts-table', + 'url' => '/crm/contacts/table', + 'model' => $this->contactModel, + 'columns' => [ + 'id' => ['label' => 'ID', 'width' => '60px'], + 'name' => ['label' => 'Имя'], + 'email' => ['label' => 'Email', 'width' => '180px'], + 'phone' => ['label' => 'Телефон', 'width' => '140px'], + 'position' => ['label' => 'Должность', 'width' => '150px'], + 'customer_name' => ['label' => 'Клиент'], + 'created_at' => ['label' => 'Дата', 'width' => '100px'], + ], + 'searchable' => ['name', 'email', 'phone', 'position', 'customer_name'], + 'sortable' => ['id', 'name', 'created_at'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'fieldMap' => [ + 'customer_name' => 'customers.name', + 'name' => 'contacts.name', + 'email' => 'contacts.email', + 'phone' => 'contacts.phone', + 'position' => 'contacts.position', + 'created_at' => 'contacts.created_at', + 'id' => 'contacts.id', + ], + 'scope' => function($builder) use ($organizationId) { + $builder->from('contacts') + ->select('contacts.id, contacts.name, contacts.email, contacts.phone, contacts.position, contacts.created_at, contacts.deleted_at, customers.name as customer_name') + ->join('organizations_clients customers', 'customers.id = contacts.customer_id', 'left') + ->where('contacts.organization_id', $organizationId) + ->where('contacts.deleted_at', null); + }, + 'actions' => ['label' => 'Действия', 'width' => '120px'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/crm/contacts/{id}/edit', + 'icon' => 'fa-solid fa-pen', + 'class' => 'btn-outline-primary', + 'title' => 'Редактировать', + ], + ], + 'emptyMessage' => 'Контактов пока нет', + 'emptyIcon' => 'fa-solid fa-users', + ]; + } + /** + public function index() + { + $config = $this->getContactsTableConfig(); + $tableHtml = $this->renderTable($config); + return $this->renderTwig('@CRM/contacts/index', [ + 'title' => 'Контакты', + 'tableHtml' => $tableHtml, + 'config' => $config, + ]); + } + /** + public function contactsTable() + { + return parent::table($this->getContactsTableConfig(), '/crm/contacts'); + } + /** + public function create() + { + $organizationId = $this->requireActiveOrg(); + $clients = $this->clientModel + ->where('organization_id', $organizationId) + ->findAll(); + return $this->renderTwig('@CRM/contacts/form', [ + 'title' => 'Новый контакт', + 'actionUrl' => '/crm/contacts', + 'clients' => $clients, + ]); + } + /** + public function store() + { + $organizationId = $this->requireActiveOrg(); + $data = [ + 'organization_id' => $organizationId, + 'customer_id' => $this->request->getPost('customer_id') ?: null, + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?: null, + 'phone' => $this->request->getPost('phone') ?: null, + 'position' => $this->request->getPost('position') ?: null, + 'is_primary' => $this->request->getPost('is_primary') ? 1 : 0, + 'notes' => $this->request->getPost('notes') ?: null, + ]; + $this->contactModel->save($data); + $contactId = $this->contactModel->getInsertID(); + if ($contactId) { + return redirect()->to('/crm/contacts')->with('success', 'Контакт успешно создан'); + } + return redirect()->back()->with('error', 'Ошибка при создании контакта')->withInput(); + } + /** + public function edit(int $id) + { + $organizationId = $this->requireActiveOrg(); + $contact = $this->contactModel->find($id); + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + $clients = $this->clientModel + ->where('organization_id', $organizationId) + ->findAll(); + return $this->renderTwig('@CRM/contacts/form', [ + 'title' => 'Редактирование контакта', + 'actionUrl' => "/crm/contacts/{$id}", + 'contact' => $contact, + 'clients' => $clients, + ]); + } + /** + public function update(int $id) + { + $organizationId = $this->requireActiveOrg(); + $contact = $this->contactModel->find($id); + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + $data = [ + 'customer_id' => $this->request->getPost('customer_id') ?: null, + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?: null, + 'phone' => $this->request->getPost('phone') ?: null, + 'position' => $this->request->getPost('position') ?: null, + 'is_primary' => $this->request->getPost('is_primary') ? 1 : 0, + 'notes' => $this->request->getPost('notes') ?: null, + ]; + $this->contactModel->update($id, $data); + return redirect()->to('/crm/contacts')->with('success', 'Контакт обновлён'); + } + /** + public function destroy(int $id) + { + $organizationId = $this->requireActiveOrg(); + $contact = $this->contactModel->find($id); + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + $this->contactModel->delete($id); + return redirect()->to('/crm/contacts')->with('success', 'Контакт удалён'); + } + /** + public function ajaxList(int $clientId) + { + $organizationId = $this->requireActiveOrg(); + $client = $this->clientModel->forCurrentOrg()->find($clientId); + if (!$client) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Клиент не найден', + ]); + } + $contacts = $this->contactModel + ->where('organization_id', $organizationId) + ->where('customer_id', $clientId) + ->orderBy('name', 'ASC') + ->findAll(); + $items = array_map(function ($contact) { + return [ + 'id' => $contact->id, + 'name' => $contact->name, + 'email' => $contact->email, + 'phone' => $contact->phone, + 'position' => $contact->position, + ]; + }, $contacts); + return $this->response + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setHeader('X-CSRF-HASH', csrf_token()) + ->setJSON([ + 'success' => true, + 'items' => $items, + 'total' => count($items), + ]); + } + /** + public function ajaxStore() + { + $organizationId = $this->requireActiveOrg(); + $jsonData = $this->request->getJSON(true); + $rawInput = $jsonData ?? $this->request->getPost(); + $customerId = $rawInput['customer_id'] ?? null; + if ($customerId) { + $client = $this->clientModel->forCurrentOrg()->find($customerId); + if (!$client) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Клиент не найден', + ])->setStatusCode(422); + } + } + $data = [ + 'organization_id' => $organizationId, + 'customer_id' => $customerId ?: null, + 'name' => $rawInput['name'] ?? '', + 'email' => $rawInput['email'] ?? null, + 'phone' => $rawInput['phone'] ?? null, + 'position' => $rawInput['position'] ?? null, + ]; + if (empty($data['name'])) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Имя контакта обязательно', + 'errors' => ['name' => 'Имя контакта обязательно'], + ])->setStatusCode(422); + } + $contactId = $this->contactModel->insert($data); + if (!$contactId) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Ошибка при создании контакта', + 'errors' => $this->contactModel->errors(), + ])->setStatusCode(422); + } + return $this->response + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setHeader('X-CSRF-HASH', csrf_token()) + ->setJSON([ + 'success' => true, + 'message' => 'Контакт создан', + 'item' => [ + 'id' => $contactId, + 'name' => $data['name'], + 'email' => $data['email'], + 'phone' => $data['phone'], + 'position' => $data['position'], + ], + ]); + } + /** + public function ajaxUpdate(int $id) + { + $organizationId = $this->requireActiveOrg(); + $contact = $this->contactModel->find($id); + if (!$contact || $contact->organization_id !== $organizationId) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Контакт не найден', + ])->setStatusCode(404); + } + $jsonData = $this->request->getJSON(true); + $rawInput = $jsonData ?? $this->request->getPost(); + $data = [ + 'name' => $rawInput['name'] ?? '', + 'email' => $rawInput['email'] ?? null, + 'phone' => $rawInput['phone'] ?? null, + 'position' => $rawInput['position'] ?? null, + ]; + if (empty($data['name'])) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Имя контакта обязательно', + 'errors' => ['name' => 'Имя контакта обязательно'], + ])->setStatusCode(422); + } + $result = $this->contactModel->update($id, $data); + if (!$result) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Ошибка при обновлении контакта', + 'errors' => $this->contactModel->errors(), + ])->setStatusCode(422); + } + return $this->response + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setHeader('X-CSRF-HASH', csrf_token()) + ->setJSON([ + 'success' => true, + 'message' => 'Контакт обновлён', + 'item' => [ + 'id' => $id, + 'name' => $data['name'], + 'email' => $data['email'], + 'phone' => $data['phone'], + 'position' => $data['position'], + ], + ]); + } + /** + public function ajaxDelete(int $id) + { + $organizationId = $this->requireActiveOrg(); + $contact = $this->contactModel->find($id); + if (!$contact || $contact->organization_id !== $organizationId) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Контакт не найден', + ])->setStatusCode(404); + } + $this->contactModel->delete($id); + return $this->response + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setHeader('X-CSRF-HASH', csrf_token()) + ->setJSON([ + 'success' => true, + 'message' => 'Контакт удалён', + ]); + } +} + +// app/Modules/Clients/Views/form.twig +{% extends 'layouts/base.twig' %} +{% import 'macros/forms.twig' as forms %} + +{% block content %} +
+
+
+
+
+ + + +
+

{{ title }}

+
+
+
+ +
+ {{ forms.form_open(client ? base_url('/clients/update/' ~ client.id) : base_url('/clients/create')) }} + + {# Табы #} + + + {# Содержимое табов #} +
+ {# Таб "Основное" #} +
+
+ + + {% if errors.name %} +
{{ errors.name }}
+ {% endif %} +
ФИО клиента или название компании
+
+ +
+
+ + + {% if errors.email %} +
{{ errors.email }}
+ {% endif %} +
+ +
+ + + {% if errors.phone %} +
{{ errors.phone }}
+ {% endif %} +
+
+ +
+ + + {% if errors.notes %} +
{{ errors.notes }}
+ {% endif %} +
+
+ + {# Таб "Контакты" (только при активном CRM) #} + {% if crm_active %} +
+ + + {# Скрипт инициализации контактов подключаем в конце #} +
+ {# Таблица контактов загружается через AJAX #} +
+
+ Загрузка... +
+

Загрузка контактов...

+
+
+
+ {% endif %} +
+ +
+ Отмена + +
+ {{ forms.form_close() }} +
+
+
+
+{% endblock %} + +{% block scripts %} +{# Inline-редактирование контактов #} +{% if crm_active %} + +{% endif %} +{% endblock %} + +// app/Modules/Clients/Views/_client_modal.twig +{# +# Модальное окно просмотра клиента +#} + +{# Скрытый модальный контейнер - будет показан при клике на строку таблицы #} + + + + +// app/Modules/Clients/Views/import.twig +{% extends 'layouts/base.twig' %} + +{% block title %}Импорт клиентов - {{ parent() }}{% endblock %} + +{% block content %} +
+
+
+
Импорт клиентов
+ + Назад + +
+
+
+ {{ csrf_field()|raw }} + + {# Инструкция #} +
+
Инструкция по импорту
+
    +
  • Загрузите файл в формате CSV (разделитель — точка с запятой)
  • +
  • Файл должен содержать заголовки в первой строке
  • +
  • Обязательные поля: Имя
  • +
  • Опционально: Email, Телефон
  • +
+
+ + {# Ссылка на шаблон #} + + + {# Загрузка файла #} +
+ + +
Поддерживается только формат CSV
+
+ + {# Кнопка отправки #} +
+ Отмена + +
+
+ + {# Результат импорта (скрыт по умолчанию) #} + +
+
+
+ + +{% endblock %} + +// app/Modules/Clients/Views/index.twig +{% extends 'layouts/base.twig' %} + +{% block content %} +
+
+

{{ title }}

+

Управление клиентами вашей организации

+
+
+ {# Кнопки экспорта и импорта #} + + + Импорт + + + {% if can_create %} + + Добавить клиента + + {% endif %} +
+
+ +
+
+
+
+ Нажмите на для поиска по столбцу +
+
+
+ +
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+ +{# Модальное окно просмотра клиента #} +{% include '@Clients/_client_modal.twig' %} +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + +{% endblock %} + +// app/Modules/Clients/Models/ClientModel.php +where('organization_id', $organizationId); + } + /** + public function search(int $organizationId, string $query = '') + { + $builder = $this->forOrganization($organizationId); + if (!empty($query)) { + $builder->groupStart() + ->like('name', $query) + ->orLike('email', $query) + ->orLike('phone', $query) + ->groupEnd(); + } + return $builder; + } +} + +// app/Modules/Clients/Config/Routes.php +group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) { + $routes->get('/', 'Clients::index'); + $routes->get('table', 'Clients::table'); + $routes->get('view/(:num)', 'Clients::view/$1'); + $routes->get('new', 'Clients::new'); + $routes->post('create', 'Clients::create'); + $routes->get('edit/(:num)', 'Clients::edit/$1'); + $routes->post('update/(:num)', 'Clients::update/$1'); + $routes->get('delete/(:num)', 'Clients::delete/$1'); + $routes->get('export', 'Clients::export'); + $routes->get('import', 'Clients::importPage'); + $routes->post('import', 'Clients::import'); +}); +// app/Modules/Clients/Controllers/Clients.php +clientModel = new ClientModel(); + $this->subscriptionService = service('moduleSubscription'); + } + 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'), + ]); + } + /** + 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%'], + ], + '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', + 'title' => 'Редактировать', + 'type' => 'edit', + ], + [ + 'label' => '', + 'url' => '/clients/delete/{id}', + 'icon' => 'fa-solid fa-trash', + 'class' => 'btn-outline-danger', + 'title' => 'Удалить', + 'type' => 'delete', + ] + ], + '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'), + ]; + } + public function table(?array $config = null, ?string $pageUrl = null) + { + if (!$this->access->canView('clients')) { + return $this->forbiddenResponse('У вас нет прав для просмотра клиентов'); + } + return parent::table($config, '/clients'); + } + public function new() + { + if (!$this->access->canCreate('clients')) { + return $this->forbiddenResponse('У вас нет прав для создания клиентов'); + } + $data = [ + 'title' => 'Добавить клиента', + 'client' => null, + 'crm_active' => $this->subscriptionService->isModuleActive('crm'), + ]; + return $this->renderTwig('@Clients/form', $data); + } + public function create() + { + if (!$this->access->canCreate('clients')) { + return $this->forbiddenResponse('У вас нет прав для создания клиентов'); + } + $organizationId = session()->get('active_org_id'); + $rules = [ + 'name' => 'required|min_length[2]|max_length[255]', + 'email' => 'permit_empty|valid_email', + 'phone' => 'permit_empty|max_length[50]', + ]; + if (!$this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + $this->clientModel->insert([ + 'organization_id' => $organizationId, + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?? null, + 'phone' => $this->request->getPost('phone') ?? null, + 'notes' => $this->request->getPost('notes') ?? null, + ]); + if ($this->clientModel->errors()) { + return redirect()->back()->withInput()->with('error', 'Ошибка при создании клиента'); + } + session()->setFlashdata('success', 'Клиент успешно добавлен'); + return redirect()->to('/clients'); + } + public function edit($id) + { + if (!$this->access->canEdit('clients')) { + return $this->forbiddenResponse('У вас нет прав для редактирования клиентов'); + } + $client = $this->clientModel->forCurrentOrg()->find($id); + if (!$client) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); + } + $data = [ + 'title' => 'Редактировать клиента', + 'client' => $client, + 'crm_active' => $this->subscriptionService->isModuleActive('crm'), + ]; + return $this->renderTwig('@Clients/form', $data); + } + public function update($id) + { + if (!$this->access->canEdit('clients')) { + return $this->forbiddenResponse('У вас нет прав для редактирования клиентов'); + } + $client = $this->clientModel->forCurrentOrg()->find($id); + if (!$client) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); + } + $rules = [ + 'name' => 'required|min_length[2]|max_length[255]', + 'email' => 'permit_empty|valid_email', + 'phone' => 'permit_empty|max_length[50]', + ]; + if (!$this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + $this->clientModel->update($id, [ + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?? null, + 'phone' => $this->request->getPost('phone') ?? null, + 'notes' => $this->request->getPost('notes') ?? null, + ]); + if ($this->clientModel->errors()) { + return redirect()->back()->withInput()->with('error', 'Ошибка при обновлении клиента'); + } + session()->setFlashdata('success', 'Клиент успешно обновлён'); + return redirect()->to('/clients'); + } + public function delete($id) + { + if (!$this->access->canDelete('clients')) { + return $this->forbiddenResponse('У вас нет прав для удаления клиентов'); + } + $client = $this->clientModel->forCurrentOrg()->find($id); + if (!$client) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); + } + $this->clientModel->delete($id); + session()->setFlashdata('success', 'Клиент удалён'); + return redirect()->to('/clients'); + } +} + +// app/Modules/Tasks/Views/tasks/form.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+
+ +
+
+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Выберите нескольких исполнителей, удерживая Ctrl/Cmd
+
+ +
+ + Отмена +
+
+
+
+
+ +
+
+
+
Информация
+
+
+ {% if task %} +

Создано: {{ task.created_at|date('d.m.Y H:i') }}

+

Автор: {{ task.created_by_name|default('—') }}

+ {% if task.completed_at %} +

Завершено: {{ task.completed_at|date('d.m.Y H:i') }}

+ {% endif %} + {% else %} +

Заполните форму для создания новой задачи

+ {% endif %} +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Modules/Tasks/Views/tasks/kanban.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} +
+

{{ title }}

+
+ {# Выбор доски #} + + + + + + Добавить задачу + +
+
+ +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +{# Канбан доска #} +
+
+ {% for column in kanbanColumns %} +
+
+
+
{{ column.name }}
+ {{ column.items|length }} +
+
+ + {% for item in column.items %} +
+
+
+
{{ item.title }}
+ {% if item.priority == 'urgent' %} + Срочно + {% elseif item.priority == 'high' %} + Высокий + {% endif %} +
+ + {% if item.description %} +

+ {{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }} +

+ {% endif %} + +
+ {% if item.due_date %} + + + {{ item.due_date|date('d.m') }} + {% if item.due_date < date('Y-m-d') %} + ! + {% endif %} + + {% endif %} + + + +
+
+
+ {% endfor %} + + {# Кнопка добавления #} + + Добавить задачу + +
+
+
+ {% endfor %} +
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Modules/Tasks/Views/tasks/show.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} + + +
+
+
+
+
+ {% if task.priority == 'urgent' %} + Срочно + {% elseif task.priority == 'high' %} + Высокий + {% elseif task.priority == 'low' %} + Низкий + {% endif %} + {{ task.title }} +
+
+ {% if not task.completed_at %} +
+ {{ csrf_field()|raw }} + +
+ {% else %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} + + Редактировать + +
+
+
+
+
+

Статус:

+ + {{ task.column_name|default('—') }} + +
+
+

Приоритет:

+ + {{ task.priorityLabels[task.priority]|default(task.priority) }} + +
+
+ + {% if task.description %} +
+

Описание:

+

{{ task.description|nl2br }}

+
+ {% endif %} + + {% if task.assignees %} +
+

Исполнители:

+
+ {% for assignee in task.assignees %} + + + {{ assignee.user_name|default(assignee.user_email) }} + {% if assignee.role == 'watcher' %} + (наблюдатель) + {% endif %} + + {% endfor %} +
+
+ {% endif %} +
+
+ + {# Комментарии #} +
+
+
Комментарии
+
+
+

Комментарии будут доступны в следующей версии

+
+
+
+ +
+
+
+
Детали
+
+
+

+ + Срок: + {% if task.due_date %} + {{ task.due_date|date('d.m.Y') }} + {% if task.due_date < date('now') and not task.completed_at %} + (просрочено) + {% endif %} + {% else %} + не указан + {% endif %} +

+

+ + Автор: {{ task.created_by_name|default('—') }} +

+

+ + Создано: {{ task.created_at|date('d.m.Y H:i') }} +

+ {% if task.completed_at %} +

+ + Завершено: {{ task.completed_at|date('d.m.Y H:i') }} +

+ {% endif %} +
+
+ +
+
+
Действия
+
+
+
+ {% if not task.completed_at %} +
+ {{ csrf_field()|raw }} + +
+ {% else %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} + + Редактировать + +
+ {{ csrf_field()|raw }} + +
+
+
+
+
+
+{% endblock %} + +// app/Modules/Tasks/Views/tasks/index.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} + + +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +{# Переключатель видов #} + + +{# Таблица задач #} +
+
+ {{ tableHtml|raw }} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} + +// app/Modules/Tasks/Views/tasks/calendar.twig +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }} — Бизнес.Точка{% endblock %} + +{% block content %} + + +{# Статистика #} +
+
+
+
+
Всего
+

{{ stats.total }}

+
+
+
+
+
+
+
Выполнено
+

{{ stats.completed }}

+
+
+
+
+
+
+
В ожидании
+

{{ stats.pending }}

+
+
+
+
+
+
+
Просрочено
+

{{ stats.overdue }}

+
+
+
+
+ +
+
+ +
{{ monthName }}
+ + Сегодня + +
+
+
+ {# Дни недели #} +
+ {% 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 %} +
+ +
+ {% for event in dayEvents|slice(0, 3) %} + + + {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} + {% if event.priority == 'urgent' or event.priority == 'high' %} + + {% endif %} + + {% endfor %} + + {% if dayEvents|length > 3 %} +
+ +{{ dayEvents|length - 3 }} ещё +
+ {% 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 %} + + +
+
+ {{ tableHtml|raw }} +
+
+{% endblock %} + +{% block scripts %} +{{ parent() }} + + +{% endblock %} +``` + +### Прямое использование компонента + +При необходимости таблицу можно подключить напрямую через `include`, передав все параметры вручную: + +```twig +{% from '@components/table/macros.twig' import render_actions %} + +
+ {{ include('@components/table/table.twig', { + id: 'my-table', + url: '/my-module/table', + perPage: 25, + sort: sort|default(''), + order: order|default('asc'), + filters: filters|default({}), + items: items, + pagerDetails: pagerDetails, + columns: { + name: { label: 'Название', width: '40%' }, + email: { label: 'Email', width: '30%' }, + status: { label: 'Статус', width: '20%' }, + }, + actions: { label: 'Действия', width: '10%' }, + actionsConfig: [ + { label: 'Ред.', url: '/edit/{id}', icon: 'fa-solid fa-pen', class: 'btn-outline-primary' }, + ], + can_edit: can_edit|default(true), + can_delete: can_delete|default(true), + emptyMessage: 'Записей не найдено', + emptyIcon: 'fa-solid fa-inbox', + emptyActionUrl: url('/create'), + emptyActionLabel: 'Создать', + emptyActionIcon: 'fa-solid fa-plus', + onRowClick: 'openClientDetails', + tableClass: 'table-sm', + }) }} +
+``` + +### Поддержка 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 +{# В шаблоне ячейки с форматированием #} + + {{ item.title }} + {% if item.description %} +
{{ item.description|slice(0, 50) }}... + {% endif %} + + + {{ item.amount|number_format(0, ',', ' ') }} ₽ + + + {% if item.status == 'active' %} + Активна + {% elseif item.status == 'won' %} + Выиграна + {% elseif item.status == 'lost' %} + Проиграна + {% endif %} + +``` + +--- + +## Проверка при создании модуля + +### Чек-лист при добавлении новой таблицы + +При создании нового модуля с таблицей необходимо выполнить следующие действия: + +**В контроллере:** +- Определить метод `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 +