bp/bp.txt

23542 lines
954 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/Language/en/Validation.php
<?php
return [];
// app/Language/.gitkeep
// app/Libraries/Twig/TwigJsonDecodeExtension.php
<?php
namespace App\Libraries\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class TwigJsonDecodeExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('json_decode', [$this, 'jsonDecode']),
];
}
public function jsonDecode(string $json, bool $assoc = true, int $depth = 512, int $flags = 0)
{
return json_decode($json, $assoc, $depth, $flags);
}
}
// app/Libraries/Twig/TwigGlobalsExtension.php
<?php
namespace App\Libraries\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigGlobal;
use App\Models\OrganizationModel;
use Config\Services;
class TwigGlobalsExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('get_session', [$this, 'getSession'], ['is_safe' => ['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 '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
}
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 '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
}
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 '<img src="' . esc($url) . '" alt="' . esc($name) . '" class="' . esc($class) . '" style="' . esc($style) . '">';
}
$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 '<div class="' . esc($class) . '" style="' . esc($style) . '">' . esc($initial) . '</div>';
}
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 = '<div class="btn-group btn-group-sm">';
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 ? '<i class="' . esc($icon) . '"></i> ' : '';
$targetAttr = $target ? ' target="' . esc($target) . '"' : '';
$html .= '<a href="' . esc($url) . '" '
. 'class="btn ' . esc($class) . '" '
. 'title="' . esc($title) . '"'
. $targetAttr . '>'
. $iconHtml . esc($label)
. '</a>';
}
$html .= '</div>';
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 '<a href="mailto:' . esc($value) . '">' . esc($value) . '</a>';
}
return $config['default'] ?? '—';
case 'phone':
if ($value) {
return '<a href="tel:' . esc($value) . '">' . esc($value) . '</a>';
}
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 '<span class="badge bg-success">Да</span>';
}
return '<span class="badge bg-secondary">Нет</span>';
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
? '<img src="' . esc($avatar) . '" alt="" style="width: 32px; height: 32px; object-fit: cover; border-radius: 50%;">'
: '<div class="bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;"><i class="fa-solid fa-user text-muted small"></i></div>';
return '<div class="d-flex align-items-center gap-2">' . $avatarHtml . '<div><div class="fw-medium">' . esc($name ?: $email) . '</div>' . ($name ? '<div class="small text-muted">' . esc($email) . '</div>' : '') . '</div></div>';
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
<?php
namespace App\Libraries;
/**
class RateLimitIdentifier
{
private const COOKIE_NAME = 'rl_token';
private const COOKIE_TTL = 365 * 24 * 3600;
/**
public function getIdentifier(string $action): string
{
$parts = [];
$cookieToken = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($cookieToken && $this->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 <<<JS
(function() {
const cookieName = 'rl_token';
const hasCookie = document.cookie.split('; ').find(function(row) {
return row.indexOf(cookieName + '=') === 0;
});
if (!hasCookie) {
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
const token = generateUUID();
const date = new Date();
date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000));
const cookieString = cookieName + '=' + token +
'; expires=' + date.toUTCString() +
'; path=/' +
'; SameSite=Lax' +
(location.protocol === 'https:' ? '; Secure' : '') +
'; HttpOnly';
document.cookie = cookieString;
}
})();
JS;
}
/**
private function generateToken(): string
{
return bin2hex(random_bytes(16));
}
/**
private function isValidToken(string $token): bool
{
return preg_match('/^[a-f0-9]{32}$/', $token) === 1;
}
/**
private function getClientIp(): string
{
$ipKeys = [
'HTTP_CF_CONNECTING_IP',
'HTTP_X_REAL_IP',
'HTTP_X_FORWARDED_FOR',
'REMOTE_ADDR',
];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
return trim($ips[0]);
}
}
return '0.0.0.0';
}
/**
private function getUserAgentHash(): string
{
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (strlen($ua) > 500) {
$ua = substr($ua, 0, 500);
}
return md5($ua);
}
}
// app/Libraries/EmailLibrary.php
<?php
namespace App\Libraries;
use CodeIgniter\Email\Email;
use Config\Services;
class EmailLibrary
{
/**
public function sendVerificationEmail(string $email, string $name, string $token): bool
{
$emailConfig = config('Email');
$verificationUrl = base_url('/auth/verify/' . $token);
$twig = Services::twig();
$htmlBody = $twig->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
<?php
namespace App\Helpers;
/**
/**
if (!function_exists('can')) {
function can(string $action, string $resource): bool
{
$access = service('access');
return $access->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
<?php
/**
if (!function_exists('hex2rgba')) {
function hex2rgba(string $color, float $opacity = 1): string
{
$color = ltrim($color, '#');
if (strlen($color) === 3) {
$color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2];
}
$hex = [hexdec($color[0] . $color[1]), hexdec($color[2] . $color[3]), hexdec($color[4] . $color[5])];
return 'rgba(' . implode(', ', $hex) . ', ' . $opacity . ')';
}
}
/**
if (!function_exists('format_currency')) {
function format_currency(float $amount, string $currency = 'RUB'): string
{
$locale = locale_get_default() ?: 'ru_RU';
$formatter = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
return $formatter->formatCurrency($amount, $currency);
}
}
/**
function current_organization_id(): ?int
{
$session = session();
$orgId = $session->get('active_org_id');
return $orgId ? (int) $orgId : null;
}
// app/index.html
<!DOCTYPE html>
<html>
<head>
<title>403 Forbidden</title>
</head>
<body>
<p>Directory access is forbidden.</p>
</body>
</html>
// app/Common.php
<?php
/**
// app/Views/layouts/base.twig
{# app/Views/layouts/base.html.twig #}
{% set session_data = get_session() %}
{% set current_org = get_current_org() %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ csrf_meta() }}
<title>{% block title %}Бизнес.Точка{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="{{ base_url('assets/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- FontAwesome -->
<link href="{{ base_url('assets/css/all.min.css') }}" rel="stylesheet">
<!-- Base -->
<link href="{{ base_url('assets/css/base.css') }}" rel="stylesheet">
{% block styles %}{% endblock %}
</head>
<body class="bg-light" data-base-url="{{ base_url('/') }}">
<div class="d-flex" id="wrapper">
<!-- SIDEBAR -->
<div class="bg-white border-end" id="sidebar-wrapper" style="width: 250px;">
<div class="sidebar-heading bg-primary text-white p-3 d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-circle-nodes me-2"></i>Бизнес.Точка</span>
</div>
<div class="list-group list-group-flush mt-2">
<!-- Главная -->
<a href="{{ base_url('/') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route(['', 'dashboard', 'home']) ? 'active' : '' }}">
<i class="fa-solid fa-gauge-high me-2"></i> Главная
</a>
<!-- Клиенты -->
<a href="{{ base_url('/clients') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('clients') ? 'active' : '' }}">
<i class="fa-solid fa-building text-primary me-2"></i> Клиенты
</a>
{# Модули #}
{# CRM модуль #}
{% if is_module_available('crm') %}
<a href="{{ base_url('/crm') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('crm') ? 'active' : '' }}">
<i class="fa-solid fa-chart-line text-success me-2"></i> CRM
</a>
{% else %}
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль CRM не активен">
<i class="fa-solid fa-lock text-muted me-2"></i> CRM
</a>
{% endif %}
{# Booking модуль #}
{% if is_module_available('booking') %}
<a href="{{ base_url('/booking') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('booking') ? 'active' : '' }}">
<i class="fa-solid fa-calendar me-2"></i> Booking
</a>
{% else %}
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Бронирования не активен">
<i class="fa-solid fa-lock text-muted me-2"></i> Booking
</a>
{% endif %}
{# Tasks модуль #}
{% if is_module_available('tasks') %}
<a href="{{ base_url('/tasks') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('tasks') ? 'active' : '' }}">
<i class="fa-solid fa-check-square me-2"></i> Tasks
</a>
{% else %}
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Задачи не активен">
<i class="fa-solid fa-lock text-muted me-2"></i> Tasks
</a>
{% endif %}
{# Proof модуль #}
{% if is_module_available('proof') %}
<a href="{{ base_url('/proof') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('proof') ? 'active' : '' }}">
<i class="fa-solid fa-file-contract me-2"></i> Proof
</a>
{% else %}
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Proof не активен">
<i class="fa-solid fa-lock text-muted me-2"></i> Proof
</a>
{% endif %}
</div>
</div>
<!-- /SIDEBAR -->
<!-- Page Content -->
<div id="page-content-wrapper" class="w-100">
<!-- TOPBAR -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom px-4 shadow-sm">
<button class="btn btn-light border-0" id="sidebarToggle"><i class="fa-solid fa-bars"></i></button>
<div class="ms-auto d-flex align-items-center">
<!-- ДРОПДАУН ОРГАНИЗАЦИИ -->
<div class="dropdown me-4">
{% if current_org %}
<button class="btn btn-light dropdown-toggle border d-flex align-items-center" type="button" data-bs-toggle="dropdown">
<i class="fa-solid fa-building text-primary me-2"></i>
<span class="fw-bold text-dark">{{ current_org.name }}</span>
<span class="badge bg-light text-secondary ms-2 text-uppercase fs-6">{{ current_org.type == 'personal' ? 'Личное' : 'Бизнес' }}</span>
</button>
{% else %}
<a href="{{ base_url('/organizations') }}" class="btn btn-outline-danger">
<i class="fa-solid fa-exclamation-triangle me-1"></i> Выберите организацию
</a>
{% endif %}
{% if current_org %}
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
{# <li>
<span class="dropdown-header text-muted small">
<i class="fa-solid fa-hashtag me-1"></i> {{ current_org.id }}
</span>
</li>
<li><hr class="dropdown-divider"></li> #}
<!-- Ссылка: Список всех организаций -->
<li>
<a class="dropdown-item" href="{{ base_url('/organizations') }}">
<i class="fa-solid fa-layer-group text-primary me-2"></i> Список организаций
</a>
</li>
<!-- Ссылка: Создать новую организацию -->
<li>
<a class="dropdown-item" href="{{ base_url('/organizations/create') }}">
<i class="fa-solid fa-plus-circle text-success me-2"></i> Создать организацию
</a>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Ссылка: Дашборд управления организацией -->
<li>
<a class="dropdown-item" href="{{ base_url('/organizations/' ~ current_org.id ~ '/dashboard') }}">
<i class="fa-solid fa-sliders text-primary me-2"></i> Управление организацией
</a>
</li>
</ul>
{% endif %}
</div>
<!-- /ДРОПДАУН ОРГАНИЗАЦИИ -->
<!-- ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ -->
<div class="dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown">
{{ get_avatar(null, 32, 'me-2') }}
<span class="d-none d-md-inline text-dark">{{ session_data.name }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
<li><h6 class="dropdown-header">{{ session_data.email }}</h6></li>
{% if is_superadmin() %}
<li>
<a class="dropdown-item" href="{{ base_url('/superadmin') }}">
<i class="fa-solid fa-shield-halved text-warning me-2"></i> Панель суперадмина
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ base_url('/profile') }}"><i class="fa-regular fa-user me-2"></i> Профиль</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ base_url('/logout') }}"><i class="fa-solid fa-arrow-right-from-bracket me-2"></i> Выйти</a></li>
</ul>
</div>
<!-- /ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ -->
</div>
</nav>
<!-- /TOPBAR -->
<!-- CONTENT -->
<div class="container-fluid p-4">
<!-- Подключаем компонент алертов (Toasts) -->
{% include 'components/alerts.twig' %}
{% block content %}{% endblock %}
</div>
<!-- /CONTENT -->
</div>
</div>
<!-- Scripts -->
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ base_url('assets/js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
// app/Views/layouts/public.twig
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - Бизнес.Точка</title>
<link href="{{ base_url('assets/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ base_url('assets/css/all.min.css') }}" rel="stylesheet">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="height: 100vh;">
{% include 'components/alerts.twig' %}
{% block content %}{% endblock %}
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ base_url('assets/js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
// app/Views/superadmin/modules/index.twig
{% extends 'superadmin/layout.twig' %}
{% block title %}Модули - Суперадмин{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Модули системы</h1>
</div>
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="modulesTabs" role="tablist">
{% for code, module in modules %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if loop.first %}active{% endif %}"
id="{{ code }}-tab"
data-bs-toggle="tab"
data-bs-target="#{{ code }}-tab-pane"
type="button"
role="tab"
aria-controls="{{ code }}-tab-pane"
aria-selected="{{ loop.first ? 'true' : 'false' }}">
{{ module.name }}
</button>
</li>
{% endfor %}
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="modulesTabsContent">
{% for code, module in modules %}
<div class="tab-pane fade {% if loop.first %}show active{% endif %}"
id="{{ code }}-tab-pane"
role="tabpanel"
aria-labelledby="{{ code }}-tab"
tabindex="0">
<form action="{{ base_url('/superadmin/modules/update') }}" method="post" class="row g-3">
{{ csrf_field()|raw }}
<input type="hidden" name="module_code" value="{{ code }}">
<div class="col-md-6">
<label class="form-label">Название модуля</label>
<input type="text" name="name" class="form-control" value="{{ module.name }}" required>
</div>
<div class="col-md-6">
<label class="form-label">Описание</label>
<input type="text" name="description" class="form-control" value="{{ module.description }}">
</div>
<div class="col-md-4">
<label class="form-label">Цена (руб/месяц)</label>
<input type="number" name="price_monthly" class="form-control" value="{{ module.price_monthly }}" min="0">
</div>
<div class="col-md-4">
<label class="form-label">Цена (руб/год)</label>
<input type="number" name="price_yearly" class="form-control" value="{{ module.price_yearly }}" min="0">
</div>
<div class="col-md-4">
<label class="form-label">Дней триала</label>
<input type="number" name="trial_days" class="form-control" value="{{ module.trial_days }}" min="0">
</div>
<div class="col-12">
<div class="alert alert-info">
<strong>Код модуля:</strong> {{ code }}<br>
<strong>Возможности:</strong>
<ul class="mb-0 mt-2">
{% for feature in module.features %}
<li>{{ feature }}</li>
{% else %}
<li>Нет описания возможностей</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-save"></i> Сохранить изменения
</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
// app/Views/superadmin/layout.twig
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Панель суперадмина{% endblock %} — Бизнес.Точка</title>
<link href="{{ base_url('assets/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ base_url('assets/css/all.min.css') }}" rel="stylesheet">
<style>
.sa-layout { display: flex; min-height: 100vh; }
.sa-sidebar { width: 260px; background: #1a1a2e; color: #fff; padding: 20px 0; flex-shrink: 0; }
.sa-sidebar .logo { padding: 0 20px 30px; font-size: 24px; font-weight: bold; border-bottom: 1px solid #333; margin-bottom: 20px; }
.sa-sidebar .logo a { color: #fff; text-decoration: none; }
.sa-sidebar nav ul { list-style: none; padding: 0; margin: 0; }
.sa-sidebar nav ul li { border-bottom: 1px solid #252540; }
.sa-sidebar nav ul li a { display: block; padding: 15px 20px; color: #aaa; text-decoration: none; transition: 0.3s; }
.sa-sidebar nav ul li a:hover, .sa-sidebar nav ul li a.active { background: #252540; color: #fff; padding-left: 25px; }
.sa-sidebar nav ul li a i { margin-right: 10px; }
.sa-content { flex: 1; padding: 30px; background: #f5f6fa; overflow-y: auto; }
.sa-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
.sa-header h1 { margin: 0; font-size: 28px; color: #2c3e50; }
.sa-header .user-menu { display: flex; align-items: center; gap: 15px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.stat-card h3 { margin: 0 0 10px; font-size: 14px; color: #7f8c8d; text-transform: uppercase; }
.stat-card .value { font-size: 36px; font-weight: bold; color: #2c3e50; }
.stat-card .icon { float: right; font-size: 40px; opacity: 0.1; }
.sa-card { background: #fff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
.sa-card-header { padding: 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
.sa-card-header h2 { margin: 0; font-size: 18px; color: #2c3e50; }
.sa-card-body { padding: 20px; }
.btn { display: inline-block; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; text-decoration: none; font-size: 14px; transition: 0.3s; }
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover { background: #219a52; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-warning { background: #f39c12; color: #fff; }
.btn-warning:hover { background: #d68910; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-block { display: block; width: 100%; text-align: center; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
.table th { background: #f8f9fa; font-weight: 600; color: #2c3e50; }
.table tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: #d1ecf1; color: #0c5460; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #2c3e50; }
.form-control { width: 100%; padding: 12px 15px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; box-sizing: border-box; }
.form-control:focus { outline: none; border-color: #3498db; }
.alert { padding: 15px 20px; border-radius: 5px; margin-bottom: 20px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
</style>
{% block styles %}{% endblock %}
</head>
<body>
<div class="sa-layout">
<aside class="sa-sidebar">
<div class="logo">
<a href="{{ base_url('/superadmin') }}">⚡ Business.Point</a>
</div>
<nav>
<ul>
<li><a href="{{ base_url('/superadmin') }}" class="{{ is_active_route('superadmin') and not is_active_route('superadmin/') ? 'active' : '' }}">📊 Дашборд</a></li>
<li><a href="{{ base_url('/superadmin/modules') }}" class="{{ is_active_route('superadmin/modules') ? 'active' : '' }}">📦 Модули</a></li>
<li><a href="{{ base_url('/superadmin/subscriptions') }}" class="{{ is_active_route('superadmin/subscriptions') ? 'active' : '' }}">💳 Подписки</a></li>
<li><a href="{{ base_url('/superadmin/organizations') }}" class="{{ is_active_route('superadmin/organizations') ? 'active' : '' }}">🏢 Организации</a></li>
<li><a href="{{ base_url('/superadmin/users') }}" class="{{ is_active_route('superadmin/users') ? 'active' : '' }}">👥 Пользователи</a></li>
<li><a href="{{ base_url('/superadmin/statistics') }}" class="{{ is_active_route('superadmin/statistics') ? 'active' : '' }}">📈 Статистика</a></li>
<li><hr style="border-color: #333; margin: 15px 20px;"></li>
<li><a href="{{ base_url('/') }}" target="_blank">🔗 Вернуться на сайт</a></li>
</ul>
</nav>
</aside>
<main class="sa-content">
{% block content %}{% endblock %}
</main>
</div>
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ base_url('assets/js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
// app/Views/superadmin/users/index.twig
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Пользователи</h1>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="sa-card">
<div class="sa-card-body">
<div id="users-table">
{# Динамическая таблица #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
</div>
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация DataTable
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Views/superadmin/statistics.twig
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Статистика</h1>
</div>
<div class="grid-2" style="margin-top: 20px;">
<div class="sa-card">
<div class="sa-card-header">
<h2>Распределение по тарифам</h2>
</div>
<div class="sa-card-body">
{% if planStats is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Нет данных о тарифах</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Тариф</th>
<th>Организаций</th>
<th>Доля</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td>{{ plan.name }}</td>
<td>{{ plan.orgs_count }}</td>
<td>
<div style="display: flex; align-items: center; gap: 10px;">
<div style="flex: 1; background: #eee; height: 20px; border-radius: 10px; overflow: hidden;">
<div style="width: {{ percent }}%; background: #3498db; height: 100%;"></div>
</div>
<span style="min-width: 40px;">{{ percent }}%</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Сводка</h2>
</div>
<div class="sa-card-body">
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Всего пользователей (30 дней)</span>
<strong>
{% set totalUsers = 0 %}
{% for stat in dailyStats %}
{% set totalUsers = totalUsers + stat.users %}
{% endfor %}
{{ totalUsers|number_format(0, '', ' ') }}
</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Всего организаций (30 дней)</span>
<strong>
{% set totalOrgs = 0 %}
{% for stat in dailyStats %}
{% set totalOrgs = totalOrgs + stat.orgs %}
{% endfor %}
{{ totalOrgs|number_format(0, '', ' ') }}
</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Среднее организаций на пользователя</span>
<strong>
{{ totalUsers > 0 ? (totalOrgs / totalUsers)|round(2) : 0 }}
</strong>
</div>
</div>
</div>
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Регистрации по дням (последние 30 дней)</h2>
</div>
<div style="margin-bottom: 30px;">
<canvas id="statsChart" width="800" height="300"></canvas>
</div>
<div class="sa-card-body">
<div style="overflow-x: auto;">
<table class="table">
<thead>
<tr>
<th>Дата</th>
<th>Новые пользователи</th>
<th>Новые организации</th>
</tr>
</thead>
<tbody>
{% for stat in dailyStats %}
<tr>
<td>{{ stat.date|date('d.m.Y') }}</td>
<td>{{ stat.users }}</td>
<td>{{ stat.orgs }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('statsChart').getContext('2d');
const labels = [
{% for stat in dailyStats %}
'{{ stat.date|date('d.m') }}'{% if not loop.last %},{% endif %}
{% endfor %}
];
const usersData = [
{% for stat in dailyStats %}
{{ stat.users }}{% if not loop.last %},{% endif %}
{% endfor %}
];
const orgsData = [
{% for stat in dailyStats %}
{{ stat.orgs }}{% if not loop.last %},{% endif %}
{% endfor %}
];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Пользователи',
data: usersData,
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Организации',
data: orgsData,
borderColor: '#27ae60',
backgroundColor: 'rgba(39, 174, 96, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'top'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
});
</script>
{% endblock %}
// app/Views/superadmin/organizations/view.twig
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Организация: {{ organization.name }}</h1>
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left"></i> Назад к списку
</a>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Подписки на модули</h5>
</div>
<div class="card-body">
{% if subscriptions is empty %}
<p class="text-muted text-center py-4">У организации нет активных подписок</p>
{% else %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Модуль</th>
<th>Статус</th>
<th>Истекает</th>
<th>Создана</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for sub in subscriptions %}
{% set module = allModules[sub.module_code] %}
<tr>
<td>
<strong>{{ module.name|default(sub.module_code) }}</strong>
<div class="text-muted small">{{ module.description|default('') }}</div>
</td>
<td>
{% if sub.status == 'active' %}
<span class="badge bg-success">Активна</span>
{% elseif sub.status == 'trial' %}
<span class="badge bg-info">Триал</span>
{% elseif sub.status == 'expired' %}
<span class="badge bg-danger">Истекла</span>
{% else %}
<span class="badge bg-warning">{{ sub.status }}</span>
{% endif %}
</td>
<td>
{% if sub.expires_at %}
{{ sub.expires_at|date('d.m.Y H:i') }}
{% else %}
<span class="text-muted">Бессрочно</span>
{% endif %}
</td>
<td>{{ sub.created_at|date('d.m.Y H:i') }}</td>
<td>
<a href="{{ base_url('/superadmin/organizations/' ~ organization.id ~ '/removeSubscription/' ~ sub.id) }}"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Удалить подписку на модуль {{ module.name|default(sub.module_code) }}?')"
title="Удалить подписку">
<i class="fa-solid fa-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Участники организации</h5>
</div>
<div class="card-body">
{% if users is empty %}
<p class="text-muted text-center py-4">Участников пока нет</p>
{% else %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Пользователь</th>
<th>Email</th>
<th>Роль</th>
<th>Статус</th>
<th>Дата добавления</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name|default('—') }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge {{ user.role == 'owner' ? 'bg-danger' : (user.role == 'admin' ? 'bg-warning' : 'bg-info') }}">
{{ user.role }}
</span>
</td>
<td>
{% if user.status == 'active' %}
<span class="badge bg-success">Активен</span>
{% elseif user.status == 'blocked' %}
<span class="badge bg-danger">Заблокирован</span>
{% else %}
<span class="badge bg-warning">{{ user.status }}</span>
{% endif %}
</td>
<td>{{ user.created_at|date('d.m.Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Добавить подписку</h5>
</div>
<div class="card-body">
<form action="{{ base_url('/superadmin/organizations/' ~ organization.id ~ '/add-subscription') }}" method="post">
{{ csrf_field()|raw }}
<div class="mb-3">
<label class="form-label">Модуль</label>
<select name="module_code" class="form-select" required>
<option value="">Выберите модуль...</option>
{% for code, module in allModules %}
{% set hasSub = false %}
{% for sub in subscriptions %}
{% if sub.module_code == code %}
{% set hasSub = true %}
{% endif %}
{% endfor %}
<option value="{{ code }}" {{ hasSub ? 'disabled style="background-color: #f8f9fa;"' : '' }}>
{{ module.name }} - {{ module.price_monthly }} руб/мес {{ hasSub ? '(уже есть)' : '' }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Срок действия (дней)</label>
<input type="number" name="duration_days" class="form-control" value="30" min="0">
<div class="form-text">0 - бессрочно</div>
</div>
<div class="mb-3">
<label class="form-label">Статус</label>
<select name="status" class="form-select">
<option value="active">Активна</option>
<option value="trial">Триал</option>
<option value="expired">Истекла</option>
<option value="cancelled">Отменена</option>
</select>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="fa-solid fa-plus"></i> Добавить подписку
</button>
</form>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Информация</h5>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<td>ID</td>
<td class="text-end">{{ organization.id }}</td>
</tr>
<tr>
<td>Тип</td>
<td class="text-end">
{% if organization.type == 'business' %}
<span class="badge bg-info">Бизнес</span>
{% else %}
<span class="badge bg-warning">Личное</span>
{% endif %}
</td>
</tr>
<tr>
<td>Статус</td>
<td class="text-end">
{% if organization.status == 'active' %}
<span class="badge bg-success">Активна</span>
{% elseif organization.status == 'blocked' %}
<span class="badge bg-danger">Заблокирована</span>
{% else %}
<span class="badge bg-warning">{{ organization.status }}</span>
{% endif %}
</td>
</tr>
<tr>
<td>Создана</td>
<td class="text-end">{{ organization.created_at|date('d.m.Y H:i') }}</td>
</tr>
<tr>
<td>Участников</td>
<td class="text-end">{{ users|length }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex flex-column gap-2">
{% if organization.status == 'active' %}
<a href="{{ base_url('/superadmin/organizations/block/' ~ organization.id) }}"
class="btn btn-warning"
onclick="return confirm('Заблокировать организацию?')">
<i class="fa-solid fa-ban"></i> Заблокировать
</a>
{% else %}
<a href="{{ base_url('/superadmin/organizations/unblock/' ~ organization.id) }}"
class="btn btn-success"
onclick="return confirm('Разблокировать организацию?')">
<i class="fa-solid fa-check"></i> Разблокировать
</a>
{% endif %}
<a href="{{ base_url('/superadmin/organizations/delete/' ~ organization.id) }}"
class="btn btn-danger"
onclick="return confirm('Удалить организацию? Это действие нельзя отменить!')">
<i class="fa-solid fa-trash"></i> Удалить организацию
</a>
</div>
</div>
</div>
{% endblock %}
// app/Views/superadmin/organizations/index.twig
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Организации</h1>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="sa-card">
<div class="sa-card-body">
<div id="organizations-table">
{# Динамическая таблица #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
</div>
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация DataTable
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Views/superadmin/dashboard.twig
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Дашборд</h1>
<div class="user-menu">
<span>Добро пожаловать, {{ session_data.name }}</span>
{{ get_avatar(null, 40, '') }}
</div>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="stats-grid">
<div class="stat-card">
<h3>Всего пользователей</h3>
<div class="value">{{ stats.total_users|number_format(0, '', ' ') }}</div>
<div class="icon">👥</div>
</div>
<div class="stat-card">
<h3>Всего организаций</h3>
<div class="value">{{ stats.total_orgs|number_format(0, '', ' ') }}</div>
<div class="icon">🏢</div>
</div>
<div class="stat-card">
<h3>Зарегистрировано сегодня</h3>
<div class="value">{{ stats.active_today|number_format(0, '', ' ') }}</div>
<div class="icon">📅</div>
</div>
<div class="stat-card">
<h3>Всего модулей</h3>
<div class="value">{{ stats.total_modules|number_format(0, '', ' ') }}</div>
<div class="icon">📦</div>
</div>
</div>
<div class="grid-2">
<div class="sa-card">
<div class="sa-card-header">
<h2>Последние организации</h2>
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-primary btn-sm">Все организации</a>
</div>
<div class="sa-card-body">
{% if recentOrgs is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 20px;">Организаций пока нет</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Название</th>
<th>Тип</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% for org in recentOrgs %}
<tr>
<td>
<a href="{{ base_url('/superadmin/organizations/view/' ~ org.id) }}" style="color: #3498db; text-decoration: none;">
{{ org.name }}
</a>
</td>
<td>
{% if org.type == 'business' %}
<span class="badge badge-info">Бизнес</span>
{% else %}
<span class="badge badge-warning">Личное</span>
{% endif %}
</td>
<td>{{ org.created_at|date('d.m.Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Последние пользователи</h2>
<a href="{{ base_url('/superadmin/users') }}" class="btn btn-primary btn-sm">Все пользователи</a>
</div>
<div class="sa-card-body">
{% if recentUsers is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 20px;">Пользователей пока нет</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Имя</th>
<th>Email</th>
<th>Роль</th>
</tr>
</thead>
<tbody>
{% for user in recentUsers %}
<tr>
<td>{{ user.name|default('—') }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge {{ user.system_role == 'superadmin' ? 'badge-danger' : (user.system_role == 'admin' ? 'badge-warning' : 'badge-success') }}">
{{ user.system_role|default('user') }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
{% endblock %}
// app/Views/superadmin/subscriptions/create.twig
{% extends 'superadmin/layout.twig' %}
{% block title %}Добавить подписку - Суперадмин{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Добавить подписку</h1>
<a href="{{ base_url('/superadmin/subscriptions') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left"></i> Назад к списку
</a>
</div>
<div class="card">
<div class="card-body">
<form action="{{ base_url('/superadmin/subscriptions/store') }}" method="post" class="row g-3">
{{ csrf_field()|raw }}
<div class="col-md-6">
<label class="form-label">Организация *</label>
<div class="autocomplete-wrapper">
<input type="text" class="form-control autocomplete-input" placeholder="Начните вводить название организации..."
data-url="{{ base_url('/superadmin/organizations/search') }}" autocomplete="off">
<input type="hidden" name="organization_id" class="autocomplete-value" value="">
<div class="autocomplete-dropdown"></div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Модуль *</label>
<select name="module_code" class="form-select" required>
<option value="">Выберите модуль...</option>
{% for code, module in modules %}
<option value="{{ code }}">{{ module.name }} - {{ module.price_monthly }} руб/мес</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Количество дней</label>
<input type="number" name="duration_days" class="form-control" value="30" min="0" placeholder="0 - бессрочно">
<div class="form-text">0 - подписка без срока истечения</div>
</div>
<div class="col-md-4">
<label class="form-label">Статус</label>
<select name="status" class="form-select">
<option value="active">Активна</option>
<option value="trial">Триал</option>
<option value="expired">Истекла</option>
<option value="cancelled">Отменена</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Истекает</label>
<input type="text" class="form-control" disabled value="Будет рассчитано автоматически">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Создать подписку
</button>
</div>
</form>
</div>
</div>
<style>
.autocomplete-wrapper {
position: relative;
}
.autocomplete-input {
width: 100%;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
max-height: 250px;
overflow-y: auto;
display: none;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.autocomplete-dropdown.active {
display: block;
}
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background-color: #f8f9fa;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.autocomplete-empty {
padding: 12px 15px;
color: #6c757d;
text-align: center;
}
</style>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.querySelector('.autocomplete-input');
const hiddenInput = document.querySelector('.autocomplete-value');
const dropdown = document.querySelector('.autocomplete-dropdown');
let timeout = null;
input.addEventListener('input', function() {
clearTimeout(timeout);
const value = this.value.trim();
if (value.length < 2) {
dropdown.classList.remove('active');
hiddenInput.value = '';
return;
}
timeout = setTimeout(() => {
fetch(this.dataset.url + '?q=' + encodeURIComponent(value))
.then(response => response.json())
.then(data => {
dropdown.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(item => {
const div = document.createElement('div');
div.className = 'autocomplete-item';
div.textContent = item.text;
div.dataset.id = item.id;
div.addEventListener('click', function() {
input.value = this.textContent;
hiddenInput.value = this.dataset.id;
dropdown.classList.remove('active');
});
dropdown.appendChild(div);
});
} else {
const div = document.createElement('div');
div.className = 'autocomplete-empty';
div.textContent = 'Организации не найдены';
dropdown.appendChild(div);
}
dropdown.classList.add('active');
})
.catch(error => {
console.error('Error:', error);
});
}, 300);
});
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) {
dropdown.classList.remove('active');
}
});
input.addEventListener('focus', function() {
if (this.value.trim().length >= 2) {
this.dispatchEvent(new Event('input'));
}
});
input.addEventListener('keydown', function(e) {
const items = dropdown.querySelectorAll('.autocomplete-item');
const selected = dropdown.querySelector('.selected');
let index = Array.from(items).indexOf(selected);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (items.length > 0) {
if (index < items.length - 1) {
if (selected) selected.classList.remove('selected');
items[index + 1].classList.add('selected');
}
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (index > 0) {
if (selected) selected.classList.remove('selected');
items[index - 1].classList.add('selected');
}
} else if (e.key === 'Enter' && selected) {
e.preventDefault();
input.value = selected.textContent;
hiddenInput.value = selected.dataset.id;
dropdown.classList.remove('active');
} else if (e.key === 'Escape') {
dropdown.classList.remove('active');
}
});
});
</script>
{% endblock %}
// app/Views/superadmin/subscriptions/index.twig
{% extends 'superadmin/layout.twig' %}
{% block title %}Подписки - Суперадмин{% endblock %}
{% block styles %}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Управление подписками</h1>
<a href="{{ base_url('/superadmin/subscriptions/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Добавить подписку
</a>
</div>
{% if session.success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session.success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if session.error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session.error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="card">
<div class="card-body">
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ site_url('/assets/js/modules/DataTable.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Views/pager/bootstrap_full.php
<?php
use CodeIgniter\Pager\PagerRenderer;
/**
$pager->setSurroundCount(2);
?>
<nav aria-label="<?= lang('Pager.pageNavigation') ?>">
<ul class="pagination justify-content-center">
<?php if ($pager->hasPrevious()) : ?>
<li class="page-item">
<a href="<?= $pager->getPrevious() ?>" class="page-link" aria-label="<?= lang('Pager.previous') ?>">
<span aria-hidden="true"><?= lang('Pager.previous') ?></span>
</a>
</li>
<?php endif ?>
<?php foreach ($pager->links() as $link) : ?>
<li class="page-item <?= $link['active'] ? 'active' : '' ?>">
<a href="<?= $link['uri'] ?>" class="page-link"><?= $link['title'] ?></a>
</li>
<?php endforeach ?>
<?php if ($pager->hasNext()) : ?>
<li class="page-item">
<a href="<?= $pager->getNext() ?>" class="page-link" aria-label="<?= lang('Pager.next') ?>">
<span aria-hidden="true"><?= lang('Pager.next') ?></span>
</a>
</li>
<?php endif ?>
</ul>
</nav>
// app/Views/profile/security.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block styles %}
<style>
.profile-header {
text-align: center;
padding: 2rem 0;
}
.avatar-container {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 1rem;
}
.avatar-img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-pills .nav-link {
color: #495057;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
margin-bottom: 0.5rem;
}
.nav-pills .nav-link.active {
background-color: #0d6efd;
color: white;
}
.nav-pills .nav-link:hover:not(.active) {
background-color: #e9ecef;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
.session-item {
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
background: #fafafa;
}
.session-item:last-child {
margin-bottom: 0;
}
.session-device {
font-weight: 500;
color: #333;
}
.session-meta {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.25rem;
}
.session-current {
border-color: #198754;
background: #f0fff4;
}
.session-current .badge {
background: #198754;
}
.session-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.empty-sessions {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.empty-sessions i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-3">
<!-- Боковая панель навигации -->
<div class="card mb-4">
<div class="card-body">
<div class="profile-header">
<div class="avatar-container">
{% if user.avatar %}
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
{% else %}
<div class="avatar-placeholder">
{{ user.name|first|upper }}
</div>
{% endif %}
</div>
<h5 class="mb-1">{{ user.name }}</h5>
<p class="text-muted mb-0">{{ user.email }}</p>
</div>
<hr>
<nav class="nav-pills flex-column">
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
<i class="fa-solid fa-user me-2"></i> Основное
</a>
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
<i class="fa-solid fa-building me-2"></i> Мои организации
</a>
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
</a>
</nav>
</div>
</div>
</div>
<div class="col-md-9">
<!-- Смена пароля -->
<div class="card mb-4">
<div class="card-header bg-white">
<h4 class="mb-0"><i class="fa-solid fa-key me-2"></i>Смена пароля</h4>
</div>
<div class="card-body">
<form action="{{ base_url('/profile/change-password') }}" method="post">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="current_password" class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6">
<div class="form-text">Минимум 6 символов</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Подтвердите новый пароль</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<div class="alert alert-info">
<i class="fa-solid fa-info-circle me-2"></i>
После смены пароля вы будете автоматически разлогинены на всех устройствах для безопасности.
</div>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-1"></i> Изменить пароль
</button>
</form>
</div>
</div>
<!-- Активные сессии и устройства -->
<div class="card mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="fa-solid fa-desktop me-2"></i>Активные сессии</h4>
{% if sessions|length > 0 %}
<form action="{{ base_url('/profile/sessions/revoke-all') }}" method="post" onsubmit="return confirm('Вы уверены? Это завершит сессии на всех устройствах, кроме текущего.');">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fa-solid fa-times me-1"></i>Завершить все
</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if sessions|length > 0 %}
{% for session in sessions %}
<div class="session-item{% if session.is_current %} session-current{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="session-device">
<i class="fa-solid fa-desktop me-2"></i>{{ session.device }}
{% if session.is_current %}
<span class="badge session-badge ms-2">Текущая сессия</span>
{% endif %}
</div>
<div class="session-meta">
<i class="fa-solid fa-globe me-1"></i>{{ session.ip_address }}
{% if session.expires_at %}
&nbsp;|&nbsp;
<i class="fa-solid fa-clock me-1"></i>истекает {{ session.expires_at|date('d.m.Y H:i') }}
{% endif %}
</div>
</div>
{% if not session.is_current %}
<form action="{{ base_url('/profile/session/revoke') }}" method="post">
{{ csrf_field()|raw }}
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Завершить эту сессию?');">
<i class="fa-solid fa-times"></i>
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
<div class="alert alert-info mt-3 mb-0">
<i class="fa-solid fa-info-circle me-2"></i>
<strong>Запомненные устройства:</strong> Если вы отметили "Запомнить меня" при входе, устройство будет автоматически авторизовано в течение 30 дней. Вы можете завершить эти сессии вручную.
</div>
{% else %}
<div class="empty-sessions">
<i class="fa-solid fa-shield-check"></i>
<p>Нет активных сессий на других устройствах</p>
</div>
{% endif %}
</div>
</div>
<!-- Информация о безопасности -->
<div class="card">
<div class="card-header bg-white">
<h4 class="mb-0"><i class="fa-solid fa-shield-halved me-2"></i>Рекомендации по безопасности</h4>
</div>
<div class="card-body">
<ul class="mb-0">
<li class="mb-2">Используйте пароль длиной не менее 8 символов</li>
<li class="mb-2">Комбинируйте буквы, цифры и специальные символы</li>
<li class="mb-2">Не используйте один и тот же пароль для разных сервисов</li>
<li class="mb-2">Регулярно меняйте пароль</li>
<li>Не сообщайте пароль третьим лицам</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/profile/index.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block styles %}
<style>
.profile-header {
text-align: center;
padding: 2rem 0;
}
.avatar-container {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 1rem;
}
.avatar-img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.avatar-upload-btn {
position: absolute;
bottom: 0;
right: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: #0d6efd;
color: white;
border: 3px solid #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 10;
}
.avatar-upload-btn:hover {
background: #0b5ed7;
transform: scale(1.1);
}
.nav-pills .nav-link {
color: #495057;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
margin-bottom: 0.5rem;
}
.nav-pills .nav-link.active {
background-color: #0d6efd;
color: white;
}
.nav-pills .nav-link:hover:not(.active) {
background-color: #e9ecef;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
.btn-save {
min-width: 150px;
}
#avatarPreviewModal {
max-width: 200px;
max-height: 200px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-3">
<!-- Боковая панель навигации -->
<div class="card mb-4">
<div class="card-body">
<div class="profile-header">
<div class="avatar-container">
{% if user.avatar %}
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
{% else %}
<div class="avatar-placeholder">
{{ user.name|first|upper }}
</div>
{% endif %}
<label for="avatarInput" class="avatar-upload-btn" title="Изменить аватар">
<i class="fa-solid fa-camera"></i>
</label>
<input type="file" id="avatarInput" name="avatar" accept="image/jpeg,image/png,image/gif" style="display: none;">
</div>
<h5 class="mb-1">{{ user.name }}</h5>
<p class="text-muted mb-0">{{ user.email }}</p>
</div>
<hr>
<nav class="nav-pills flex-column">
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
<i class="fa-solid fa-user me-2"></i> Основное
</a>
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
<i class="fa-solid fa-building me-2"></i> Мои организации
</a>
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
</a>
</nav>
</div>
</div>
</div>
<div class="col-md-9">
<!-- Основной контент -->
<div class="card">
<div class="card-header bg-white">
<h4 class="mb-0">Основная информация</h4>
</div>
<div class="card-body">
<form action="{{ base_url('/profile/update-name') }}" method="post">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="name" class="form-label">Имя</label>
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}" required minlength="3">
<div class="form-text">Ваше имя будет отображаться в системе</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" value="{{ user.email }}" disabled>
<div class="form-text">Email нельзя изменить (он является вашим логином)</div>
</div>
<div class="mb-3">
<label for="created_at" class="form-label">Дата регистрации</label>
<input type="text" class="form-control" id="created_at" value="{{ user.created_at|date('d.m.Y H:i') }}" disabled>
</div>
<button type="submit" class="btn btn-primary btn-save">
<i class="fa-solid fa-check me-1"></i> Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Модальное окно загрузки аватара -->
<div class="modal fade" id="avatarModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Загрузка аватара</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="{{ base_url('/profile/upload-avatar') }}" method="post" enctype="multipart/form-data" id="avatarForm">
{{ csrf_field()|raw }}
<div class="modal-body text-center">
<p class="text-muted mb-3">Выберите изображение JPG, PNG или GIF (максимум 2 МБ)</p>
<div id="avatarPreviewContainer" style="display: none; margin-bottom: 15px;">
<img id="avatarPreviewModal" src="" alt="Превью аватара">
</div>
<input type="file" class="form-control" id="avatarFile" name="avatar" accept="image/jpeg,image/png,image/gif" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-upload me-1"></i> Загрузить
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const avatarInput = document.getElementById('avatarInput');
const avatarFile = document.getElementById('avatarFile');
const avatarModal = new bootstrap.Modal(document.getElementById('avatarModal'));
const avatarPreviewModal = document.getElementById('avatarPreviewModal');
const avatarPreviewContainer = document.getElementById('avatarPreviewContainer');
// При клике на кнопку камеры - открываем модалку
avatarInput.addEventListener('click', function(e) {
e.preventDefault();
avatarModal.show();
});
// При изменении файла в модалке - показываем превью
avatarFile.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) {
avatarPreviewContainer.style.display = 'none';
return;
}
// Проверка типа файла
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
alert('Разрешены только файлы JPG, PNG и GIF');
this.value = '';
avatarPreviewContainer.style.display = 'none';
return;
}
// Проверка размера (2 МБ)
if (file.size > 2 * 1024 * 1024) {
alert('Максимальный размер файла - 2 МБ');
this.value = '';
avatarPreviewContainer.style.display = 'none';
return;
}
// Показываем превью
const reader = new FileReader();
reader.onload = function(event) {
avatarPreviewModal.src = event.target.result;
avatarPreviewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
});
// Очистка при закрытии модалки
avatarModal._element.addEventListener('hidden.bs.modal', function() {
avatarFile.value = '';
avatarPreviewContainer.style.display = 'none';
avatarPreviewModal.src = '';
});
});
</script>
{% endblock %}
// app/Views/profile/organizations.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block styles %}
<style>
.profile-header {
text-align: center;
padding: 2rem 0;
}
.avatar-container {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 1rem;
}
.avatar-img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
border: 4px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-pills .nav-link {
color: #495057;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
margin-bottom: 0.5rem;
}
.nav-pills .nav-link.active {
background-color: #0d6efd;
color: white;
}
.nav-pills .nav-link:hover:not(.active) {
background-color: #e9ecef;
}
.org-current-badge {
background-color: #198754;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
margin-left: 8px;
text-transform: uppercase;
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-3">
<!-- Боковая панель навигации -->
<div class="card mb-4">
<div class="card-body">
<div class="profile-header">
<div class="avatar-container">
{% if user.avatar %}
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
{% else %}
<div class="avatar-placeholder">
{{ user.name|first|upper }}
</div>
{% endif %}
</div>
<h5 class="mb-1">{{ user.name }}</h5>
<p class="text-muted mb-0">{{ user.email }}</p>
</div>
<hr>
<nav class="nav-pills flex-column">
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
<i class="fa-solid fa-user me-2"></i> Основное
</a>
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
<i class="fa-solid fa-building me-2"></i> Мои организации
</a>
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
</a>
</nav>
</div>
</div>
</div>
<div class="col-md-9">
<!-- Список организаций -->
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h4 class="mb-0">Мои организации</h4>
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary btn-sm">
<i class="fa-solid fa-plus me-1"></i> Создать организацию
</a>
</div>
<div class="card-body">
{% if organizations is empty %}
<div class="text-center py-5">
<i class="fa-solid fa-building text-muted mb-3" style="font-size: 48px;"></i>
<p class="text-muted mb-3">У вас пока нет организаций</p>
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-1"></i> Создать первую организацию
</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Организация</th>
<th>Тип</th>
<th>Ваша роль</th>
<th>Дата входа</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for org in organizations %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="bg-primary text-white rounded me-3 d-flex align-items-center justify-content-center" style="width:40px; height:40px;">
<i class="fa-solid fa-building"></i>
</div>
<div>
<strong>{{ org.name }}</strong>
{% if org.is_current_org %}
<span class="org-current-badge">Текущая</span>
{% endif %}
<br>
<small class="text-muted">ID: {{ org.id }}</small>
</div>
</div>
</td>
<td>
{% if org.type == 'personal' %}
<span class="badge bg-info">Личное пространство</span>
{% else %}
<span class="badge bg-success">Бизнес</span>
{% endif %}
</td>
<td>
{% if org.role == 'owner' %}
<span class="badge bg-warning text-dark">Владелец</span>
{% elseif org.role == 'admin' %}
<span class="badge bg-primary">Администратор</span>
{% elseif org.role == 'manager' %}
<span class="badge bg-secondary">Менеджер</span>
{% else %}
<span class="badge bg-light text-dark">Гость</span>
{% endif %}
</td>
<td>{{ org.joined_at ? org.joined_at|date('d.m.Y H:i') : '—' }}</td>
<td>
<div class="btn-group btn-group-sm">
{# ВЛАДЕЛЕЦ #}
{% if org.is_owner %}
{# Текущая организация #}
{% if org.is_current_org %}
<a href="{{ base_url('/organizations/' ~ org.id ~ '/dashboard') }}" class="btn btn-outline-primary" title="Дашборд">
<i class="fa-solid fa-gauge-high"></i>
</a>
<a href="{{ base_url('/organizations/' ~ org.id ~ '/edit') }}" class="btn btn-outline-secondary" title="Редактировать">
<i class="fa-solid fa-pen"></i>
</a>
<button type="button" class="btn btn-outline-danger delete-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Удалить">
<i class="fa-solid fa-trash"></i>
</button>
{% else %}
{# Не текущая организация #}
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
<i class="fa-solid fa-check"></i>
</a>
<a href="{{ base_url('/organizations/' ~ org.id ~ '/edit') }}" class="btn btn-outline-secondary" title="Редактировать">
<i class="fa-solid fa-pen"></i>
</a>
<button type="button" class="btn btn-outline-danger delete-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Удалить">
<i class="fa-solid fa-trash"></i>
</button>
{% endif %}
{% elseif org.role in ['admin', 'manager'] %}
{# Админ/Менеджер #}
{% if org.is_current_org %}
<a href="{{ base_url('/organizations/' ~ org.id ~ '/dashboard') }}" class="btn btn-outline-primary" title="Дашборд">
<i class="fa-solid fa-gauge-high"></i>
</a>
<button type="button" class="btn btn-outline-danger leave-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Покинуть">
<i class="fa-solid fa-person-walking-arrow-right"></i>
</button>
{% else %}
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
<i class="fa-solid fa-check"></i>
</a>
<button type="button" class="btn btn-outline-danger leave-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Покинуть">
<i class="fa-solid fa-person-walking-arrow-right"></i>
</button>
{% endif %}
{% else %}
{# Гость (не owner, не admin, не manager) #}
{% if org.is_current_org %}
<button type="button" class="btn btn-outline-danger leave-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Покинуть">
<i class="fa-solid fa-person-walking-arrow-right"></i>
</button>
{% else %}
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
<i class="fa-solid fa-check"></i>
</a>
<button type="button" class="btn btn-outline-danger leave-org-btn"
data-org-id="{{ org.id }}"
data-org-name="{{ org.name }}"
title="Покинуть">
<i class="fa-solid fa-person-walking-arrow-right"></i>
</button>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Модальное окно подтверждения выхода из организации -->
<div class="modal fade" id="leaveOrgModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Покинуть организацию</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Вы уверены, что хотите покинуть организацию <strong id="leaveOrgName"></strong>?</p>
<p class="text-muted mb-0">После выхода вы потеряете доступ к данным этой организации.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" id="confirmLeaveOrg">
<i class="fa-solid fa-person-walking-arrow-right me-1"></i> Покинуть
</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно подтверждения удаления организации -->
<div class="modal fade" id="deleteOrgModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Удалить организацию</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation me-2"></i>Внимание! Это действие нельзя отменить.</p>
<p>Вы уверены, что хотите удалить организацию <strong id="deleteOrgName"></strong>?</p>
<p class="text-muted mb-0">Все данные организации и её участники будут удалены.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<a href="#" class="btn btn-danger" id="confirmDeleteOrg">
<i class="fa-solid fa-trash me-1"></i> Удалить
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const leaveOrgModal = new bootstrap.Modal(document.getElementById('leaveOrgModal'));
const deleteOrgModal = new bootstrap.Modal(document.getElementById('deleteOrgModal'));
let selectedOrgId = null;
// Обработчики кнопок выхода из организации
document.querySelectorAll('.leave-org-btn').forEach(btn => {
btn.addEventListener('click', function() {
selectedOrgId = this.dataset.orgId;
const orgName = this.dataset.orgName;
document.getElementById('leaveOrgName').textContent = orgName;
leaveOrgModal.show();
});
});
// Обработчики кнопок удаления организации
document.querySelectorAll('.delete-org-btn').forEach(btn => {
btn.addEventListener('click', function() {
const orgId = this.dataset.orgId;
const orgName = this.dataset.orgName;
document.getElementById('deleteOrgName').textContent = orgName;
document.getElementById('confirmDeleteOrg').href = '{{ base_url("/organizations/") }}' + orgId + '/delete';
deleteOrgModal.show();
});
});
// Подтверждение выхода
document.getElementById('confirmLeaveOrg').addEventListener('click', function() {
if (!selectedOrgId) return;
const formData = new FormData();
fetch('{{ base_url("/organizations/") }}' + selectedOrgId + '/leave', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
leaveOrgModal.hide();
if (data.success) {
alert(data.message);
window.location.reload();
} else {
alert(data.message);
}
})
.catch(error => {
leaveOrgModal.hide();
alert('Ошибка при выходе из организации');
});
});
});
</script>
{% endblock %}
// app/Views/errors/cli/production.php
<?php
include __DIR__ . '/error_exception.php';
// app/Views/errors/cli/error_404.php
<?php
use CodeIgniter\CLI\CLI;
CLI::error('ERROR: ' . $code);
CLI::write($message);
CLI::newLine();
// app/Views/errors/cli/error_exception.php
<?php
use CodeIgniter\CLI\CLI;
CLI::write('[' . $exception::class . ']', 'light_gray', 'red');
CLI::write($message);
CLI::write('at ' . CLI::color(clean_path($exception->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
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= lang('Errors.whoops') ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
</head>
<body>
<div class="container text-center">
<h1 class="headline"><?= lang('Errors.whoops') ?></h1>
<p class="lead"><?= lang('Errors.weHitASnag') ?></p>
</div>
</body>
</html>
// app/Views/errors/html/error_400.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.badRequest') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>400</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryBadRequest') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>
// 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?= lang('Errors.pageNotFound') ?></title>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>404</h1>
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryCannotFind') ?>
<?php endif; ?>
</p>
</div>
</body>
</html>
// app/Views/errors/html/error_exception.php
<?php
use CodeIgniter\HTTP\Header;
use CodeIgniter\CodeIgniter;
$errorId = uniqid('error', true);
?>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title><?= esc($title) ?></title>
<style>
<?= preg_replace('#[\r\n\t ]+#', ' ', file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.css')) ?>
</style>
<script>
<?= file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'debug.js') ?>
</script>
</head>
<body onload="init()">
<!-- Header -->
<div class="header">
<div class="environment">
Displayed at <?= esc(date('H:i:s')) ?> &mdash;
PHP: <?= esc(PHP_VERSION) ?> &mdash;
CodeIgniter: <?= esc(CodeIgniter::CI_VERSION) ?> --
Environment: <?= ENVIRONMENT ?>
</div>
<div class="container">
<h1><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h1>
<p>
<?= nl2br(esc($exception->getMessage())) ?>
<a href="https:
rel="noreferrer" target="_blank">search &rarr;</a>
</p>
</div>
</div>
<!-- Source -->
<div class="container">
<p><b><?= esc(clean_path($file)) ?></b> at line <b><?= esc($line) ?></b></p>
<?php if (is_file($file)) : ?>
<div class="source">
<?= static::highlightFile($file, $line, 15); ?>
</div>
<?php endif; ?>
</div>
<div class="container">
<?php
$last = $exception;
while ($prevException = $last->getPrevious()) {
$last = $prevException;
?>
<pre>
Caused by:
<?= esc($prevException::class), esc($prevException->getCode() ? ' #' . $prevException->getCode() : '') ?>
<?= nl2br(esc($prevException->getMessage())) ?>
<a href="https:
rel="noreferrer" target="_blank">search &rarr;</a>
<?= esc(clean_path($prevException->getFile()) . ':' . $prevException->getLine()) ?>
</pre>
<?php
}
?>
</div>
<?php if (defined('SHOW_DEBUG_BACKTRACE') && SHOW_DEBUG_BACKTRACE) : ?>
<div class="container">
<ul class="tabs" id="tabs">
<li><a href="#backtrace">Backtrace</a></li>
<li><a href="#server">Server</a></li>
<li><a href="#request">Request</a></li>
<li><a href="#response">Response</a></li>
<li><a href="#files">Files</a></li>
<li><a href="#memory">Memory</a></li>
</ul>
<div class="tab-content">
<!-- Backtrace -->
<div class="content" id="backtrace">
<ol class="trace">
<?php foreach ($trace as $index => $row) : ?>
<li>
<p>
<!-- Trace info -->
<?php if (isset($row['file']) && is_file($row['file'])) : ?>
<?php
if (isset($row['function']) && in_array($row['function'], ['include', 'include_once', 'require', 'require_once'], true)) {
echo esc($row['function'] . ' ' . clean_path($row['file']));
} else {
echo esc(clean_path($row['file']) . ' : ' . $row['line']);
}
?>
<?php else: ?>
{PHP internal code}
<?php endif; ?>
<!-- Class/Method -->
<?php if (isset($row['class'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp;<?= esc($row['class'] . $row['type'] . $row['function']) ?>
<?php if (! empty($row['args'])) : ?>
<?php $argsId = $errorId . 'args' . $index ?>
( <a href="#" onclick="return toggle('<?= esc($argsId, 'attr') ?>');">arguments</a> )
<div class="args" id="<?= esc($argsId, 'attr') ?>">
<table cellspacing="0">
<?php
$params = null;
if (! str_ends_with($row['function'], '}')) {
$mirror = isset($row['class']) ? new ReflectionMethod($row['class'], $row['function']) : new ReflectionFunction($row['function']);
$params = $mirror->getParameters();
}
foreach ($row['args'] as $key => $value) : ?>
<tr>
<td><code><?= esc(isset($params[$key]) ? '$' . $params[$key]->name : "#{$key}") ?></code></td>
<td><pre><?= esc(print_r($value, true)) ?></pre></td>
</tr>
<?php endforeach ?>
</table>
</div>
<?php else : ?>
()
<?php endif; ?>
<?php endif; ?>
<?php if (! isset($row['class']) && isset($row['function'])) : ?>
&nbsp;&nbsp;&mdash;&nbsp;&nbsp; <?= esc($row['function']) ?>()
<?php endif; ?>
</p>
<!-- Source? -->
<?php if (isset($row['file']) && is_file($row['file']) && isset($row['class'])) : ?>
<div class="source">
<?= static::highlightFile($row['file'], $row['line']) ?>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
</div>
<!-- Server -->
<div class="content" id="server">
<?php foreach (['_SERVER', '_SESSION'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<h3>$<?= esc($var) ?></h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<!-- Constants -->
<?php $constants = get_defined_constants(true); ?>
<?php if (! empty($constants['user'])) : ?>
<h3>Constants</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($constants['user'] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Request -->
<div class="content" id="request">
<?php $request = service('request'); ?>
<table>
<tbody>
<tr>
<td style="width: 10em">Path</td>
<td><?= esc($request->getUri()) ?></td>
</tr>
<tr>
<td>HTTP Method</td>
<td><?= esc($request->getMethod()) ?></td>
</tr>
<tr>
<td>IP Address</td>
<td><?= esc($request->getIPAddress()) ?></td>
</tr>
<tr>
<td style="width: 10em">Is AJAX Request?</td>
<td><?= $request->isAJAX() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is CLI Request?</td>
<td><?= $request->isCLI() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>Is Secure Request?</td>
<td><?= $request->isSecure() ? 'yes' : 'no' ?></td>
</tr>
<tr>
<td>User Agent</td>
<td><?= esc($request->getUserAgent()->getAgentString()) ?></td>
</tr>
</tbody>
</table>
<?php $empty = true; ?>
<?php foreach (['_GET', '_POST', '_COOKIE'] as $var) : ?>
<?php
if (empty($GLOBALS[$var]) || ! is_array($GLOBALS[$var])) {
continue;
} ?>
<?php $empty = false; ?>
<h3>$<?= esc($var) ?></h3>
<table style="width: 100%">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($GLOBALS[$var] as $key => $value) : ?>
<tr>
<td><?= esc($key) ?></td>
<td>
<?php if (is_string($value)) : ?>
<?= esc($value) ?>
<?php else: ?>
<pre><?= esc(print_r($value, true)) ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach ?>
<?php if ($empty) : ?>
<div class="alert">
No $_GET, $_POST, or $_COOKIE Information to show.
</div>
<?php endif; ?>
<?php $headers = $request->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($value->getValueLine(), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Response -->
<?php
$response = service('response');
$response->setStatusCode(http_response_code());
?>
<div class="content" id="response">
<table>
<tr>
<td style="width: 15em">Response Status</td>
<td><?= esc($response->getStatusCode() . ' - ' . $response->getReasonPhrase()) ?></td>
</tr>
</table>
<?php $headers = $response->headers(); ?>
<?php if (! empty($headers)) : ?>
<h3>Headers</h3>
<table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<?php foreach ($headers as $name => $value) : ?>
<tr>
<td><?= esc($name, 'html') ?></td>
<td>
<?php
if ($value instanceof Header) {
echo esc($response->getHeaderLine($name), 'html');
} else {
foreach ($value as $i => $header) {
echo ' ('. $i+1 . ') ' . esc($header->getValueLine(), 'html');
}
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Files -->
<div class="content" id="files">
<?php $files = get_included_files(); ?>
<ol>
<?php foreach ($files as $file) :?>
<li><?= esc(clean_path($file)) ?></li>
<?php endforeach ?>
</ol>
</div>
<!-- Memory -->
<div class="content" id="memory">
<table>
<tbody>
<tr>
<td>Memory Usage</td>
<td><?= esc(static::describeMemory(memory_get_usage(true))) ?></td>
</tr>
<tr>
<td style="width: 12em">Peak Memory Usage:</td>
<td><?= esc(static::describeMemory(memory_get_peak_usage(true))) ?></td>
</tr>
<tr>
<td>Memory Limit:</td>
<td><?= esc(ini_get('memory_limit')) ?></td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /tab-content -->
</div> <!-- /container -->
<?php endif; ?>
</body>
</html>
// app/Views/emails/password_reset.twig
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сброс пароля</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.email-card {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 40px;
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo-text {
font-size: 24px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
.content {
color: #666;
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 14px 28px;
background-color: #0d6efd;
color: #ffffff;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #999;
font-size: 14px;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin: 20px 0;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="email-card">
<div class="logo">
<span class="logo-text">{{ app_name }}</span>
</div>
<h1>Сброс пароля</h1>
<div class="content">
<p>Здравствуйте, {{ name }}!</p>
<p>Мы получили запрос на сброс пароля для вашей учётной записи. Если вы не отправляли этот запрос, просто проигнорируйте это письмо.</p>
</div>
<div class="button-container">
<a href="{{ reset_url }}" class="button">Сбросить пароль</a>
</div>
<div class="content">
<p>Ссылка действительна в течение 24 часов.</p>
</div>
<div class="warning">
<strong>Внимание:</strong> Если вы не запрашивали сброс пароля, рекомендуем проверить безопасность вашей учётной записи и изменить пароль.
</div>
<div class="footer">
<p>С уважением,<br>Команда {{ app_name }}</p>
<p style="font-size: 12px; color: #999;">
Если кнопка не работает, скопируйте ссылку и вставьте её в адресную строку браузера:<br>
<a href="{{ reset_url }}" style="color: #0d6efd;">{{ reset_url }}</a>
</p>
</div>
</div>
</div>
</body>
</html>
// app/Views/emails/verification.twig
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение регистрации</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.logo {
text-align: center;
margin-bottom: 20px;
font-size: 24px;
font-weight: bold;
color: #0d6efd;
}
.btn {
display: inline-block;
padding: 12px 24px;
background-color: #0d6efd;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
text-align: center;
}
.btn:hover {
background-color: #0b5ed7;
}
.footer {
text-align: center;
margin-top: 20px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="logo">{{ app_name }}</div>
<h2>Добрый день, {{ name }}!</h2>
<p>Спасибо за регистрацию в {{ app_name }}.</p>
<p>Для завершения регистрации и подтверждения вашего email адреса, пожалуйста, нажмите на кнопку ниже:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ verification_url }}" class="btn">Подтвердить email</a>
</p>
<p>Если кнопка не работает, вы можете скопировать ссылку и вставить её в адресную строку браузера:</p>
<p style="word-break: break-all; font-size: 12px; color: #666; background: #f8f9fa; padding: 10px; border-radius: 4px;">
{{ verification_url }}
</p>
<p>Ссылка действительна в течение 24 часов.</p>
<p>Если вы не регистрировались на {{ app_name }}, просто проигнорируйте это письмо.</p>
</div>
<div class="footer">
<p>© {{ "now"|date("Y") }} {{ app_name }}. Все права защищены.</p>
</div>
</div>
</body>
</html>
// app/Views/emails/welcome.twig
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Добро пожаловать</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.logo {
text-align: center;
margin-bottom: 20px;
font-size: 24px;
font-weight: bold;
color: #198754;
}
.btn {
display: inline-block;
padding: 12px 24px;
background-color: #198754;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
text-align: center;
}
.btn:hover {
background-color: #157347;
}
.footer {
text-align: center;
margin-top: 20px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="logo">{{ app_name }}</div>
<h2>Добро пожаловать, {{ name }}!</h2>
<p>Поздравляем! Ваш email успешно подтверждён.</p>
<p>Теперь вы можете:</p>
<ul>
<li>Создавать и управлять организациями</li>
<li>Приглашать сотрудников</li>
<li>Использовать все функции платформы</li>
</ul>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ base_url('/') }}" class="btn">Перейти в личный кабинет</a>
</p>
<p>Если у вас возникнут вопросы, наша служба поддержки всегда готова помочь.</p>
<p>С уважением,<br>Команда {{ app_name }}</p>
</div>
<div class="footer">
<p>© {{ "now"|date("Y") }} {{ app_name }}. Все права защищены.</p>
</div>
</div>
</body>
</html>
// app/Views/organizations/invitation_accept.twig
{#
organizations/invitation_accept.twig - Страница принятия/отклонения приглашения
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
{# Логотип организации #}
<div class="text-center mb-4">
{% if organization.logo %}
<img src="{{ organization.logo }}" alt="{{ organization.name }}" style="max-height: 60px;">
{% else %}
<div class="bg-primary text-white rounded-3 d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fa-solid fa-building fs-3"></i>
</div>
{% endif %}
</div>
<h2 class="text-center mb-4">Приглашение в организацию</h2>
{# Информация об организации #}
<div class="text-center mb-4">
<h4 class="mb-1">{{ organization.name }}</h4>
<span class="badge bg-primary fs-6">{{ role_label }}</span>
</div>
<p class="text-center text-muted mb-4">
Вас пригласили присоединиться к организации "{{ organization.name }}"
</p>
{% if invited_by %}
<p class="text-center text-muted small mb-4">
Приглашение отправил: {{ invited_by.name|default(invited_by.email) }}
</p>
{% endif %}
{# Если пользователь не авторизован #}
{% if not is_logged_in %}
<div class="alert alert-warning">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
Для принятия приглашения необходимо войти в аккаунт или зарегистрироваться
</div>
{% endif %}
{# Если авторизован, но email не совпадает #}
{% if is_logged_in and not email_matches %}
<div class="alert alert-warning">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
Внимание! Вы вошли как <strong>{{ get_session('email') }}</strong>,
а приглашение отправлено на другой email.
</div>
{% endif %}
{# Форма принятия/отклонения #}
<form action="/invitation/accept/{{ token }}" method="POST" data-ajax="true">
{{ csrf_field()|raw }}
<input type="hidden" name="action" value="accept">
<div class="d-flex gap-3">
<button type="submit" class="btn btn-primary flex-grow-1">
<i class="fa-solid fa-check me-2"></i>Принять
</button>
<button type="submit"
name="action"
value="decline"
class="btn btn-outline-danger flex-grow-1">
<i class="fa-solid fa-xmark me-2"></i>Отклонить
</button>
</div>
</form>
{# Ссылка на вход/регистрацию #}
{% if not is_logged_in %}
<div class="text-center mt-4">
<a href="/login?redirect={{ url_encode('/invitation/accept/' ~ token) }}" class="text-muted">
<i class="fa-solid fa-sign-in-alt me-1"></i>Войти в аккаунт
</a>
<span class="text-muted mx-2">|</span>
<a href="/register?redirect={{ url_encode('/invitation/accept/' ~ token) }}" class="text-muted">
<i class="fa-solid fa-user-plus me-1"></i>Регистрация
</a>
</div>
{% endif %}
<div class="text-center mt-4 text-muted small">
<i class="fa-regular fa-clock me-1"></i>
Приглашение действительно 48 часов
</div>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/confirm_modal.twig
{#
organizations/confirm_modal.twig - Модальное окно подтверждения действия
#}
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmModalTitle">Подтверждение действия</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="confirmModalMessage" class="mb-0">Вы уверены?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="confirmModalBtn">Подтвердить</button>
</div>
</div>
</div>
</div>
// app/Views/organizations/invite_modal.twig
{#
organizations/invite_modal.twig - Модальное окно приглашения пользователя
#}
<div class="modal fade" id="inviteUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-user-plus me-2"></i>Пригласить пользователя
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="inviteUserForm" action="/organizations/users/{{ organization_id }}/invite" method="POST" data-ajax="true">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="inviteEmail" class="form-label">Email адрес</label>
<input type="email"
class="form-control"
id="inviteEmail"
name="email"
placeholder="user@example.com"
required>
<div class="form-text">На этот адрес будет отправлено приглашение</div>
</div>
<div class="mb-3">
<label for="inviteRole" class="form-label">Роль</label>
<select class="form-select" id="inviteRole" name="role" required>
{% for role_value, role_info in get_all_roles() %}
{% if role_value != 'owner' %}
<option value="{{ role_value }}">{{ role_info.label }}</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text">{{ get_all_roles()[current_role].description|default('') }}</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-paper-plane me-2"></i>Отправить приглашение
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{# Модалка с ссылкой приглашения #}
<div class="modal fade" id="inviteLinkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-check-circle text-success me-2"></i>Приглашение отправлено
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Приглашение успешно отправлено на <strong id="inviteEmailDisplay"></strong></p>
<div class="mb-3">
<label class="form-label">Ссылка для приглашения</label>
<div class="input-group">
<input type="text"
class="form-control"
id="inviteLinkInput"
readonly>
<button type="button"
class="btn btn-outline-secondary"
id="copyLinkBtn"
onclick="copyInviteLink()"
title="Копировать">
<i class="fa-regular fa-copy"></i>
</button>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" onclick="shareViaWebShare()">
<i class="fa-solid fa-share-nodes me-1"></i>Поделиться
</button>
<a href="#" onclick="shareToTelegram(); return false;" class="btn btn-outline-primary">
<i class="fa-brands fa-telegram me-1"></i>Telegram
</a>
</div>
<div class="alert alert-info mt-3 mb-0">
<i class="fa-solid fa-circle-info me-2"></i>
Если email не дошёл, можно скопировать ссылку и отправить другим способом
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
// app/Views/organizations/delete.twig
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="container" style="max-width: 500px; margin-top: 100px;">
<div class="card shadow border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0"><i class="fa-solid fa-triangle-exclamation me-2"></i>Подтверждение удаления</h5>
</div>
<div class="card-body text-center p-4">
<p class="lead">Вы уверены, что хотите удалить организацию?</p>
<div class="alert alert-warning">
<h5 class="alert-heading">{{ organization.name }}</h5>
{% if organization.inn %}
<p class="mb-0">ИНН: {{ organization.inn }}</p>
{% endif %}
{% if organization.type == 'personal' %}
<span class="badge bg-info">Личное пространство</span>
{% else %}
<span class="badge bg-secondary">Организация</span>
{% endif %}
</div>
<div class="alert alert-danger">
<strong>Внимание!</strong> Это действие нельзя отменить.<br>
Будут удалены:
<ul class="text-start mb-0 mt-2">
<li>Все данные организации</li>
<li>Все связи с пользователями</li>
<li>Все записи, связанные с организацией</li>
</ul>
</div>
{% from 'macros/forms.twig' import form_open, form_close %}
{{ form_open(base_url('/organizations/delete/' ~ organization.id)) }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger btn-lg">
<i class="fa-solid fa-trash me-2"></i>Да, удалить безвозвратно
</button>
<a href="{{ base_url('/organizations/edit/' ~ organization.id) }}" class="btn btn-outline-secondary">
Нет, отмена
</a>
</div>
{{ form_close() }}
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/edit.twig
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="container" style="max-width: 800px;">
<div class="card shadow">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Редактирование организации</h5>
<a href="{{ base_url('/organizations') }}" class="btn btn-light btn-sm">
<i class="fa-solid fa-arrow-left"></i> Назад
</a>
</div>
<div class="card-body p-4">
{% from 'macros/forms.twig' import form_open, form_close %}
{{ form_open(base_url('/organizations/edit/' ~ organization.id)) }}
{# Основная информация #}
<h6 class="text-muted mb-3">Основная информация</h6>
<div class="mb-3">
<label for="name" class="form-label">Название организации <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control {{ errors.name is defined ? 'is-invalid' : '' }}"
id="name" required value="{{ old.name|default(organization.name) }}"
placeholder="Например, ООО Ромашка">
{% if errors.name is defined %}
<div class="invalid-feedback">{{ errors.name }}</div>
{% endif %}
</div>
<hr class="my-4">
{# Регистрационные данные #}
<h6 class="text-muted mb-3">Регистрационные данные</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label for="inn" class="form-label">ИНН</label>
<input type="text" name="inn" class="form-control" id="inn"
value="{{ old.inn|default(requisites.inn) }}"
placeholder="10 или 12 цифр" maxlength="12">
</div>
<div class="col-md-4 mb-3">
<label for="ogrn" class="form-label">ОГРН</label>
<input type="text" name="ogrn" class="form-control" id="ogrn"
value="{{ old.ogrn|default(requisites.ogrn) }}"
placeholder="13 цифр" maxlength="13">
</div>
<div class="col-md-4 mb-3">
<label for="kpp" class="form-label">КПП</label>
<input type="text" name="kpp" class="form-control" id="kpp"
value="{{ old.kpp|default(requisites.kpp) }}"
placeholder="9 цифр" maxlength="9">
</div>
</div>
<hr class="my-4">
{# Адреса #}
<h6 class="text-muted mb-3">Адреса</h6>
<div class="mb-3">
<label for="legal_address" class="form-label">Юридический адрес</label>
<input type="text" name="legal_address" class="form-control" id="legal_address"
value="{{ old.legal_address|default(requisites.legal_address) }}"
placeholder="г. Москва, ул. Примерная, д. 1">
</div>
<div class="mb-3">
<label for="actual_address" class="form-label">Фактический адрес</label>
<input type="text" name="actual_address" class="form-control" id="actual_address"
value="{{ old.actual_address|default(requisites.actual_address) }}"
placeholder="г. Москва, ул. Примерная, д. 1">
<div class="form-text">Если совпадает с юридическим — оставьте пустым</div>
</div>
<hr class="my-4">
{# Контакты #}
<h6 class="text-muted mb-3">Контакты</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" name="phone" class="form-control" id="phone"
value="{{ old.phone|default(requisites.phone) }}"
placeholder="+7 (999) 123-45-67">
</div>
<div class="col-md-4 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" class="form-control" id="email"
value="{{ old.email|default(requisites.email) }}"
placeholder="info@company.ru">
</div>
<div class="col-md-4 mb-3">
<label for="website" class="form-label">Веб-сайт</label>
<input type="url" name="website" class="form-control" id="website"
value="{{ old.website|default(requisites.website) }}"
placeholder="https://company.ru">
</div>
</div>
<hr class="my-4">
{# Банковские реквизиты #}
<h6 class="text-muted mb-3">Банковские реквизиты</h6>
<div class="mb-3">
<label for="bank_name" class="form-label">Название банка</label>
<input type="text" name="bank_name" class="form-control" id="bank_name"
value="{{ old.bank_name|default(requisites.bank_name) }}"
placeholder="ПАО Сбербанк г. Москва">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="bank_bik" class="form-label">БИК</label>
<input type="text" name="bank_bik" class="form-control" id="bank_bik"
value="{{ old.bank_bik|default(requisites.bank_bik) }}"
placeholder="9 цифр" maxlength="9">
</div>
<div class="col-md-4 mb-3">
<label for="checking_account" class="form-label">Расчётный счёт</label>
<input type="text" name="checking_account" class="form-control" id="checking_account"
value="{{ old.checking_account|default(requisites.checking_account) }}"
placeholder="20 цифр" maxlength="20">
</div>
<div class="col-md-4 mb-3">
<label for="correspondent_account" class="form-label">Корреспондентский счёт</label>
<input type="text" name="correspondent_account" class="form-control" id="correspondent_account"
value="{{ old.correspondent_account|default(requisites.correspondent_account) }}"
placeholder="20 цифр" maxlength="20">
</div>
</div>
<div class="d-grid gap-2 d-md-flex mt-4">
<button type="submit" class="btn btn-success btn-lg">
<i class="fa-solid fa-check me-2"></i>Сохранить изменения
</button>
<a href="{{ base_url('/organizations') }}" class="btn btn-outline-secondary btn-lg">
Отмена
</a>
<a href="{{ base_url('/organizations/delete/' ~ organization.id) }}"
class="btn btn-outline-danger btn-lg ms-md-auto">
<i class="fa-solid fa-trash me-2"></i>Удалить организацию
</a>
</div>
{{ form_close() }}
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/users.twig
{#
organizations/users.twig - Страница управления пользователями организации
#}
{% extends 'layouts/base.twig' %}
{% block title %}Участники организации - {{ parent() }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
{# Заголовок #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">Участники организации</h1>
<h3 class="text-muted mb-0">{{ organization.name }}</h3>
</div>
{% if can_manage_users %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
<i class="fa-solid fa-user-plus me-2"></i>Пригласить пользователя
</button>
{% endif %}
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Всего участников</div>
<div class="fs-4 fw-bold">{{ users|length }}</div>
</div>
<i class="fa-solid fa-users fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Активных</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'active')|length }}</div>
</div>
<i class="fa-solid fa-check-circle fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Ожидают ответа</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'pending')|length }}</div>
</div>
<i class="fa-solid fa-clock fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-danger text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Заблокировано</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'blocked')|length }}</div>
</div>
<i class="fa-solid fa-ban fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
{# Таблица пользователей #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
{# Кнопка выхода из организации #}
{% if current_role != 'owner' %}
<div class="mt-4">
<button type="button" class="btn btn-outline-danger" onclick="leaveOrganization()">
<i class="fa-solid fa-sign-out-alt me-2"></i>Покинуть организацию
</button>
</div>
{% endif %}
</div>
{# Модалка приглашения #}
{% include 'organizations/invite_modal.twig' %}
{# Модалка подтверждения действий #}
{% include 'organizations/confirm_modal.twig' %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
<script>
// Функции для действий с пользователями
function openEditRoleModal(userId, email, currentRole) {
document.getElementById('editRoleUserId').value = userId;
document.getElementById('editRoleUserEmail').textContent = email;
document.getElementById('editRoleSelect').value = currentRole;
const modal = new bootstrap.Modal(document.getElementById('editRoleModal'));
modal.show();
}
function saveRole() {
const userId = document.getElementById('editRoleUserId').value;
const role = document.getElementById('editRoleSelect').value;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/organizations/users/' + {{ organization_id }} + '/role';
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
const userIdInput = document.createElement('input');
userIdInput.type = 'hidden';
userIdInput.name = 'user_id';
userIdInput.value = userId;
form.appendChild(userIdInput);
const roleInput = document.createElement('input');
roleInput.type = 'hidden';
roleInput.name = 'role';
roleInput.value = role;
form.appendChild(roleInput);
document.body.appendChild(form);
form.submit();
}
function blockUser(userId) {
showConfirmModal(
'Блокировка пользователя',
'Вы уверены, что хотите заблокировать этого пользователя? Он потеряет доступ к организации.',
'/organizations/users/' + {{ organization_id }} + '/block/' + userId
);
}
function unblockUser(userId) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/organizations/users/' + {{ organization_id }} + '/unblock/' + userId;
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
}
function removeUser(userId) {
showConfirmModal(
'Удаление пользователя',
'Вы уверены, что хотите удалить этого пользователя из организации? Это действие нельзя отменить.',
'/organizations/users/' + {{ organization_id }} + '/remove/' + userId
);
}
function leaveOrganization() {
showConfirmModal(
'Покинуть организацию',
'Вы уверены, что хотите покинуть эту организацию?',
'/organizations/users/' + {{ organization_id }} + '/leave',
true
);
}
// Функция показа модалки подтверждения
function showConfirmModal(title, message, actionUrl, isDanger) {
document.getElementById('confirmModalTitle').textContent = title;
document.getElementById('confirmModalMessage').textContent = message;
const btn = document.getElementById('confirmModalBtn');
btn.onclick = function() {
const form = document.createElement('form');
form.method = 'POST';
form.action = actionUrl;
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
};
if (isDanger) {
btn.className = 'btn btn-danger';
} else {
btn.className = 'btn btn-primary';
}
const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
modal.show();
}
// Обработка отправки приглашения
document.getElementById('inviteUserForm').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-2"></i>Отправка...';
submitBtn.disabled = true;
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Показываем ссылку приглашения
showInviteLinkModal(data.invite_link, data.email);
form.reset();
// Перезагружаем страницу чтобы увидеть нового пользователя
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
alert(data.message || 'Ошибка при отправке приглашения');
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка');
})
.finally(() => {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
});
});
function showInviteLinkModal(link, email) {
document.getElementById('inviteLinkInput').value = link;
document.getElementById('inviteEmailDisplay').textContent = email;
// Скрываем модалку приглашения
bootstrap.Modal.getInstance(document.getElementById('inviteUserModal')).hide();
// Показываем модалку со ссылкой
const modal = new bootstrap.Modal(document.getElementById('inviteLinkModal'));
modal.show();
}
function copyInviteLink() {
const input = document.getElementById('inviteLinkInput');
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(() => {
const btn = document.getElementById('copyLinkBtn');
const originalIcon = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => {
btn.innerHTML = originalIcon;
}, 2000);
});
}
function shareToTelegram() {
const link = document.getElementById('inviteLinkInput').value;
const text = encodeURIComponent('Присоединяйся к нашей организации!');
window.open('https://t.me/share/url?url=' + encodeURIComponent(link) + '&text=' + text, '_blank');
}
function shareViaWebShare() {
const link = document.getElementById('inviteLinkInput').value;
if (navigator.share) {
navigator.share({
title: 'Приглашение в Бизнес.Точка',
text: 'Присоединяйся к нашей организации!',
url: link
});
}
}
</script>
{% endblock %}
// app/Views/organizations/invitation_expired.twig
{#
organizations/invitation_expired.twig - Страница истёкшего/недействительного приглашения
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h3 class="mb-3">{{ title }}</h3>
<p class="text-muted mb-4">
Это приглашение недействительно или уже было обработано.<br>
Возможно, оно истекло или было отозвано отправителем.
</p>
{% if expired and expired_at %}
<div class="alert alert-info mb-4">
<i class="fa-solid fa-clock me-2"></i>
Приглашение истекло {{ expired_at|date("d.m.Y в H:i") }}
</div>
{% endif %}
<div class="d-grid gap-2">
<a href="/" class="btn btn-primary">
<i class="fa-solid fa-home me-2"></i>На главную
</a>
<a href="/login" class="btn btn-outline-secondary">
<i class="fa-solid fa-sign-in-alt me-2"></i>Войти в аккаунт
</a>
</div>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/invitation_complete.twig
{#
organizations/invitation_complete.twig - Страница завершения регистрации нового пользователя
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
{# Логотип организации #}
<div class="text-center mb-4">
{% if organization.logo %}
<img src="{{ organization.logo }}" alt="{{ organization.name }}" style="max-height: 60px;">
{% else %}
<div class="bg-primary text-white rounded-3 d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fa-solid fa-building fs-3"></i>
</div>
{% endif %}
</div>
<h2 class="text-center mb-3">Завершение регистрации</h2>
<p class="text-center text-muted mb-4">
Вы приняли приглашение в организацию "{{ organization.name }}"
<br>
в роли <span class="badge bg-primary">{{ role_label }}</span>
</p>
<p class="text-center text-muted mb-4">
Пожалуйста, создайте пароль для вашего аккаунта
</p>
{# Ошибки валидации #}
{% if get_alerts()|filter(a => a.type == 'error')|length > 0 %}
<div class="alert alert-danger">
{% for alert in get_alerts() %}
{% if alert.type == 'error' %}
<div>{{ alert.message }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Форма регистрации #}
<form action="/invitation/complete/{{ token }}" method="POST" data-ajax="true">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="completeName" class="form-label">Ваше имя</label>
<input type="text"
class="form-control {% if old.name is defined and old.name is empty %}is-invalid{% endif %}"
id="completeName"
name="name"
value="{{ old.name|default('') }}"
placeholder="Иван Иванов"
required>
{% if old.name is defined and old.name is empty %}
<div class="invalid-feedback">Имя обязательно</div>
{% endif %}
</div>
<div class="mb-3">
<label for="completeEmail" class="form-label">Email</label>
<input type="email"
class="form-control"
id="completeEmail"
value="{{ email }}"
disabled>
<div class="form-text">Email подтверждён через приглашение</div>
</div>
<div class="mb-3">
<label for="completePassword" class="form-label">Пароль</label>
<input type="password"
class="form-control"
id="completePassword"
name="password"
minlength="8"
required>
<div class="form-text">Минимум 8 символов</div>
</div>
<div class="mb-4">
<label for="completePasswordConfirm" class="form-label">Подтвердите пароль</label>
<input type="password"
class="form-control"
id="completePasswordConfirm"
name="password_confirm"
required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa-solid fa-check me-2"></i>Завершить регистрацию
</button>
</div>
</form>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/edit_role_modal.twig
{#
organizations/edit_role_modal.twig - Модальное окно изменения роли пользователя
#}
<div class="modal fade" id="editRoleModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-user-tag me-2"></i>Изменить роль
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Пользователь: <strong id="editRoleUserEmail"></strong></p>
<div class="mb-3">
<label for="editRoleSelect" class="form-label">Роль</label>
<select class="form-select" id="editRoleSelect">
{% for role_value, role_info in get_all_roles() %}
{% if role_value != 'owner' %}
<option value="{{ role_value }}">{{ role_info.label }}</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text" id="roleDescription"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="saveRole()">
<i class="fa-solid fa-save me-2"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<script>
const roleDescriptions = {
'admin': 'Администратор может управлять пользователями и модулями организации',
'manager': 'Менеджер имеет полный доступ к функционалу модулей',
'guest': 'Гость может только просматривать данные'
};
document.getElementById('editRoleSelect').addEventListener('change', function() {
const desc = roleDescriptions[this.value] || '';
document.getElementById('roleDescription').textContent = desc;
});
// Инициализация при открытии
document.getElementById('editRoleModal').addEventListener('shown.bs.modal', function() {
const role = document.getElementById('editRoleSelect').value;
document.getElementById('roleDescription').textContent = roleDescriptions[role] || '';
});
</script>
// app/Views/organizations/create.twig
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="container" style="max-width: 800px;">
<div class="card shadow">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Создание организации</h5>
<a href="{{ base_url('/organizations') }}" class="btn btn-light btn-sm">
<i class="fa-solid fa-arrow-left"></i> Назад
</a>
</div>
<div class="card-body p-4">
{% from 'macros/forms.twig' import form_open, form_close %}
{{ form_open(base_url('/organizations/create')) }}
{# Основная информация #}
<h6 class="text-muted mb-3">Основная информация</h6>
<div class="mb-3">
<label for="name" class="form-label">Название организации <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control {{ errors.name is defined ? 'is-invalid' : '' }}"
id="name" required value="{{ old.name|default('') }}"
placeholder="Например, ООО Ромашка">
{% if errors.name is defined %}
<div class="invalid-feedback">{{ errors.name }}</div>
{% endif %}
</div>
<hr class="my-4">
{# Регистрационные данные #}
<h6 class="text-muted mb-3">Регистрационные данные</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label for="inn" class="form-label">ИНН</label>
<input type="text" name="inn" class="form-control" id="inn"
value="{{ old.inn|default('') }}"
placeholder="10 или 12 цифр" maxlength="12">
</div>
<div class="col-md-4 mb-3">
<label for="ogrn" class="form-label">ОГРН</label>
<input type="text" name="ogrn" class="form-control" id="ogrn"
value="{{ old.ogrn|default('') }}"
placeholder="13 цифр" maxlength="13">
</div>
<div class="col-md-4 mb-3">
<label for="kpp" class="form-label">КПП</label>
<input type="text" name="kpp" class="form-control" id="kpp"
value="{{ old.kpp|default('') }}"
placeholder="9 цифр" maxlength="9">
</div>
</div>
<hr class="my-4">
{# Адреса #}
<h6 class="text-muted mb-3">Адреса</h6>
<div class="mb-3">
<label for="legal_address" class="form-label">Юридический адрес</label>
<input type="text" name="legal_address" class="form-control" id="legal_address"
value="{{ old.legal_address|default('') }}"
placeholder="г. Москва, ул. Примерная, д. 1">
</div>
<div class="mb-3">
<label for="actual_address" class="form-label">Фактический адрес</label>
<input type="text" name="actual_address" class="form-control" id="actual_address"
value="{{ old.actual_address|default('') }}"
placeholder="г. Москва, ул. Примерная, д. 1">
<div class="form-text">Если совпадает с юридическим — оставьте пустым</div>
</div>
<hr class="my-4">
{# Контакты #}
<h6 class="text-muted mb-3">Контакты</h6>
<div class="row">
<div class="col-md-4 mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" name="phone" class="form-control" id="phone"
value="{{ old.phone|default('') }}"
placeholder="+7 (999) 123-45-67">
</div>
<div class="col-md-4 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" class="form-control" id="email"
value="{{ old.email|default('') }}"
placeholder="info@company.ru">
</div>
<div class="col-md-4 mb-3">
<label for="website" class="form-label">Веб-сайт</label>
<input type="url" name="website" class="form-control" id="website"
value="{{ old.website|default('') }}"
placeholder="https://company.ru">
</div>
</div>
<hr class="my-4">
{# Банковские реквизиты #}
<h6 class="text-muted mb-3">Банковские реквизиты</h6>
<div class="mb-3">
<label for="bank_name" class="form-label">Название банка</label>
<input type="text" name="bank_name" class="form-control" id="bank_name"
value="{{ old.bank_name|default('') }}"
placeholder="ПАО Сбербанк г. Москва">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="bank_bik" class="form-label">БИК</label>
<input type="text" name="bank_bik" class="form-control" id="bank_bik"
value="{{ old.bank_bik|default('') }}"
placeholder="9 цифр" maxlength="9">
</div>
<div class="col-md-4 mb-3">
<label for="checking_account" class="form-label">Расчётный счёт</label>
<input type="text" name="checking_account" class="form-control" id="checking_account"
value="{{ old.checking_account|default('') }}"
placeholder="20 цифр" maxlength="20">
</div>
<div class="col-md-4 mb-3">
<label for="correspondent_account" class="form-label">Корреспондентский счёт</label>
<input type="text" name="correspondent_account" class="form-control" id="correspondent_account"
value="{{ old.correspondent_account|default('') }}"
placeholder="20 цифр" maxlength="20">
</div>
</div>
<div class="d-grid gap-2 d-md-flex mt-4">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa-solid fa-check me-2"></i>Создать организацию
</button>
<a href="{{ base_url('/organizations') }}" class="btn btn-outline-secondary btn-lg">
Отмена
</a>
</div>
{{ form_close() }}
</div>
</div>
</div>
{% endblock %}
// app/Views/organizations/index.twig
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="container" style="max-width: 900px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Мои организации</h4>
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Создать организацию
</a>
</div>
{% if organizations is empty %}
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<i class="fa-solid fa-building display-1 text-muted mb-3"></i>
<h5 class="text-muted">Организаций пока нет</h5>
<p class="text-muted">Создайте свою первую организацию для начала работы</p>
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Создать организацию
</a>
</div>
</div>
{% else %}
<div class="row">
{% for org in organizations %}
{# Декодируем JSON реквизиты #}
{% set req = org.requisites ? org.requisites|json_decode : {} %}
<div class="col-md-6 mb-3">
<div class="card shadow-sm h-100 {{ org.id == session.get('active_org_id') ? 'border-primary' : '' }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">
{% if org.type == 'personal' %}
<i class="fa-solid fa-user text-info me-2"></i>
{% else %}
<i class="fa-solid fa-building text-primary me-2"></i>
{% endif %}
{{ org.name }}
</h5>
<small class="text-muted">
{% if org.type == 'personal' %}
Личное пространство
{% else %}
{% if req.inn %}ИНН: {{ req.inn }}{% endif %}
{% endif %}
</small>
</div>
{% if org.id == session.get('active_org_id') %}
<span class="badge bg-primary">Активна</span>
{% endif %}
</div>
{# Краткая информация #}
{% if req.phone or req.email %}
<div class="mb-3">
{% if req.phone %}
<small class="d-block text-muted">
<i class="fa-solid fa-phone me-1"></i>{{ req.phone }}
</small>
{% endif %}
{% if req.email %}
<small class="d-block text-muted">
<i class="fa-solid fa-envelope me-1"></i>{{ req.email }}
</small>
{% endif %}
</div>
{% endif %}
<div class="d-flex flex-wrap gap-2">
{# Кнопка выбора/переключения #}
{% if org.id != session.get('active_org_id') %}
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fa-solid fa-check me-1"></i>Выбрать
</a>
{% endif %}
{# Кнопка редактирования #}
<a href="{{ base_url('/organizations/edit/' ~ org.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-pen me-1"></i>Ред.
</a>
{# Кнопка удаления (только для бизнес-организаций) #}
{% if org.type == 'business' %}
<a href="{{ base_url('/organizations/delete/' ~ org.id) }}"
class="btn btn-outline-danger btn-sm">
<i class="fa-solid fa-trash me-1"></i>Удалить
</a>
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent text-muted small">
Создана: {{ org.created_at|date('d.m.Y') }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Статистика #}
<div class="mt-4 text-center text-muted small">
Всего организаций: {{ count }}
</div>
</div>
{% endblock %}
// app/Views/organizations/dashboard.twig
{% extends 'layouts/base.twig' %}
{% block title %}Управление организацией - {{ organization.name }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
{# Заголовок #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">Управление организацией</h1>
<h3 class="text-muted mb-0">{{ organization.name }}</h3>
</div>
<div class="text-end">
{{ role_badge(current_role) }}
</div>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-primary">{{ stats.users_total }}</div>
<div class="text-muted">Всего участников</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-success">{{ stats.users_active }}</div>
<div class="text-muted">Активных</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-warning">{{ stats.users_blocked }}</div>
<div class="text-muted">Заблокировано</div>
</div>
</div>
</div>
</div>
{# Карточки управления #}
<div class="row g-4">
{# Управление командой #}
{% if can_manage_users %}
<div class="col-md-6 col-lg-4">
<a href="{{ base_url('/organizations/'~ organization.id ~ '/users' ) }}" class="card h-100 text-decoration-none border-0 shadow-sm card-hover">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-primary bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-users-gear fs-3 text-primary"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Управление командой</h5>
<p class="card-text text-muted small mb-0">Приглашайте, блокируйте и управляйте ролями участников организации</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-primary fw-medium">Перейти <i class="fa-solid fa-arrow-right ms-1"></i></span>
</div>
</a>
</div>
{% endif %}
{# Редактирование организации #}
<div class="col-md-6 col-lg-4">
<a href="{{ base_url('/organizations/edit/' ~ organization.id) }}" class="card h-100 text-decoration-none border-0 shadow-sm card-hover">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-info bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-building fs-3 text-info"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Реквизиты организации</h5>
<p class="card-text text-muted small mb-0">Измените название, адрес, банковские реквизиты и другую информацию</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-info fw-medium">Редактировать <i class="fa-solid fa-arrow-right ms-1"></i></span>
</div>
</a>
</div>
{# Модули организации - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-secondary bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-puzzle fs-3 text-secondary"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Модули</h5>
<p class="card-text text-muted small mb-0">Управление подключёнными модулями и функционалом организации</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Биллинг - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-success bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-credit-card fs-3 text-success"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Биллинг и оплата</h5>
<p class="card-text text-muted small mb-0">Просмотр счетов, история платежей и управление подпиской</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Приглашения - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-warning bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-envelope-open-text fs-3 text-warning"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">История приглашений</h5>
<p class="card-text text-muted small mb-0">Просмотр отправленных и отклонённых приглашений</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Настройки безопасности - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-danger bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-shield-halved fs-3 text-danger"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Безопасность</h5>
<p class="card-text text-muted small mb-0">Настройки безопасности, двухфакторная аутентификация, логи</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
</div>
</div>
<style>
.card-hover {
transition: all 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
{% endblock %}
// app/Views/dashboard/index.twig
{# app/Views/dashboard/index.twig #}
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<h2>{% if current_org %}Добро пожаловать в {{ current_org.name }}!{% else %}Добро пожаловать!{% endif %}</h2>
<p class="text-muted">Ваш личный кабинет для управления бизнесом.</p>
</div>
</div>
<div class="row">
<!-- Карточки модулей (плейсхолдеры) -->
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3">
<div class="card-body">
<i class="fa-solid fa-building fa-3x text-primary mb-3"></i>
<h5 class="card-title">Клиенты</h5>
<p class="card-text text-muted">Управление клиентами</p>
<a href="/clients" class="btn btn-outline-primary btn-sm">Открыть</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3">
<div class="card-body">
<i class="fa-solid fa-chart-line fa-3x text-primary mb-3"></i>
<h5 class="card-title">CRM</h5>
<p class="card-text">Управление сделками</p>
<a href="/crm" class="btn btn-outline-primary btn-sm">Открыть</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3">
<div class="card-body">
<i class="fa-solid fa-calendar-check fa-3x text-success mb-3"></i>
<h5 class="card-title">Booking</h5>
<p class="card-text text-muted">Запись на приём</p>
<a href="#" class="btn btn-outline-success btn-sm">Скоро</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3">
<div class="card-body">
<i class="fa-solid fa-file-signature fa-3x text-warning mb-3"></i>
<h5 class="card-title">Proof</h5>
<p class="card-text text-muted">Согласование файлов</p>
<a href="#" class="btn btn-outline-warning btn-sm">Скоро</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3">
<div class="card-body">
<i class="fa-solid fa-list-check fa-3x text-danger mb-3"></i>
<h5 class="card-title">Tasks</h5>
<p class="card-text text-muted">Задачи и проекты</p>
<a href="#" class="btn btn-outline-danger btn-sm">Скоро</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/welcome_message.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome to CodeIgniter 4!</title>
<meta name="description" content="The small framework with powerful features">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" type="image/png" href="/favicon.ico">
<!-- STYLES -->
<style {csp-style-nonce}>
transition: background-color 300ms ease, color 300ms ease;
}
background-color: rgba(221, 72, 20, .2);
outline: none;
}
html, body {
color: rgba(33, 37, 41, 1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 16px;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
header {
background-color: rgba(247, 248, 249, 1);
padding: .4rem 0 0;
}
.menu {
padding: .4rem 2rem;
}
header ul {
border-bottom: 1px solid rgba(242, 242, 242, 1);
list-style-type: none;
margin: 0;
overflow: hidden;
padding: 0;
text-align: right;
}
header li {
display: inline-block;
}
header li a {
border-radius: 5px;
color: rgba(0, 0, 0, .5);
display: block;
height: 44px;
text-decoration: none;
}
header li.menu-item a {
border-radius: 5px;
margin: 5px 0;
height: 38px;
line-height: 36px;
padding: .4rem .65rem;
text-align: center;
}
header li.menu-item a:hover,
header li.menu-item a:focus {
background-color: rgba(221, 72, 20, .2);
color: rgba(221, 72, 20, 1);
}
header .logo {
float: left;
height: 44px;
padding: .4rem .5rem;
}
header .menu-toggle {
display: none;
float: right;
font-size: 2rem;
font-weight: bold;
}
header .menu-toggle button {
background-color: rgba(221, 72, 20, .6);
border: none;
border-radius: 3px;
color: rgba(255, 255, 255, 1);
cursor: pointer;
font: inherit;
font-size: 1.3rem;
height: 36px;
padding: 0;
margin: 11px 0;
overflow: visible;
width: 40px;
}
header .menu-toggle button:hover,
header .menu-toggle button:focus {
background-color: rgba(221, 72, 20, .8);
color: rgba(255, 255, 255, .8);
}
header .heroe {
margin: 0 auto;
max-width: 1100px;
padding: 1rem 1.75rem 1.75rem 1.75rem;
}
header .heroe h1 {
font-size: 2.5rem;
font-weight: 500;
}
header .heroe h2 {
font-size: 1.5rem;
font-weight: 300;
}
section {
margin: 0 auto;
max-width: 1100px;
padding: 2.5rem 1.75rem 3.5rem 1.75rem;
}
section h1 {
margin-bottom: 2.5rem;
}
section h2 {
font-size: 120%;
line-height: 2.5rem;
padding-top: 1.5rem;
}
section pre {
background-color: rgba(247, 248, 249, 1);
border: 1px solid rgba(242, 242, 242, 1);
display: block;
font-size: .9rem;
margin: 2rem 0;
padding: 1rem 1.5rem;
white-space: pre-wrap;
word-break: break-all;
}
section code {
display: block;
}
section a {
color: rgba(221, 72, 20, 1);
}
section svg {
margin-bottom: -5px;
margin-right: 5px;
width: 25px;
}
.further {
background-color: rgba(247, 248, 249, 1);
border-bottom: 1px solid rgba(242, 242, 242, 1);
border-top: 1px solid rgba(242, 242, 242, 1);
}
.further h2:first-of-type {
padding-top: 0;
}
.svg-stroke {
fill: none;
stroke: #000;
stroke-width: 32px;
}
footer {
background-color: rgba(221, 72, 20, .8);
text-align: center;
}
footer .environment {
color: rgba(255, 255, 255, 1);
padding: 2rem 1.75rem;
}
footer .copyrights {
background-color: rgba(62, 62, 62, 1);
color: rgba(200, 200, 200, 1);
padding: .25rem 1.75rem;
}
@media (max-width: 629px) {
header ul {
padding: 0;
}
header .menu-toggle {
padding: 0 1rem;
}
header .menu-item {
background-color: rgba(244, 245, 246, 1);
border-top: 1px solid rgba(242, 242, 242, 1);
margin: 0 15px;
width: calc(100% - 30px);
}
header .menu-toggle {
display: block;
}
header .hidden {
display: none;
}
header li.menu-item a {
background-color: rgba(221, 72, 20, .1);
}
header li.menu-item a:hover,
header li.menu-item a:focus {
background-color: rgba(221, 72, 20, .7);
color: rgba(255, 255, 255, .8);
}
}
</style>
</head>
<body>
<!-- HEADER: MENU + HEROE SECTION -->
<header>
<div class="menu">
<ul>
<li class="logo">
<a href="https:
<svg role="img" aria-label="Visit CodeIgniter.com official website!" xmlns="http:
</a>
</li>
<li class="menu-toggle">
<button id="menuToggle">&#9776;</button>
</li>
<li class="menu-item hidden"><a href="#">Home</a></li>
<li class="menu-item hidden"><a href="https:
</li>
<li class="menu-item hidden"><a href="https:
<li class="menu-item hidden"><a
href="https:
</li>
</ul>
</div>
<div class="heroe">
<h1>Welcome to CodeIgniter <?= CodeIgniter\CodeIgniter::CI_VERSION ?></h1>
<h2>The small framework with powerful features</h2>
</div>
</header>
<!-- CONTENT -->
<section>
<h1>About this page</h1>
<p>The page you are looking at is being generated dynamically by CodeIgniter.</p>
<p>If you would like to edit this page you will find it located at:</p>
<pre><code>app/Views/welcome_message.php</code></pre>
<p>The corresponding controller for this page can be found at:</p>
<pre><code>app/Controllers/Home.php</code></pre>
</section>
<div class="further">
<section>
<h1>Go further</h1>
<h2>
<svg xmlns='http:
Learn
</h2>
<p>The User Guide contains an introduction, tutorial, a number of "how to"
guides, and then reference documentation for the components that make up
the framework. Check the <a href="https:
target="_blank">User Guide</a> !</p>
<h2>
<svg xmlns='http:
Discuss
</h2>
<p>CodeIgniter is a community-developed open source project, with several
venues for the community members to gather and exchange ideas. View all
the threads on <a href="https:
target="_blank">CodeIgniter's forum</a>, or <a href="https:
target="_blank">chat on Slack</a> !</p>
<h2>
<svg xmlns='http:
Contribute
</h2>
<p>CodeIgniter is a community driven project and accepts contributions
of code and documentation from the community. Why not
<a href="https:
join us</a> ?</p>
</section>
</div>
<!-- FOOTER: DEBUG INFO + COPYRIGHTS -->
<footer>
<div class="environment">
<p>Page rendered in {elapsed_time} seconds using {memory_usage} MB of memory.</p>
<p>Environment: <?= ENVIRONMENT ?></p>
</div>
<div class="copyrights">
<p>&copy; <?= date('Y') ?> CodeIgniter Foundation. CodeIgniter is open source project released under the MIT
open source licence.</p>
</div>
</footer>
<!-- SCRIPTS -->
<script {csp-script-nonce}>
document.getElementById("menuToggle").addEventListener('click', toggleMenu);
function toggleMenu() {
var menuItems = document.getElementsByClassName('menu-item');
for (var i = 0; i < menuItems.length; i++) {
var menuItem = menuItems[i];
menuItem.classList.toggle("hidden");
}
}
</script>
<!-- -->
</body>
</html>
// app/Views/landing/index.twig
{# app/Views/Landing/index.twig #}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Бизнес.Точка - Автоматизация для бизнеса</title>
<link href="{{ base_url('assets/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ base_url('assets/css/all.min.css') }}" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
<nav class="d-flex justify-content-between align-items-center py-3">
<h3 class="text-primary fw-bold m-0"><i class="fa-solid fa-circle-nodes"></i> Бизнес.Точка</h3>
<div>
<a href="{{ base_url('/login') }}" class="btn btn-outline-primary me-2">Войти</a>
<a href="{{ base_url('/register') }}" class="btn btn-primary">Попробовать бесплатно</a>
</div>
</nav>
<div class="text-center py-5 my-5">
<h1 class="display-4 fw-bold mb-3">Всё для вашего бизнеса</h1>
<p class="lead text-muted mb-4">CRM, Запись клиентов, Задачи и Согласование файлов в одном месте.</p>
<div class="d-flex justify-content-center gap-3">
<a href="{{ base_url('/register') }}" class="btn btn-primary btn-lg px-5 shadow">Начать бесплатно</a>
</div>
</div>
</div>
</body>
</html>
// app/Views/auth/register_success.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center" style="min-height: 80vh;">
<div class="card shadow-lg border-0" style="max-width: 500px; width: 100%;">
<div class="card-body p-5 text-center">
<div class="mb-4">
<i class="fa-solid fa-envelope-circle-check display-1 text-primary"></i>
</div>
<h3 class="mb-3">Регистрация успешна!</h3>
<p class="text-muted mb-4">
Мы отправили письмо с ссылкой для подтверждения email на вашу почту.
</p>
<div class="alert alert-info text-start">
<i class="fa-solid fa-info-circle me-2"></i>
<strong>Что делать дальше:</strong>
<ol class="mb-0 mt-2">
<li>Откройте почтовый ящик</li>
<li>Найдите письмо от нас</li>
<li>Нажмите на ссылку для подтверждения</li>
</ol>
</div>
<div class="mb-4">
<a href="/auth/resend-verification" class="text-muted small">
<i class="fa-solid fa-rotate-right me-1"></i>Не получили письмо? Отправить снова
</a>
</div>
<hr class="my-4">
<a href="/" class="btn btn-outline-secondary">
<i class="fa-solid fa-home me-2"></i>На главную
</a>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/resend_verification.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center" style="min-height: 80vh;">
<div class="card shadow-lg border-0" style="max-width: 500px; width: 100%;">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="fa-solid fa-envelope-open-text display-4 text-primary"></i>
<h4 class="mt-3">Повторная отправка письма</h4>
<p class="text-muted">Введите ваш email для получения новой ссылки подтверждения</p>
</div>
{% from 'macros/forms.twig' import form_open, form_close %}
{{ form_open(base_url('/auth/resend-verification')) }}
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" class="form-control {{ errors.email is defined ? 'is-invalid' : '' }}"
id="email" required value="{{ old.email|default('') }}"
placeholder="name@example.com">
{% if errors.email is defined %}
<div class="invalid-feedback">{{ errors.email }}</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-paper-plane me-2"></i>Отправить письмо
</button>
<a href="/" class="btn btn-outline-secondary">
На главную
</a>
</div>
{{ form_close() }}
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/reset_password.twig
{% extends 'layouts/public.twig' %}
{% block title %}Сброс пароля - {{ parent() }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-white">
<h4 class="mb-0 text-center">
<i class="fa-solid fa-key me-2"></i>Сброс пароля
</h4>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
{{ error }}
</div>
<div class="text-center mt-3">
<a href="{{ base_url('/forgot-password') }}" class="btn btn-primary">
<i class="fa-solid fa-repeat me-1"></i>Запросить ссылку снова
</a>
</div>
{% else %}
<p class="text-muted text-center mb-4">
Введите новый пароль для учётной записи <strong>{{ email }}</strong>
</p>
<form action="{{ base_url('/forgot-password/update') }}" method="post">
{{ csrf_field()|raw }}
<input type="hidden" name="token" value="{{ token }}">
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password"
class="form-control"
id="password"
name="password"
placeholder="Минимум 6 символов"
required
minlength="6"
autofocus>
<div class="form-text">Минимум 6 символов</div>
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">Подтвердите пароль</label>
<input type="password"
class="form-control"
id="password_confirm"
name="password_confirm"
placeholder="Повторите пароль"
required>
</div>
<div class="alert alert-info">
<i class="fa-solid fa-info-circle me-2"></i>
После смены пароля вы будете автоматически разлогинены на всех устройствах.
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-1"></i>Изменить пароль
</button>
</div>
</form>
{% endif %}
</div>
<div class="card-footer bg-white text-center">
<a href="{{ base_url('/login') }}" class="text-muted">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/forgot_password.twig
{% extends 'layouts/public.twig' %}
{% block title %}Восстановление пароля - {{ parent() }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-white">
<h4 class="mb-0 text-center">
<i class="fa-solid fa-key me-2"></i>Восстановление пароля
</h4>
</div>
<div class="card-body">
{% if success %}
<div class="alert alert-success">
<i class="fa-solid fa-check-circle me-2"></i>
{{ success }}
</div>
<div class="text-center mt-3">
<a href="{{ base_url('/login') }}" class="btn btn-primary">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
{% else %}
<p class="text-muted text-center mb-4">
Введите email, на который зарегистрирована ваша учётная запись.
Мы отправим вам ссылку для сброса пароля.
</p>
{% if error %}
<div class="alert alert-danger">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
{{ error }}
</div>
{% endif %}
<form action="{{ base_url('/forgot-password/send') }}" method="post">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="email" class="form-label">Email адрес</label>
<input type="email"
class="form-control"
id="email"
name="email"
value="{{ old('email') }}"
placeholder="name@example.com"
required
autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-paper-plane me-1"></i>Отправить ссылку
</button>
</div>
</form>
{% endif %}
</div>
<div class="card-footer bg-white text-center">
<a href="{{ base_url('/login') }}" class="text-muted">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/register.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="card shadow-sm" style="width: 100%; max-width: 400px;">
<div class="card-body p-4">
<h3 class="text-center mb-4">Регистрация</h3>
{# ИСПОЛЬЗУЕМ МАКРОС form_open. CSRF добавится АВТОМАТИЧЕСКИ #}
{{ form_open(base_url('/register'), 'class="needs-validation"') }}
<div class="mb-3">
<label for="name" class="form-label">Ваше имя</label>
<input type="text" name="name" class="form-control" id="name" required value="{{ old.name|default('') }}">
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" class="form-control" id="email" required value="{{ old.email|default('') }}">
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" name="password" class="form-control" id="password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary w-100">Создать аккаунт</button>
{# ЗАКРЫВАЕМ ФОРМУ МАКРОСОМ #}
{{ form_close() }}
<div class="mt-3 text-center">
<small>Уже есть аккаунт? <a href="{{ base_url('/login') }}">Войти</a></small>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/verify_error.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center" style="min-height: 80vh;">
<div class="card shadow-lg border-0" style="max-width: 500px; width: 100%;">
<div class="card-body p-5 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation display-1 text-warning"></i>
</div>
<h3 class="mb-3">Ошибка подтверждения</h3>
<div class="alert alert-warning text-start">
{{ message|default('Недействительная ссылка для подтверждения.') }}
</div>
<div class="mb-4">
<a href="/auth/resend-verification" class="btn btn-outline-primary">
<i class="fa-solid fa-envelope me-2"></i>Запросить ссылку повторно
</a>
</div>
<hr class="my-4">
<div class="d-flex justify-content-center gap-3">
<a href="/" class="btn btn-outline-secondary">
<i class="fa-solid fa-home me-2"></i>На главную
</a>
<a href="/login" class="btn btn-outline-secondary">
<i class="fa-solid fa-sign-in-alt me-2"></i>Войти
</a>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/verify_success.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center" style="min-height: 80vh;">
<div class="card shadow-lg border-0" style="max-width: 500px; width: 100%;">
<div class="card-body p-5 text-center">
<div class="mb-4">
<i class="fa-solid fa-circle-check display-1 text-success"></i>
</div>
<h3 class="mb-3">Email подтверждён!</h3>
<p class="text-muted mb-4">
{% if name %}
{{ name }}, спасибо за подтверждение.
{% else %}
Спасибо за подтверждение.
{% endif %}
</p>
<p class="text-muted mb-4">
Теперь вы можете войти в систему и начать работу.
</p>
<div class="d-grid gap-2">
<a href="/login" class="btn btn-primary btn-lg">
<i class="fa-solid fa-sign-in-alt me-2"></i>Войти
</a>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Views/auth/login.twig
{% extends 'layouts/public.twig' %}
{% block content %}
<div class="card shadow-sm" style="width: 100%; max-width: 400px;">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="fa-solid fa-circle-nodes fa-3x text-primary mb-2"></i>
<h3>Бизнес.Точка</h3>
<p class="text-muted">Вход в систему</p>
</div>
{{ form_open(base_url('/login'), 'class="needs-validation"') }}
{{ csrf_field()|raw }}
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" value="{{ old.email|default('') }}" class="form-control" id="email" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" name="password" value="{{ old.password|default('') }}" class="form-control" id="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Запомнить меня</label>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
{{ form_close() }}
<div class="mt-3 text-center">
<small><a href="{{ base_url('/forgot-password') }}">Забыли пароль?</a></small>
</div>
<div class="mt-2 text-center">
<small>Нет аккаунта? <a href="{{ base_url('/register') }}">Зарегистрироваться</a></small>
</div>
</div>
</div>
{% endblock %}
// app/Views/components/alerts.twig
{# app/Views/components/alerts.twig #}
{% set alerts = get_alerts() %}
{% if alerts is not empty %}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1050">
{% for alert in alerts %}
{# Преобразуем наш тип 'error' в класс Bootstrap 'danger' #}
{% set bs_type = alert.type == 'error' ? 'danger' : alert.type %}
<div class="toast align-items-center text-white bg-{{ bs_type }} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
{{ alert.message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
</div>
<script>
// Автоматически показываем все toasts при загрузке страницы
document.addEventListener("DOMContentLoaded", function() {
var toastElList = [].slice.call(document.querySelectorAll('.toast'));
toastElList.forEach(function(toastEl) {
var toast = new bootstrap.Toast(toastEl);
toast.show();
});
});
</script>
{% endif %}
// app/Views/components/calendar/default_event.twig
{#
default_event.twig - Событие по умолчанию для Календаря
Параметры:
- event: Объект события
Ожидаемые поля:
- id: Идентификатор
- title: Заголовок
- date: Дата события (для сравнения с today)
- color: Цвет для бордера
- url: Ссылка (опционально)
- onEventClick: JavaScript функция при клике (опционально)
#}
{% if event.url %}
<a href="{{ event.url }}"
class="calendar-event"
style="border-left-color: {{ event.color|default('#6B7280') }}"
{% if onEventClick %}onclick="{{ onEventClick }}({{ event.id }}); return false;"{% endif %}
title="{{ event.title }}">
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
</a>
{% else %}
<div class="calendar-event"
style="border-left-color: {{ event.color|default('#6B7280') }}"
{% if onEventClick %}onclick="{{ onEventClick }}({{ event.id }});"{% endif %}
title="{{ event.title }}">
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
</div>
{% 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) %}
<div class="card shadow-sm mb-4">
<div class="card-body d-flex justify-content-between align-items-center">
<a href="{{ prevMonth }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-chevron-left me-1"></i>Предыдущий
</a>
<h5 class="mb-0">{{ monthName }}</h5>
<a href="{{ nextMonth }}" class="btn btn-outline-secondary">
Следующий<i class="fa-solid fa-chevron-right ms-1"></i>
</a>
</div>
</div>
{% endif %}
{# Календарь #}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="calendar">
{# Дни недели #}
<div class="calendar-header bg-light">
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
<div class="calendar-header-cell text-center py-2 text-muted small fw-normal">
{{ day }}
</div>
{% endfor %}
</div>
{# Сетка календаря #}
<div class="calendar-grid">
{# Пустые ячейки до первого дня #}
{% for i in 0..(firstDayOfWeek - 1) %}
<div class="calendar-cell bg-light"></div>
{% endfor %}
{# Дни месяца #}
{% for day in 1..daysInMonth %}
{% set dateStr = currentMonth ~ '-' ~ (day < 10 ? '0' ~ day : day) %}
{% set dayEvents = eventsByDate[dateStr]|default([]) %}
{% set isToday = dateStr == today %}
<div class="calendar-cell {{ isToday ? 'calendar-cell-today' : '' }}">
<div class="calendar-day-number {{ isToday ? 'text-primary fw-bold' : '' }}">
{{ day }}
</div>
<div class="calendar-events">
{% 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 %}
<div class="calendar-events-more text-muted small">
+{{ dayEvents|length - 3 }} ещё
</div>
{% endif %}
</div>
</div>
{% endfor %}
{# Пустые ячейки после последнего дня #}
{% set remainingCells = 7 - ((firstDayOfWeek + daysInMonth) % 7) %}
{% if remainingCells < 7 %}
{% for i in 1..remainingCells %}
<div class="calendar-cell bg-light"></div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
{% block stylesheets %}
<style>
.calendar {
width: 100%;
}
.calendar .calendar-header {
display: grid !important;
grid-template-columns: repeat(7, 1fr) !important;
border-bottom: 1px solid #e5e7eb;
}
.calendar .calendar-header-cell {
padding: 0.75rem 0.5rem;
font-weight: 500;
color: #6b7280;
font-size: 0.875rem;
text-align: center;
}
.calendar .calendar-grid {
display: grid !important;
grid-template-columns: repeat(7, 1fr) !important;
}
.calendar .calendar-cell {
min-height: 100px;
padding: 0.5rem;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
display: block !important;
}
.calendar .calendar-cell:nth-child(7n) {
border-right: none;
}
.calendar .calendar-cell.bg-light {
background-color: #f9fafb;
}
.calendar .calendar-cell-today {
background-color: #eff6ff;
}
.calendar .calendar-day-number {
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.calendar .calendar-events {
display: flex !important;
flex-direction: column;
gap: 0.25rem;
}
.calendar .calendar-event {
display: block !important;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background-color: #f3f4f6;
border-left: 3px solid #6b7280;
border-radius: 0.25rem;
text-decoration: none;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar .calendar-event:hover {
background-color: #e5e7eb;
}
.calendar .calendar-events-more {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
}
</style>
{% endblock %}
{% if showLegend|default(true) and (legend is defined or events is defined) %}
<div class="card shadow-sm mt-4">
<div class="card-body">
<h6 class="card-title">Легенда</h6>
<div class="d-flex flex-wrap gap-2">
{% if legend is defined %}
{% for item in legend %}
<span class="badge"
style="background-color: {{ item.color }}20; color: {{ item.color }}; border: 1px solid {{ item.color }}40;">
{{ item.name }}
</span>
{% endfor %}
{% else %}
{# Автоматическая легенда из типов событий #}
{% set uniqueColors = {} %}
{% for event in events %}
{% if event.color is defined and event.color not in uniqueColors %}
<span class="badge"
style="background-color: {{ event.color }}20; color: {{ event.color }}; border: 1px solid {{ event.color }}40;">
{{ event.title }}
</span>
{% set uniqueColors = uniqueColors|merge([event.color]) %}
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endif %}
// app/Views/components/table/macros.twig
{#
macros.twig - Универсальные макросы для таблиц
Макросы:
- render_actions(actions): Рендерит кнопки действий для строки таблицы
#}
{% macro render_actions(actions) %}
<div class="btn-group btn-group-sm">
{% for action in actions %}
<a href="{{ action.url }}"
class="btn {{ action.class|default('btn-outline-primary') }}"
title="{{ action.title|default('') }}"
{% if action.confirm %}onclick="return confirm('{{ action.confirm }}');"{% endif %}>
{% if action.icon %}<i class="{{ action.icon }}"></i>{% endif %}
</a>
{% endfor %}
</div>
{% 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 %}
<div class="pagination-wrapper">
{# Информация о количестве записей #}
<div class="pagination-info">
<span>{{ infoText }}</span>
</div>
{# Кнопки навигации - посередине #}
<nav aria-label="Пагинация" style="float: left;">
<ul class="pagination pagination-sm mb-0">
{# Предыдущая страница #}
<li class="page-item {{ currentPage <= 1 ? 'disabled' }}">
<a class="page-link" href="#" data-nav-page="prev" aria-label="Предыдущая">
<i class="fa-solid fa-chevron-left"></i>
</a>
</li>
{# Номера страниц #}
{% set startPage = max(1, currentPage - 2) %}
{% set endPage = min(totalPages, currentPage + 2) %}
{# Всегда показываем первую страницу если есть #}
{% if startPage > 1 %}
<li class="page-item">
<a class="page-link" href="#" data-page="1">1</a>
</li>
{% if startPage > 2 %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endif %}
{# Страницы вокруг текущей #}
{% for page in startPage..endPage %}
<li class="page-item {{ page == currentPage ? 'active' }}">
<a class="page-link" href="#" data-page="{{ page }}">{{ page }}</a>
</li>
{% endfor %}
{# Всегда показываем последнюю страницу если есть #}
{% if endPage < totalPages %}
{% if endPage < totalPages - 1 %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
<li class="page-item">
<a class="page-link" href="#" data-page="{{ totalPages }}">{{ totalPages }}</a>
</li>
{% endif %}
{# Следующая страница #}
<li class="page-item {{ currentPage >= totalPages ? 'disabled' }}">
<a class="page-link" href="#" data-nav-page="next" aria-label="Следующая">
<i class="fa-solid fa-chevron-right"></i>
</a>
</li>
</ul>
</nav>
{# Выбор количества записей - справа #}
<div class="per-page-selector" style="float: right;">
<label for="per-page-select-{{ id|default('table') }}">Записей на странице:</label>
<select id="per-page-select-{{ id|default('table') }}" class="form-select-sm per-page-select">
<option value="10" {{ perPage == 10 ? 'selected' }}>10</option>
<option value="25" {{ perPage == 25 ? 'selected' }}>25</option>
<option value="50" {{ perPage == 50 ? 'selected' }}>50</option>
<option value="100" {{ perPage == 100 ? 'selected' }}>100</option>
</select>
</div>
</div>
// app/Views/components/table/table_header.twig
{#
table_header.twig - Переиспользуемый шаблон заголовка таблицы
Параметры:
- columns: Ассоциативный массив столбцов ['name' => ['label' => 'Name', 'width' => '40%']]
- sort: Текущий столбец сортировки
- order: Направление сортировки (asc/desc)
- filters: Текущие значения фильтров
#}
<thead class="table-light">
<tr>
{% for columnKey, column in columns %}
<th style="width: {{ column.width|default('auto') }};"
class="{{ column.align|default('') }}"
data-sort-column="{{ columnKey }}">
<div class="header-content">
{# Поле поиска - первым, для правильного позиционирования #}
{# Иконка поиска #}
<i class="fa-solid fa-search search-trigger text-muted"
data-search-trigger="{{ columnKey }}"
title="{{ column.searchTitle|default('Поиск по ' ~ column.label|lower) }}"></i>
<input type="text"
class="form-control-sm header-search-input"
data-search-input="{{ columnKey }}"
value="{{ filters[columnKey]|default('') }}"
placeholder="{{ column.placeholder|default('Поиск...') }}"
style="display: none;">
{# Текст заголовка #}
<span class="header-text" data-header-text="{{ columnKey }}">
{{ column.label }}
</span>
{# Иконка сортировки #}
<i class="fa-solid fa-sort sort-icon {{ sort == columnKey ? 'active' : 'text-muted' }}"
data-sort="{{ columnKey }}"
title="{{ sort == columnKey ? (order == 'asc' ? 'Сортировка по возрастанию (нажмите для сортировки по убыванию)' : 'Сортировка по убыванию (нажмите для сортировки по возрастанию)') : 'Сортировка' }}"></i>
</div>
</th>
{% endfor %}
{# Колонка действий (опционально) #}
{% if actions is defined and actions %}
<th class="text-end {{ actions.class|default('') }}" style="width: {{ actions.width|default('auto') }};">
{{ actions.label|default('Действия') }}
</th>
{% endif %}
</tr>
</thead>
// 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() }}
<link rel="stylesheet" href="/css/components/data-table.css">
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
new DataTable('your-table-id', {
url: '/your-module/table',
perPage: 10
});
});
</script>
{% 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 #}
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.price }}</td>
<td class="text-end">
<a href="...">Редактировать</a>
</td>
</tr>
{% endfor %}
</tbody>
{% if items is not empty and pager %}
<tfoot>
<tr>
<td colspan="3">
{{ include('@components/table/pagination.twig', {
pagination: paginationData,
id: 'your-table-id'
}) }}
</td>
</tr>
</tfoot>
{% endif %}
{% else %}
{# Обычный запрос: полная таблица #}
<div class="table-responsive">
{{ include('@components/table/table.twig', {
id: 'your-table-id',
url: '/your-module/table',
perPage: perPage,
columns: columns,
pagination: paginationData,
actions: { label: 'Действия' },
emptyMessage: 'Нет данных'
}) }}
</div>
{% 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 %}
<tr>
<td>{{ client.name }}</td>
<td>{{ client.email }}</td>
<td class="actions-cell text-end">
<a href="{{ editUrl }}" class="btn btn-sm btn-outline-primary">
<i class="fa-solid fa-pen"></i>
</a>
</td>
</tr>
{% endfor %}
```
### Кастомные строки
Компонент поддерживает произвольное содержимое ячеек через параметр `rows`:
```twig
{% set rows = [] %}
{% for product in products %}
{% set rows = rows|merge([{
cells: [
{ content: '<strong>' ~ product.name ~ '</strong>', class: '' },
{ content: product.price ~ ' ₽', class: 'text-end' }
],
actions: '<a href="...">Редактировать</a>'
}]) %}
{% 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: Дополнительные классы для таблицы
#}
<div id="{{ id }}" class="data-table" data-url="{{ url }}" data-per-page="{{ perPage|default(10) }}">
<table class="table table-hover mb-0 {{ tableClass|default('') }}">
{# Заголовок таблицы #}
{{ include('@components/table/table_header.twig', {
columns: columns,
sort: sort|default(''),
order: order|default('asc'),
filters: filters|default({}),
actions: actions|default(false)
}) }}
{# Тело таблицы #}
<tbody>
{% if items is defined and items|length > 0 %}
{% for item in items %}
<tr{% if onRowClick is defined and onRowClick %} onclick="{{ onRowClick }}({{ item.id }})" style="cursor: pointer;"{% endif %}>
{# Рендерим каждую колонку #}
{% for key, column in columns %}
<td>
{{ render_cell(item, key, column)|raw }}
</td>
{% endfor %}
{# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %}
<td class="actions-cell text-end"{% if onRowClick is defined and onRowClick %} onclick="event.stopPropagation();"{% endif %}>
{# Фильтруем действия на основе прав доступа #}
{% 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 %}
<span class="text-muted small">—</span>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
{% else %}
{# Пустое состояние #}
<tr>
<td colspan="{{ columns|length + (actions is defined and actions ? 1 : 0) }}"
class="text-center py-5">
{% if emptyIcon is defined and emptyIcon %}
<div class="mb-3">
<i class="{{ emptyIcon }} text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
<p class="text-muted mb-3">{{ emptyMessage|default('Нет данных') }}</p>
{% if emptyActionUrl is defined and emptyActionUrl %}
<a href="{{ emptyActionUrl }}" class="btn btn-primary">
{% if emptyActionIcon is defined and emptyActionIcon %}
<i class="{{ emptyActionIcon }} me-2"></i>
{% endif %}
{{ emptyActionLabel|default('Добавить') }}
</a>
{% endif %}
</td>
</tr>
{% endif %}
</tbody>
{# Футер с пагинацией #}
<tfoot>
<tr>
<td colspan="{{ columns|length + 1 }}">
{{ include('@components/table/pagination.twig', {
pagination: pagerDetails,
id: id
}) }}
</td>
</tr>
</tfoot>
</table>
</div>
// app/Views/components/table/ajax_table.twig
<tbody>
{% if items is defined and items|length > 0 %}
{% for item in items %}
<tr{% if onRowClick is defined and onRowClick %} onclick="{{ onRowClick }}({{ item.id }})" style="cursor: pointer;"{% endif %}>
{# Рендерим каждую колонку #}
{% for key, column in columns %}
<td>
{{ render_cell(item, key, column)|raw }}
</td>
{% endfor %}
{# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %}
<td class="actions-cell text-end"{% if onRowClick is defined and onRowClick %} onclick="event.stopPropagation();"{% endif %}>
{# Фильтруем действия на основе прав доступа #}
{% 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 %}
<span class="text-muted small">—</span>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
{% else %}
{# Пустое состояние #}
<tr>
<td colspan="{{ columns|length + (actions is defined and actions ? 1 : 0) }}"
class="text-center py-5">
{% if emptyIcon is defined and emptyIcon %}
<div class="mb-3">
<i class="{{ emptyIcon }} text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
<p class="text-muted mb-3">{{ emptyMessage|default('Нет данных') }}</p>
{% if emptyActionUrl is defined and emptyActionUrl %}
<a href="{{ emptyActionUrl }}" class="btn btn-primary">
{% if emptyActionIcon is defined and emptyActionIcon %}
<i class="{{ emptyActionIcon }} me-2"></i>
{% endif %}
{{ emptyActionLabel|default('Добавить') }}
</a>
{% endif %}
</td>
</tr>
{% endif %}
</tbody>
{# Футер с пагинацией #}
<tfoot>
<tr>
<td colspan="{{ columns|length + 1 }}">
{{ include('@components/table/pagination.twig', {
pagination: pagerDetails,
id: id
}) }}
</td>
</tr>
</tfoot>
// 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: Текст кнопки добавления (опционально)
#}
<div class="kanban-board overflow-auto pb-4">
<div class="d-flex gap-3" style="min-width: max-content;">
{% for column in columns %}
<div class="kanban-column" style="min-width: {{ column.width|default('320px') }}; min-height: 60vh;">
{# Заголовок колонки #}
<div class="card mb-2"
style="border-left: 4px solid {{ column.color }}; border-top: none; border-right: none; border-bottom: none;">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ column.name }}</h6>
<span class="badge bg-secondary">{{ column.items|length }}</span>
</div>
{% if column.total is defined %}
<small class="text-muted">
₽{{ column.total|number_format(0, ',', ' ') }}
</small>
{% endif %}
</div>
</div>
{# Карточки #}
<div class="kanban-cards-container"
data-column-id="{{ column.id }}"
data-move-url="{{ moveUrl|default('') }}"
style="min-height: 50vh;"
{% if onMove is defined %}data-on-move="{{ onMove }}"{% 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 %}
</div>
{# Кнопка добавления #}
{% if addUrl is defined or column.addUrl is defined %}
<a href="{{ column.addUrl|default(addUrl) }}?column_id={{ column.id }}"
class="btn btn-outline-secondary btn-sm w-100 mt-2">
<i class="fa-solid fa-plus me-1"></i>
{{ addLabel|default('Добавить') }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
initKanban();
});
function initKanban() {
const cards = document.querySelectorAll('.kanban-card[draggable="true"]');
const containers = document.querySelectorAll('.kanban-cards-container');
cards.forEach(card => {
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragend', handleDragEnd);
});
containers.forEach(container => {
container.addEventListener('dragover', handleDragOver);
container.addEventListener('drop', handleDrop);
container.addEventListener('dragenter', handleDragEnter);
container.addEventListener('dragleave', handleDragLeave);
});
}
function handleDragStart(e) {
this.classList.add('dragging');
e.dataTransfer.setData('text/plain', this.dataset.itemId);
e.dataTransfer.effectAllowed = 'move';
}
function handleDragEnd() {
this.classList.remove('dragging');
document.querySelectorAll('.kanban-cards-container').forEach(col => {
col.classList.remove('bg-light');
});
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleDragEnter(e) {
e.preventDefault();
this.classList.add('bg-light');
}
function handleDragLeave() {
this.classList.remove('bg-light');
}
function handleDrop(e) {
e.preventDefault();
this.classList.remove('bg-light');
const itemId = e.dataTransfer.getData('text/plain');
const newColumnId = this.dataset.columnId;
const moveUrl = this.dataset.moveUrl;
const onMove = this.dataset.onMove;
if (itemId && newColumnId) {
if (moveUrl) {
console.log('Moving deal:', itemId, 'to stage:', newColumnId);
// Находим перетаскиваемую карточку
const draggedCard = document.querySelector(`.kanban-card[data-item-id="${itemId}"]`);
const sourceColumn = draggedCard ? draggedCard.closest('.kanban-cards-container') : null;
// AJAX перемещение - base.js автоматически добавит CSRF заголовок
fetch(moveUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: 'deal_id=' + itemId + '&stage_id=' + newColumnId
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (onMove) {
window[onMove](itemId, newColumnId, data);
} else if (draggedCard && sourceColumn) {
// Перемещаем карточку в новую колонку
const targetContainer = this;
targetContainer.appendChild(draggedCard);
// Обновляем счётчики колонок
updateColumnCounters(sourceColumn);
updateColumnCounters(targetContainer);
// Анимация успешного перемещения
draggedCard.style.transition = 'all 0.2s ease';
draggedCard.style.transform = 'scale(1.02)';
setTimeout(() => {
draggedCard.style.transform = '';
}, 200);
} else {
location.reload();
}
} else {
alert(data.message || 'Ошибка при перемещении');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при перемещении');
});
} else if (onMove) {
// Только callback без AJAX
window[onMove](itemId, newColumnId);
}
}
}
/**
* Обновление счётчиков колонки (количество карточек и сумма)
*/
function updateColumnCounters(container) {
const columnId = container.dataset.columnId;
const cards = container.querySelectorAll('.kanban-card');
const count = cards.length;
// Находим badge в заголовке колонки
const column = container.closest('.kanban-column');
if (column) {
const badge = column.querySelector('.badge');
if (badge) {
badge.textContent = count;
}
}
}
</script>
// app/Views/components/kanban/default_card.twig
{#
default_card.twig - Карточка по умолчанию для Канбан-компонента
Параметры:
- item: Объект элемента
- column: Объект колонки (для доступа к color и т.д.)
Ожидаемые поля в item:
- id: Идентификатор
- title: Заголовок
- url: Ссылка на просмотр (опционально)
- amount: Сумма для отображения (опционально)
- date: Дата для отображения (опционально)
- assignee: Ответственный (опционально)
- status: Статус для цветовой маркировки (опционально)
#}
<div class="card mb-2 kanban-card"
draggable="true"
data-item-id="{{ item.id }}"
style="cursor: grab;">
<div class="card-body py-2 px-3">
{# Заголовок и сумма #}
<div class="d-flex justify-content-between align-items-start mb-2">
{% if item.url %}
<a href="{{ item.url }}" class="text-decoration-none">
<strong class="text-dark">{{ item.title }}</strong>
</a>
{% else %}
<strong class="text-dark">{{ item.title }}</strong>
{% endif %}
{% if item.amount is defined and item.amount %}
<span class="badge bg-light text-dark">
₽{{ item.amount|number_format(0, ',', ' ') }}
</span>
{% endif %}
</div>
{# Дополнительная информация #}
{% if item.description is defined and item.description %}
<small class="text-muted d-block mb-2">
{{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }}
</small>
{% endif %}
{# Нижняя панель #}
<div class="d-flex justify-content-between align-items-center">
{% if item.assignee is defined and item.assignee %}
<small class="text-muted">
<i class="fa-solid fa-user-check me-1"></i>
{{ item.assignee }}
</small>
{% else %}
<small></small>
{% endif %}
{% if item.date is defined and item.date %}
<small class="{{ item.isOverdue is defined and item.isOverdue ? 'text-danger' : 'text-muted' }}">
<i class="fa-regular fa-calendar me-1"></i>
{{ item.date|date('d.m') }}
</small>
{% endif %}
</div>
{# Теги/метки #}
{% if item.tags is defined and item.tags|length > 0 %}
<div class="mt-2 d-flex flex-wrap gap-1">
{% for tag in item.tags %}
<span class="badge" style="background-color: {{ tag.color }}20; color: {{ tag.color }}; font-size: 0.65rem;">
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
// app/Views/macros/forms.twig
{# app/Views/macros/forms.twig #}
{% macro form_open(action, attributes = '') %}
{# Добавляем data-ajax="true" для автоматической CSRF защиты #}
<form action="{{ action }}" method="post" data-ajax="true" {{ attributes|raw }}>
{{ csrf_field()|raw }}
{% endmacro %}
{% macro form_close() %}
</form>
{% endmacro %}
// app/.htaccess
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Deny from all
</IfModule>
// app/Filters/OrganizationFilter.php
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
class OrganizationFilter implements FilterInterface
{
/**
public function before(RequestInterface $request, $arguments = null)
{
if (!session()->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
<?php
namespace App\Filters;
use App\Services\AccessService;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
class RoleFilter implements FilterInterface
{
/**
public function before(RequestInterface $request, $arguments = null)
{
if ($arguments === null) {
return null;
}
$access = AccessService::getInstance();
if (is_string($arguments) && str_starts_with($arguments, 'role:system:')) {
$roles = explode(',', substr($arguments, 13));
$roles = array_map('trim', $roles);
if (!$access->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
<?php
namespace App\Filters;
use App\Controllers\Auth;
use App\Models\UserModel;
use App\Models\OrganizationUserModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
class AuthFilter implements FilterInterface
{
/**
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
if ($session->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
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use App\Services\ModuleSubscriptionService;
/**
class ModuleSubscriptionFilter implements FilterInterface
{
/**
public function before(RequestInterface $request, $arguments = null)
{
if (!$arguments) {
return;
}
$moduleCode = $arguments[0] ?? null;
if (!$moduleCode) {
return;
}
$session = session();
$orgId = $session->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
<?php
namespace App\Services;
use CodeIgniter\Events\Events;
/**
class EventManager
{
/**
private ?ModuleSubscriptionService $moduleSubscriptionService = null;
/**
private ?\Config\BusinessModules $modulesConfig = null;
/**
private ?string $moduleCode = null;
/**
private ?bool $moduleActive = null;
/**
private function getModuleSubscriptionService(): ModuleSubscriptionService
{
if ($this->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
<?php
namespace App\Services;
use App\Models\OrganizationUserModel;
use App\Models\OrganizationModel;
use App\Models\UserModel;
use Config\Services;
class InvitationService
{
protected OrganizationUserModel $orgUserModel;
protected OrganizationModel $orgModel;
protected UserModel $userModel;
protected string $baseUrl;
public function __construct()
{
$this->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 = <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
.role-badge { display: inline-block; background: #EEF2FF; color: #4F46E5; padding: 4px 12px; border-radius: 20px; font-size: 14px; }
.button { display: inline-block; background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
.footer { text-align: center; color: #6b7280; font-size: 12px; padding: 20px; }
.invite-link { word-break: break-all; font-size: 12px; color: #6b7280; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Приглашение в Бизнес.Точка</h1>
</div>
<div class="content">
<p>Вас приглашают присоединиться к организации <strong>{$orgName}</strong></p>
<p>Ваша роль: <span class="role-badge">{$roleLabel}</span></p>
<p>Нажмите кнопку ниже, чтобы принять или отклонить приглашение:</p>
<p style="text-align: center;">
<a href="{$inviteLink}" class="button">Принять приглашение</a>
</p>
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
<p class="invite-link">{$inviteLink}</p>
<p>Ссылка действительна 7 дней.</p>
</div>
<div class="footer">
<p>© Бизнес.Точка</p>
</div>
</div>
</body>
</html>
HTML;
$emailService->setMessage($message);
return $emailService->send();
}
}
// app/Services/RateLimitService.php
<?php
namespace App\Services;
use App\Libraries\RateLimitIdentifier;
use CodeIgniter\Cache\CacheFactory;
use CodeIgniter\Cache\Interfaces\CacheInterface;
use CodeIgniter\Config\Services as BaseServices;
use Redis;
use RuntimeException;
/**
class RateLimitService
{
/**
private ?Redis $redis = null;
/**
private CacheInterface $cache;
private RateLimitIdentifier $identifier;
private string $prefix;
private array $config;
/**
public function __construct(
?Redis $redis,
CacheInterface $cache,
RateLimitIdentifier $identifier,
string $prefix = 'rl:',
array $config = []
) {
$this->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
<?php
namespace App\Services;
use App\Models\OrganizationUserModel;
use CodeIgniter\Database\Exceptions\DataException;
/**
class AccessService
{
private ?array $currentMembership = null;
private OrganizationUserModel $orgUserModel;
/**
public const SYSTEM_ROLE_USER = 'user';
public const SYSTEM_ROLE_ADMIN = 'admin';
public const SYSTEM_ROLE_SUPERADMIN = 'superadmin';
/**
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_MANAGER = 'manager';
public const ROLE_GUEST = 'guest';
/**
public const ROLE_HIERARCHY = [
self::ROLE_OWNER => 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
<?php
namespace App\Services;
use CodeIgniter\Config\BaseConfig;
/**
class ModuleSubscriptionService
{
/**
protected array $modulesConfig = [
'base' => [
'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
<?php
namespace App\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class UserModel extends Model
{
use TenantScopedModel;
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'email',
'password',
'name',
'phone',
'avatar',
'verification_token',
'email_verified',
'verified_at',
'reset_token',
'reset_expires_at',
'system_role',
'status',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $beforeInsert = ['hashPassword'];
protected $beforeUpdate = ['hashPassword'];
protected function hashPassword(array $data)
{
if (isset($data['data']['password'])) {
$data['data']['password'] = password_hash($data['data']['password'], PASSWORD_DEFAULT);
}
return $data;
}
/**
public function generateResetToken(int $userId, int $expiresInHours = 24): string
{
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiresInHours} hours"));
$this->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
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
class OrganizationSubscriptionModel extends Model
{
protected $table = 'organization_subscriptions';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'organization_id',
'module_code',
'status',
'expires_at',
'created_at',
];
protected $useTimestamps = false;
/**
public function isModuleActive(int $organizationId, string $moduleCode): bool
{
$subscription = $this->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
<?php
namespace App\Models\Traits;
use CodeIgniter\Model;
/**
trait TenantScopedModel
{
/**
public function forCurrentOrg()
{
$session = session();
$orgId = $session->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
<?php
namespace App\Models;
use CodeIgniter\Model;
class OrganizationModel extends Model
{
protected $table = 'organizations';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $allowedFields = [
'owner_id',
'name',
'type',
'logo',
'requisites',
'trial_ends_at',
'settings',
'status',
];
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
public function getUserOrganizations(int $userId): array
{
$db = $this->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
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
class ModuleSettingsModel extends Model
{
protected $table = 'module_settings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'module_code',
'name',
'description',
'price_monthly',
'price_yearly',
'trial_days',
'is_active',
'created_at',
'updated_at',
];
protected $useTimestamps = false;
/**
public function getByModuleCode(string $moduleCode): ?array
{
return $this->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
<?php
namespace App\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class OrganizationUserModel extends Model
{
use TenantScopedModel;
protected $table = 'organization_users';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $allowedFields = [
'organization_id',
'user_id',
'role',
'status',
'invite_token',
'invited_by',
'invited_at',
'invite_expires_at',
'joined_at',
];
protected $useTimestamps = false;
public const STATUS_ACTIVE = 'active';
public const STATUS_PENDING = 'pending';
public const STATUS_BLOCKED = 'blocked';
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_MANAGER = 'manager';
public const ROLE_GUEST = 'guest';
/**
public function findByInviteToken(string $token): ?array
{
return $this->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
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use App\Models\UserModel;
/**
class SetSystemRoleSeeder extends Seeder
{
/**
public const ROLE_USER = 'user';
public const ROLE_ADMIN = 'admin';
public const ROLE_SUPERADMIN = 'superadmin';
/**
public function run()
{
$email = $this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateTaskAssigneesTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateUsersTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddUpdatedAtToSubscriptions extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddTrialEndsAtToSubscriptions extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEmailVerificationToUsers extends Migration
{
public function up()
{
$fields = [
'verification_token' => [
'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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
class DropOrganizationPlanSubscriptionsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
class AddPlansTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateOrganizationsClientsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddStatusToOrganizations extends Migration
{
public function up()
{
$fields = $this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateTaskColumnsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPasswordResetFieldsToUsers extends Migration
{
public function up()
{
$fields = [
'reset_token' => [
'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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateOrganizationSubscriptionsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateDealsTables extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
class CreateModuleSettingsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddInviteFieldsToOrganizationUsers extends Migration
{
public function up()
{
$fields = $this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateOrganizationsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateTasksTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateRememberTokensTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddTokenExpiresToUsers extends Migration
{
public function up()
{
$fields = $this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSystemRoleToUsers extends Migration
{
public function up()
{
$fields = [
'system_role' => [
'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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateContactsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddInviteExpiresToOrganizationUsers extends Migration
{
public function up()
{
$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 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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateOrganizationUsersTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
class CreateCiSessionsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateTaskBoardsTable extends Migration
{
public function up()
{
$this->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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Generators extends BaseConfig
{
/**
public array $views = [
'make:cell' => [
'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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use DateTimeInterface;
class Cookie extends BaseConfig
{
/**
public string $prefix = '';
/**
public $expires = 0;
/**
public string $path = '/';
/**
public string $domain = '';
/**
public bool $secure = false;
/**
public bool $httponly = true;
/**
public string $samesite = 'Lax';
/**
public bool $raw = false;
}
// app/Config/Cache.php
<?php
namespace Config;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler;
use CodeIgniter\Cache\Handlers\PredisHandler;
use CodeIgniter\Cache\Handlers\RedisHandler;
use CodeIgniter\Cache\Handlers\WincacheHandler;
use CodeIgniter\Config\BaseConfig;
class Cache extends BaseConfig
{
/**
public string $handler = 'file';
/**
public string $backupHandler = 'dummy';
/**
public string $prefix = '';
/**
public int $ttl = 60;
/**
public string $reservedCharacters = '{}()/\@:';
/**
public array $file = [
'storePath' => 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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Images\Handlers\GDHandler;
use CodeIgniter\Images\Handlers\ImageMagickHandler;
class Images extends BaseConfig
{
/**
public string $defaultHandler = 'gd';
/**
public string $libraryPath = '/usr/local/bin/convert';
/**
public array $handlers = [
'gd' => GDHandler::class,
'imagick' => ImageMagickHandler::class,
];
}
// app/Config/ContentSecurityPolicy.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
class ContentSecurityPolicy extends BaseConfig
{
/**
public bool $reportOnly = false;
/**
public ?string $reportURI = null;
/**
public bool $upgradeInsecureRequests = false;
/**
public $defaultSrc;
/**
public $scriptSrc = 'self';
/**
public $styleSrc = 'self';
/**
public $imageSrc = 'self';
/**
public $baseURI;
/**
public $childSrc = 'self';
/**
public $connectSrc = 'self';
/**
public $fontSrc;
/**
public $formAction = 'self';
/**
public $frameAncestors;
/**
public $frameSrc;
/**
public $mediaSrc;
/**
public $objectSrc = 'self';
/**
public $manifestSrc;
/**
public $pluginTypes;
/**
public $sandbox;
/**
public string $styleNonceTag = '{csp-style-nonce}';
/**
public string $scriptNonceTag = '{csp-script-nonce}';
/**
public bool $autoNonce = true;
}
// app/Config/View.php
<?php
namespace Config;
use CodeIgniter\Config\View as BaseView;
use CodeIgniter\View\ViewDecoratorInterface;
/**
class View extends BaseView
{
/**
public $saveData = true;
/**
public $filters = [];
/**
public $plugins = [];
/**
public array $decorators = [];
}
// app/Config/CURLRequest.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class CURLRequest extends BaseConfig
{
/**
public bool $shareOptions = false;
}
// app/Config/UserAgents.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
class UserAgents extends BaseConfig
{
/**
public array $platforms = [
'windows nt 10.0' => '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
<?php
/*
| --------------------------------------------------------------------
| App Namespace
| --------------------------------------------------------------------
|
| This defines the default Namespace that is used throughout
| CodeIgniter to refer to the Application directory. Change
| this constant to change the namespace that all application
| classes should use.
|
| NOTE: changing this will require manually modifying the
| existing namespaces of App\* namespaced-classes.
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
/*
| --------------------------------------------------------------------------
| Composer Path
| --------------------------------------------------------------------------
|
| The path that Composer's autoload file is expected to live. By default,
| the vendor folder is in the Root directory, but you can customize that here.
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
/*
|--------------------------------------------------------------------------
| Timing Constants
|--------------------------------------------------------------------------
|
| Provide simple ways to work with the myriad of PHP functions that
| require information to be in seconds.
defined('SECOND') || define('SECOND', 1);
defined('MINUTE') || define('MINUTE', 60);
defined('HOUR') || define('HOUR', 3600);
defined('DAY') || define('DAY', 86400);
defined('WEEK') || define('WEEK', 604800);
defined('MONTH') || define('MONTH', 2_592_000);
defined('YEAR') || define('YEAR', 31_536_000);
defined('DECADE') || define('DECADE', 315_360_000);
/*
| --------------------------------------------------------------------------
| Exit Status Codes
| --------------------------------------------------------------------------
|
| Used to indicate the conditions under which the script is exit()ing.
| While there is no universal standard for error codes, there are some
| broad conventions. Three such conventions are mentioned below, for
| those who wish to make use of them. The CodeIgniter defaults were
| chosen for the least overlap with these conventions, while still
| leaving room for others to be defined in future versions and user
| applications.
|
| The three main conventions used for determining exit status codes
| are as follows:
|
| Standard C/C++ Library (stdlibc):
| http:
| (This link also contains other GNU-specific conventions)
| BSD sysexits.h:
| http:
| Bash scripting:
| http:
|
defined('EXIT_SUCCESS') || define('EXIT_SUCCESS', 0);
defined('EXIT_ERROR') || define('EXIT_ERROR', 1);
defined('EXIT_CONFIG') || define('EXIT_CONFIG', 3);
defined('EXIT_UNKNOWN_FILE') || define('EXIT_UNKNOWN_FILE', 4);
defined('EXIT_UNKNOWN_CLASS') || define('EXIT_UNKNOWN_CLASS', 5);
defined('EXIT_UNKNOWN_METHOD') || define('EXIT_UNKNOWN_METHOD', 6);
defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7);
defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8);
defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9);
defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125);
// app/Config/Autoload.php
<?php
namespace Config;
use CodeIgniter\Config\AutoloadConfig;
/**
class Autoload extends AutoloadConfig
{
/**
public $psr4 = [
APP_NAMESPACE => APPPATH,
'App\Modules' => APPPATH . 'Modules',
'App\Libraries\Twig' => APPPATH . 'Libraries/Twig',
];
/**
public $classmap = [];
/**
public $files = [];
/**
public $helpers = [];
}
// app/Config/Exceptions.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\ExceptionHandler;
use CodeIgniter\Debug\ExceptionHandlerInterface;
use Psr\Log\LogLevel;
use Throwable;
/**
class Exceptions extends BaseConfig
{
/**
public bool $log = true;
/**
public array $ignoreCodes = [404];
/**
public string $errorViewPath = APPPATH . 'Views/errors';
/**
public array $sensitiveDataInTrace = [];
/**
public bool $logDeprecations = true;
/**
public string $deprecationLogLevel = LogLevel::WARNING;
/*
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
{
return new ExceptionHandler($this);
}
}
// app/Config/Services.php
<?php
namespace Config;
use App\Libraries\RateLimitIdentifier;
use App\Services\RateLimitService;
use CodeIgniter\Config\BaseService;
/**
class Services extends BaseService
{
/*
/**
public static function access(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('access');
}
return new \App\Services\AccessService();
}
/**
public static function rateLimitIdentifier(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('rateLimitIdentifier');
}
return new RateLimitIdentifier();
}
/**
public static function rateLimit(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('rateLimit');
}
try {
return RateLimitService::getInstance();
} catch (\Exception $e) {
log_message('warning', 'RateLimitService unavailable: ' . $e->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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\FileHandler;
class Session extends BaseConfig
{
/**
public string $driver = FileHandler::class;
/**
public string $cookieName = 'ci_session';
/**
public int $expiration = 7200;
/**
public string $savePath = WRITEPATH . 'session';
/**
public bool $matchIP = false;
/**
public int $timeToUpdate = 300;
/**
public bool $regenerateDestroy = false;
/**
public ?string $DBGroup = null;
/**
public int $lockRetryInterval = 100_000;
/**
public int $lockMaxRetries = 300;
}
// app/Config/Format.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Format\JSONFormatter;
use CodeIgniter\Format\XMLFormatter;
class Format extends BaseConfig
{
/**
public array $supportedResponseFormats = [
'application/json',
'application/xml',
'text/xml',
];
/**
public array $formatters = [
'application/json' => 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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
class Cors extends BaseConfig
{
/**
public array $default = [
/**
'allowedOrigins' => [],
/**
'allowedOriginsPatterns' => [],
/**
'supportsCredentials' => false,
/**
'allowedHeaders' => [],
/**
'exposedHeaders' => [],
/**
'allowedMethods' => [],
/**
'maxAge' => 7200,
];
}
// app/Config/Honeypot.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Honeypot extends BaseConfig
{
/**
public bool $hidden = true;
/**
public string $label = 'Fill This Field';
/**
public string $name = 'honeypot';
/**
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';
/**
public string $container = '<div style="display:none">{template}</div>';
/**
public string $containerId = 'hpc';
}
// app/Config/Mimes.php
<?php
namespace Config;
/**
class Mimes
{
/**
public static array $mimes = [
'hqx' => [
'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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Migrations extends BaseConfig
{
/**
public bool $enabled = true;
/**
public string $table = 'migrations';
/**
public string $timestampFormat = 'Y-m-d-His_';
}
// app/Config/Pager.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Pager extends BaseConfig
{
/**
public array $templates = [
'default_full' => '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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
class Feature extends BaseConfig
{
/**
public bool $autoRoutesImproved = true;
/**
public bool $oldFilterOrder = false;
/**
public bool $limitZeroAsAll = true;
/**
public bool $strictLocaleNegotiation = false;
}
// app/Config/Publisher.php
<?php
namespace Config;
use CodeIgniter\Config\Publisher as BasePublisher;
/**
class Publisher extends BasePublisher
{
/**
public $restrictions = [
ROOTPATH => '*',
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
<?php
namespace Config;
use CodeIgniter\Config\ForeignCharacters as BaseForeignCharacters;
/**
class ForeignCharacters extends BaseForeignCharacters
{
}
// app/Config/Email.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Email extends BaseConfig
{
public string $fromEmail = '';
public string $fromName = '';
public string $recipients = '';
/**
public string $userAgent = 'CodeIgniter';
/**
public string $protocol = 'mail';
/**
public string $mailPath = '/usr/sbin/sendmail';
/**
public string $SMTPHost = '';
/**
public string $SMTPUser = '';
/**
public string $SMTPPass = '';
/**
public int $SMTPPort = 25;
/**
public int $SMTPTimeout = 5;
/**
public bool $SMTPKeepAlive = false;
/**
public string $SMTPCrypto = 'tls';
/**
public bool $wordWrap = true;
/**
public int $wrapChars = 76;
/**
public string $mailType = 'text';
/**
public string $charset = 'UTF-8';
/**
public bool $validate = false;
/**
public int $priority = 3;
/**
public string $CRLF = "\r\n";
/**
public string $newline = "\r\n";
/**
public bool $BCCBatchMode = false;
/**
public int $BCCBatchSize = 200;
/**
public bool $DSN = false;
}
// app/Config/Routes.php
<?php
use CodeIgniter\Router\RouteCollection;
/**
$routes->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
<?php
namespace Config;
use CodeIgniter\Database\Config;
/**
class Database extends Config
{
/**
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
/**
public string $defaultGroup = 'default';
/**
public array $default = [
'DSN' => '',
'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
<?php
namespace Config;
/**
class Paths
{
/**
public string $systemDirectory = __DIR__ . '/../../vendor/codeigniter4/framework/system';
/**
public string $appDirectory = __DIR__ . '/..';
/**
public string $writableDirectory = __DIR__ . '/../../writable';
/**
public string $testsDirectory = __DIR__ . '/../../tests';
/**
public string $viewDirectory = __DIR__ . '/../Views';
}
// app/Config/Kint.php
<?php
namespace Config;
use Kint\Parser\ConstructablePluginInterface;
use Kint\Renderer\Rich\TabPluginInterface;
use Kint\Renderer\Rich\ValuePluginInterface;
/**
class Kint
{
/*
|--------------------------------------------------------------------------
| Global Settings
|--------------------------------------------------------------------------
/**
public $plugins;
public int $maxDepth = 6;
public bool $displayCalledFrom = true;
public bool $expanded = false;
/*
|--------------------------------------------------------------------------
| RichRenderer Settings
|--------------------------------------------------------------------------
public string $richTheme = 'aante-light.css';
public bool $richFolder = false;
/**
public $richObjectPlugins;
/**
public $richTabPlugins;
/*
|--------------------------------------------------------------------------
| CLI Settings
|--------------------------------------------------------------------------
public bool $cliColors = true;
public bool $cliForceUTF8 = false;
public bool $cliDetectWidth = true;
public int $cliMinWidth = 40;
}
// app/Config/Routing.php
<?php
/**
namespace Config;
use CodeIgniter\Config\Routing as BaseRouting;
/**
class Routing extends BaseRouting
{
/**
public array $routeFiles = [
APPPATH . 'Config/Routes.php',
];
/**
public string $defaultNamespace = 'App\Controllers';
/**
public string $defaultController = 'Home';
/**
public string $defaultMethod = 'index';
/**
public bool $translateURIDashes = false;
/**
public ?string $override404 = null;
/**
public bool $autoRoute = false;
/**
public bool $prioritize = false;
/**
public bool $multipleSegmentsOneParam = false;
/**
public array $moduleRoutes = [];
/**
public bool $translateUriToCamelCase = true;
}
// app/Config/Logger.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler;
use CodeIgniter\Log\Handlers\HandlerInterface;
class Logger extends BaseConfig
{
/**
public $threshold = (ENVIRONMENT === 'production') ? 4 : 9;
/**
public string $dateFormat = 'Y-m-d H:i:s';
/**
public array $handlers = [
/*
FileHandler::class => [
'handles' => [
'critical',
'alert',
'emergency',
'debug',
'error',
'info',
'notice',
'warning',
],
/*
'fileExtension' => '',
/*
'filePermissions' => 0644,
/*
'path' => '',
],
/*
/*
];
}
// app/Config/Events.php
<?php
namespace Config;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\FrameworkException;
use CodeIgniter\HotReloader\HotReloader;
/*
Events::on('pre_system', static function (): void {
if (ENVIRONMENT !== 'testing') {
if (ini_get('zlib.output_compression')) {
throw FrameworkException::forEnabledZlibOutputCompression();
}
while (ob_get_level() > 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
<?php
namespace Config;
class DocTypes
{
/**
public array $list = [
'xhtml11' => '<!DOCTYPE html PUBLIC "-
'xhtml1-strict' => '<!DOCTYPE html PUBLIC "-
'xhtml1-trans' => '<!DOCTYPE html PUBLIC "-
'xhtml1-frame' => '<!DOCTYPE html PUBLIC "-
'xhtml-basic11' => '<!DOCTYPE html PUBLIC "-
'html5' => '<!DOCTYPE html>',
'html4-strict' => '<!DOCTYPE HTML PUBLIC "-
'html4-trans' => '<!DOCTYPE HTML PUBLIC "-
'html4-frame' => '<!DOCTYPE HTML PUBLIC "-
'mathml1' => '<!DOCTYPE math SYSTEM "http:
'mathml2' => '<!DOCTYPE math PUBLIC "-
'svg10' => '<!DOCTYPE svg PUBLIC "-
'svg11' => '<!DOCTYPE svg PUBLIC "-
'svg11-basic' => '<!DOCTYPE svg PUBLIC "-
'svg11-tiny' => '<!DOCTYPE svg PUBLIC "-
'xhtml-math-svg-xh' => '<!DOCTYPE html PUBLIC "-
'xhtml-math-svg-sh' => '<!DOCTYPE svg:svg PUBLIC "-
'xhtml-rdfa-1' => '<!DOCTYPE html PUBLIC "-
'xhtml-rdfa-2' => '<!DOCTYPE html PUBLIC "-
];
/**
public bool $html5 = true;
}
// app/Config/BusinessModules.php
<?php
namespace App\Config;
use CodeIgniter\Config\BaseConfig;
/**
class BusinessModules extends BaseConfig
{
/**
public array $modules = [
'base' => [
'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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Security extends BaseConfig
{
/**
public string $csrfProtection = 'cookie';
/**
public bool $tokenRandomize = false;
/**
public string $tokenName = 'csrf_bp';
/**
public string $headerName = 'X-CSRF-TOKEN';
/**
public string $cookieName = 'csrf_cookie_name';
/**
public int $expires = 7200;
/**
public bool $regenerate = true;
/**
public bool $redirect = (ENVIRONMENT === 'production');
}
// app/Config/Encryption.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
class Encryption extends BaseConfig
{
/**
public string $key = '';
/**
public string $driver = 'OpenSSL';
/**
public int $blockSize = 16;
/**
public string $digest = 'SHA512';
/**
public bool $rawData = true;
/**
public string $encryptKeyInfo = '';
/**
public string $authKeyInfo = '';
/**
public string $cipher = 'AES-256-CTR';
}
// app/Config/Toolbar.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Debug\Toolbar\Collectors\Database;
use CodeIgniter\Debug\Toolbar\Collectors\Events;
use CodeIgniter\Debug\Toolbar\Collectors\Files;
use CodeIgniter\Debug\Toolbar\Collectors\Logs;
use CodeIgniter\Debug\Toolbar\Collectors\Routes;
use CodeIgniter\Debug\Toolbar\Collectors\Timers;
use CodeIgniter\Debug\Toolbar\Collectors\Views;
/**
class Toolbar extends BaseConfig
{
/**
public array $collectors = [
Timers::class,
Database::class,
Logs::class,
Views::class,
Files::class,
Routes::class,
Events::class,
];
/**
public bool $collectVarData = true;
/**
public int $maxHistory = 20;
/**
public string $viewsPath = SYSTEMPATH . 'Debug/Toolbar/Views/';
/**
public int $maxQueries = 100;
/**
public array $watchedDirectories = [
'app',
];
/**
public array $watchedExtensions = [
'php', 'css', 'js', 'html', 'svg', 'json', 'env',
];
}
// app/Config/Optimize.php
<?php
namespace Config;
/**
class Optimize
{
/**
public bool $configCacheEnabled = false;
/**
public bool $locatorCacheEnabled = false;
}
// app/Config/Boot/testing.php
<?php
/*
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
error_reporting(E_ALL);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
defined('CI_DEBUG') || define('CI_DEBUG', true);
// app/Config/Boot/production.php
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| Don't show ANY in production environments. Instead, let the system catch
| it and display a generic error message.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. It's not widely used currently, and may not survive
| release of the framework.
defined('CI_DEBUG') || define('CI_DEBUG', false);
// app/Config/Boot/development.php
<?php
/*
|--------------------------------------------------------------------------
| ERROR DISPLAY
|--------------------------------------------------------------------------
| In development, we want to show as many errors as possible to help
| make sure they don't make it to production. And save us hours of
| painful debugging.
|
| If you set 'display_errors' to '1', CI4's detailed error report will show.
error_reporting(E_ALL);
ini_set('display_errors', '1');
/*
|--------------------------------------------------------------------------
| DEBUG BACKTRACES
|--------------------------------------------------------------------------
| If true, this constant will tell the error screens to display debug
| backtraces along with the other error information. If you would
| prefer to not see this, set this value to false.
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
/*
|--------------------------------------------------------------------------
| DEBUG MODE
|--------------------------------------------------------------------------
| Debug mode is an experimental flag that can allow changes throughout
| the system. This will control whether Kint is loaded, and a few other
| items. It can always be used within your own application too.
defined('CI_DEBUG') || define('CI_DEBUG', true);
// app/Config/Filters.php
<?php
namespace Config;
use CodeIgniter\Config\Filters as BaseFilters;
use CodeIgniter\Filters\Cors;
use CodeIgniter\Filters\CSRF;
use CodeIgniter\Filters\DebugToolbar;
use CodeIgniter\Filters\ForceHTTPS;
use CodeIgniter\Filters\Honeypot;
use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\PageCache;
use CodeIgniter\Filters\PerformanceMetrics;
use CodeIgniter\Filters\SecureHeaders;
class Filters extends BaseFilters
{
/**
public array $aliases = [
'csrf' => 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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class App extends BaseConfig
{
/**
public string $baseURL = 'http:
/**
public array $allowedHostnames = [];
/**
public string $indexPage = '';
/**
public string $uriProtocol = 'REQUEST_URI';
/*
|--------------------------------------------------------------------------
| Allowed URL Characters
|--------------------------------------------------------------------------
|
| This lets you specify which characters are permitted within your URLs.
| When someone tries to submit a URL with disallowed characters they will
| get a warning message.
|
| As a security measure you are STRONGLY encouraged to restrict URLs to
| as few characters as possible.
|
| By default, only these are allowed: `a-z 0-9~%.:_-`
|
| Set an empty string to allow all characters -- but only if you are insane.
|
| The configured value is actually a regular expression character group
| and it will be used as: '/\A[<permittedURIChars>]+\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
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Validation\StrictRules\CreditCardRules;
use CodeIgniter\Validation\StrictRules\FileRules;
use CodeIgniter\Validation\StrictRules\FormatRules;
use CodeIgniter\Validation\StrictRules\Rules;
class Validation extends BaseConfig
{
/**
public array $ruleSets = [
Rules::class,
FormatRules::class,
FileRules::class,
CreditCardRules::class,
];
/**
public array $templates = [
'list' => 'CodeIgniter\Validation\Views\list',
'single' => 'CodeIgniter\Validation\Views\single',
];
}
// app/Config/Modules.php
<?php
namespace Config;
use CodeIgniter\Modules\Modules as BaseModules;
/**
class Modules extends BaseModules
{
/**
public $enabled = true;
/**
public $discoverInComposer = true;
/**
public $composerPackages = [];
/**
public $aliases = [
'events',
'filters',
'registrars',
'routes',
'services',
];
}
// app/Config/Twig.php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Twig extends \Daycry\Twig\Config\Twig
{
public string $extension = '.twig';
/**
public string $cachePath = WRITEPATH . 'cache/twig';
/**
public array $functions_safe = ['form_open', 'form_close', 'form_hidden', 'form_error', 'json_decode', 'set_value', 'csrf_field'];
/**
public array $functions_asis = ['session', 'current_url', 'base_url', 'site_url'];
/**
public array $paths = [
APPPATH . 'Views',
[APPPATH . 'Views/components', 'components'],
[APPPATH . 'Modules/Clients/Views', 'Clients'],
[APPPATH . 'Modules/CRM/Views', 'CRM'],
[APPPATH . 'Modules/Tasks/Views', 'Tasks'],
];
/**
public array $filters = [];
/**
public array $extensions = [
\App\Libraries\Twig\TwigJsonDecodeExtension::class,
\App\Libraries\Twig\TwigGlobalsExtension::class,
];
/**
public bool $strictVariables = false;
/**
public bool $saveData = true;
/**
public bool $leanMode = false;
/**
public ?bool $enableDiscoverySnapshot = null;
/**
public ?bool $enableWarmupSummary = null;
/**
public ?bool $enableInvalidationHistory = null;
/**
public ?bool $enableDynamicMetrics = null;
/**
public ?bool $enableExtendedDiagnostics = null;
/**
public int $cacheTtl = 0;
/**
public bool $toolbarMinimal = false;
public bool $toolbarShowTemplates = true;
public int $toolbarMaxTemplates = 50;
public bool $toolbarShowCapabilities = true;
public bool $toolbarShowPersistence = true;
}
// app/ThirdParty/.gitkeep
// app/Modules/CRM/Views/deals/form.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left me-2"></i>Назад
</a>
</div>
{# Сообщения об ошибках #}
{% if errors is defined and errors|length > 0 %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<ul class="mb-0">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ actionUrl }}">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="title" class="form-label fw-bold">Название сделки *</label>
<input type="text"
class="form-control"
id="title"
name="title"
value="{{ old.title|default(deal.title|default('')) }}"
required
placeholder="Например: Разработка сайта для ООО Ромашка">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="amount" class="form-label fw-bold">Сумма</label>
<div class="input-group">
<input type="number"
class="form-control"
id="amount"
name="amount"
step="0.01"
min="0"
value="{{ old.amount|default(deal.amount|default(0)) }}">
<select class="form-select" name="currency" style="max-width: 100px;">
<option value="RUB" {{ (old.currency|default(deal.currency|default('RUB'))) == 'RUB' ? 'selected' : '' }}>₽</option>
<option value="USD" {{ (old.currency|default(deal.currency|default(''))) == 'USD' ? 'selected' : '' }}>$</option>
<option value="EUR" {{ (old.currency|default(deal.currency|default(''))) == 'EUR' ? 'selected' : '' }}>€</option>
</select>
</div>
</div>
<div class="col-md-6">
<label for="stage_id" class="form-label fw-bold">Этап *</label>
<select class="form-select" id="stage_id" name="stage_id" required>
<option value="">Выберите этап</option>
{% for stage in stages %}
<option value="{{ stage.id }}"
{{ (old.stage_id|default(deal.stage_id|default(stageId|default('')))) == stage.id ? 'selected' : '' }}
data-color="{{ stage.color }}">
{{ stage.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label fw-bold">Описание</label>
<textarea class="form-control"
id="description"
name="description"
rows="4"
placeholder="Детали сделки, условия, комментарии...">{{ old.description|default(deal.description|default('')) }}</textarea>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="company_id" class="form-label fw-bold">Клиент *</label>
<select class="form-select" id="company_id" name="company_id" required>
<option value="">Выберите клиента</option>
{% for client in clients %}
<option value="{{ client.id }}"
{{ (old.company_id|default(deal.company_id|default(''))) == client.id ? 'selected' : '' }}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="contact_id" class="form-label fw-bold">Контакт</label>
<select class="form-select" id="contact_id" name="contact_id">
<option value="">Сначала выберите клиента</option>
{% for contact in contacts %}
<option value="{{ contact.id }}"
{{ (old.contact_id|default(deal.contact_id|default(''))) == contact.id ? 'selected' : '' }}>
{{ contact.name }} {{ contact.position ? ' (' ~ contact.position ~ ')' : '' }}
</option>
{% endfor %}
</select>
<div id="contacts-loading" class="text-muted small mt-1" style="display: none;">
<i class="fa-solid fa-spinner fa-spin me-1"></i>Загрузка контактов...
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label for="assigned_user_id" class="form-label fw-bold">Ответственный</label>
<select class="form-select" id="assigned_user_id" name="assigned_user_id">
<option value="">Не назначен</option>
{% for userId, userName in users %}
<option value="{{ userId }}"
{{ (old.assigned_user_id|default(deal.assigned_user_id|default(currentUserId|default('')))) == userId ? 'selected' : '' }}>
{{ userName }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="expected_close_date" class="form-label fw-bold">Ожидаемая дата закрытия</label>
<input type="date"
class="form-control"
id="expected_close_date"
name="expected_close_date"
value="{{ old.expected_close_date|default(deal.expected_close_date|default('')) }}">
</div>
</div>
<div class="d-flex justify-content-end gap-2 pt-3 border-top">
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-2"></i>
{{ deal is defined ? 'Сохранить' : 'Создать сделку' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const companySelect = document.getElementById('company_id');
const contactSelect = document.getElementById('contact_id');
const contactsLoading = document.getElementById('contacts-loading');
// Функция загрузки контактов по клиенту
function loadContacts(clientId) {
if (!clientId) {
contactSelect.innerHTML = '<option value="">Сначала выберите клиента</option>';
contactSelect.disabled = true;
return;
}
contactSelect.disabled = true;
contactsLoading.style.display = 'block';
fetch(`/crm/deals/contacts-by-client?client_id=${clientId}`)
.then(response => response.json())
.then(data => {
contactsLoading.style.display = 'none';
if (data.success && data.contacts.length > 0) {
let options = '<option value="">Выберите контакт</option>';
data.contacts.forEach(contact => {
const label = contact.name + (contact.email ? ` (${contact.email})` : '');
options += `<option value="${contact.id}">${label}</option>`;
});
contactSelect.innerHTML = options;
contactSelect.disabled = false;
} else {
contactSelect.innerHTML = '<option value="">Нет контактов для этого клиента</option>';
contactSelect.disabled = false;
}
})
.catch(error => {
console.error('Ошибка загрузки контактов:', error);
contactsLoading.style.display = 'none';
contactSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
contactSelect.disabled = false;
});
}
// Загружаем контакты если клиент уже выбран (редактирование)
const selectedClientId = companySelect.value;
if (selectedClientId) {
const selectedContactId = contactSelect.value;
if (selectedContactId) {
// При редактировании сохраняем выбранный контакт
contactSelect.dataset.selectedId = selectedContactId;
}
loadContacts(selectedClientId);
}
// Обработчик изменения клиента
companySelect.addEventListener('change', function() {
loadContacts(this.value);
});
});
</script>
{% endblock %}
// app/Modules/CRM/Views/deals/stages.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0"><i class="fa-solid fa-list-check text-warning me-2"></i> {{ title }}</h1>
<p class="text-muted mb-0">Настройка воронки продаж. Перетаскивайте этажи для изменения порядка.</p>
</div>
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left me-2"></i>К сделкам
</a>
</div>
{# Сообщения #}
{% if success is defined %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if error is defined %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{# Форма добавления этапа #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">Добавить этап</h5>
</div>
<div class="card-body">
<form action="{{ site_url('/crm/deals/stages') }}" method="POST" class="row align-items-end g-3">
{{ csrf_field()|raw }}
<div class="col-md-4">
<label for="name" class="form-label fw-bold">Название</label>
<input type="text" class="form-control" id="name" name="name" required
placeholder="Название этапа">
</div>
<div class="col-md-2">
<label for="color" class="form-label fw-bold">Цвет</label>
<input type="color" class="form-control form-control-color" id="color"
name="color" value="#6B7280">
</div>
<div class="col-md-2">
<label for="type" class="form-label fw-bold">Тип</label>
<select class="form-select" id="type" name="type">
<option value="progress">В процессе</option>
<option value="won">Успех</option>
<option value="lost">Провал</option>
</select>
</div>
<div class="col-md-2">
<label for="probability" class="form-label fw-bold">Вероятность (%)</label>
<input type="number" class="form-control" id="probability" name="probability"
value="0" min="0" max="100">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="fa-solid fa-plus me-1"></i>Добавить
</button>
</div>
</form>
</div>
</div>
{# Список этапов #}
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Этапы</h5>
<span class="text-muted small">Перетаскивайте строки для изменения порядка</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4" style="width: 60px;"></th>
<th>Этап</th>
<th style="width: 120px;">Тип</th>
<th style="width: 120px;">Вероятность</th>
<th class="text-end pe-4" style="width: 200px;">Действия</th>
</tr>
</thead>
<tbody id="stages-list">
{% for stage in stages %}
<tr data-id="{{ stage.id }}" class="stage-row">
<td class="ps-4">
<i class="fa-solid fa-grip-vertical text-muted cursor-move" style="cursor: grab;"></i>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<span class="rounded"
style="width: 16px; height: 16px; background-color: {{ stage.color }};"></span>
<span class="fw-medium">{{ stage.name }}</span>
</div>
</td>
<td>
<span class="badge {{ stage.type_class }}">
{{ stage.type_label }}
</span>
</td>
<td>{{ stage.probability }}%</td>
<td class="text-end pe-4">
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="openEditModal({{ stage.id }}, '{{ stage.name }}', '{{ stage.color }}', '{{ stage.type }}', {{ stage.probability }})">
<i class="fa-solid fa-pen"></i>
</button>
{% if not stage.is_final %}
<form action="{{ site_url('/crm/deals/stages/' ~ stage.id) }}" method="POST" class="d-inline">
{{ csrf_field()|raw }}
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Удалить этап?')">
<i class="fa-solid fa-trash"></i>
</button>
</form>
{% else %}
<button class="btn btn-outline-secondary btn-sm" disabled title="Нельзя удалить завершающий этап">
<i class="fa-solid fa-trash"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# Модальное окно редактирования #}
<div class="modal fade" id="editModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="editForm" method="POST">
{{ csrf_field()|raw }}
<div class="modal-header">
<h5 class="modal-title">Редактировать этап</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="edit_name" class="form-label fw-bold">Название</label>
<input type="text" class="form-control" id="edit_name" name="name" required>
</div>
<div class="mb-3">
<label for="edit_color" class="form-label fw-bold">Цвет</label>
<input type="color" class="form-control form-control-color" id="edit_color" name="color">
</div>
<div class="mb-3">
<label for="edit_type" class="form-label fw-bold">Тип</label>
<select class="form-select" id="edit_type" name="type">
<option value="progress">В процессе</option>
<option value="won">Успех</option>
<option value="lost">Провал</option>
</select>
</div>
<div>
<label for="edit_probability" class="form-label fw-bold">Вероятность (%)</label>
<input type="number" class="form-control" id="edit_probability" name="probability"
min="0" max="100">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ parent() }}
<script>
function openEditModal(id, name, color, type, probability) {
const modal = new bootstrap.Modal(document.getElementById('editModal'));
const form = document.getElementById('editForm');
form.action = '{{ site_url('/crm/deals/stages/') }}' + id;
document.getElementById('edit_name').value = name;
document.getElementById('edit_color').value = color;
document.getElementById('edit_type').value = type;
document.getElementById('edit_probability').value = probability;
modal.show();
}
// Drag and Drop для сортировки этапов
document.addEventListener('DOMContentLoaded', function() {
const list = document.getElementById('stages-list');
let draggedItem = null;
if (!list) return;
// Делаем строки draggable
const rows = list.querySelectorAll('.stage-row');
rows.forEach(row => {
row.draggable = true;
row.style.cursor = 'grab';
// Стиль при захвате
row.addEventListener('dragstart', function(e) {
draggedItem = this;
this.classList.add('opacity-50');
this.style.cursor = 'grabbing';
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', function() {
this.classList.remove('opacity-50');
this.style.cursor = 'grab';
draggedItem = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
row.addEventListener('dragenter', function() {
if (this !== draggedItem) {
this.classList.add('bg-light');
}
});
row.addEventListener('dragleave', function() {
this.classList.remove('bg-light');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('bg-light');
if (this !== draggedItem && draggedItem) {
// Перемещаем строку
list.insertBefore(draggedItem, this);
// Сохраняем новый порядок на сервере
saveStageOrder();
}
});
});
// Функция сохранения порядка этапов
function saveStageOrder() {
const stageIds = [];
const rows = list.querySelectorAll('.stage-row');
rows.forEach(row => {
stageIds.push(parseInt(row.dataset.id));
});
// Отправляем как form-urlencoded (как в канбане)
const formData = new URLSearchParams();
stageIds.forEach((id) => {
formData.append('stages[]', id);
});
fetch('{{ site_url('/crm/deals/stages/reorder') }}', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: formData.toString()
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Обновляем CSRF токен из ответа
if (data.csrf_token && document.body) {
document.body.dataset.csrfToken = data.csrf_token;
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.setAttribute('content', data.csrf_token);
}
showToast('Порядок сохранён', 'success');
} else {
showToast(data.message || 'Ошибка сохранения', 'danger');
}
})
.catch(error => {
console.error('Ошибка сохранения порядка:', error);
showToast('Ошибка соединения', 'danger');
});
}
// Показать toast уведомление
function showToast(message, type) {
const toastContainer = document.createElement('div');
toastContainer.className = 'position-fixed bottom-0 end-0 p-3';
toastContainer.style.zIndex = '9999';
const toastId = 'toast-' + Date.now();
toastContainer.innerHTML = `
<div id="${toastId}" class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
document.body.appendChild(toastContainer);
const toast = new bootstrap.Toast(document.getElementById(toastId), {
autohide: true,
delay: 2000
});
toast.show();
toastContainer.addEventListener('hidden.bs.toast', () => {
toastContainer.remove();
});
}
});
</script>
{% endblock %}
// app/Modules/CRM/Views/deals/calendar_event.twig
{#
calendar_event.twig - Событие календаря для сделки
#}
<a href="{{ site_url('/crm/deals/' ~ event.id) }}"
class="calendar-event"
style="border-left-color: {{ event.stage_color|default('#6B7280') }}"
title="{{ event.title }}">
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
</a>
// app/Modules/CRM/Views/deals/kanban.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{{ title }}</h1>
<p class="text-muted mb-0">
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽ |
Успешно: {{ stats.won_count }} на {{ stats.won_total|number_format(0, ',', ' ') }} ₽
</p>
</div>
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Новая сделка
</a>
</div>
{# Переключатель видов #}
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals') }}">
<i class="fa-solid fa-list me-2"></i>Список
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ site_url('/crm/deals/kanban') }}">
<i class="fa-solid fa-columns me-2"></i>Канбан
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals/calendar') }}">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</li>
</ul>
{{ 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() }}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
// app/Modules/CRM/Views/deals/kanban_card.twig
{#
kanban_card.twig - Карточка сделки для Канбана
Используется как кастомный компонент карточки в kanban.twig
#}
<div class="card mb-2 kanban-card"
draggable="true"
data-item-id="{{ item.id }}"
style="cursor: grab;">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<a href="{{ site_url('/crm/deals/' ~ item.id) }}" class="text-decoration-none">
<strong class="text-dark">{{ item.title }}</strong>
</a>
<span class="badge bg-light text-dark">
₽{{ item.amount|number_format(0, ',', ' ') }}
</span>
</div>
{% if item.contact_name or item.client_name %}
<small class="text-muted d-block mb-2">
<i class="fa-solid fa-user me-1"></i>
{{ item.contact_name|default(item.client_name) }}
</small>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
{% if item.assigned_user_name %}
<small class="text-muted">
<i class="fa-solid fa-user-check me-1"></i>
{{ item.assigned_user_name }}
</small>
{% else %}
<small></small>
{% endif %}
{% if item.expected_close_date %}
<small class="{{ item.expected_close_date < date('today') ? 'text-danger' : 'text-muted' }}">
<i class="fa-regular fa-calendar me-1"></i>
{{ item.expected_close_date|date('d.m') }}
</small>
{% endif %}
</div>
</div>
</div>
// app/Modules/CRM/Views/deals/show.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ deal.title }}{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{{ site_url('/crm/deals') }}" class="text-muted text-decoration-none">
<i class="fa-solid fa-arrow-left me-2"></i>К списку сделок
</a>
</div>
<div class="row">
{# Основная информация #}
<div class="col-lg-8">
{# Заголовок и статус #}
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<span class="badge mb-2"
style="background-color: {{ deal.stage_color }}20; color: {{ deal.stage_color }}; border: 1px solid {{ deal.stage_color }}40;">
{{ deal.stage_name|default('Без этапа') }}
</span>
<h1 class="h3 mb-0">{{ deal.title }}</h1>
</div>
<div class="text-end">
<div class="h4 mb-0">{{ deal.amount|number_format(0, ',', ' ') }} {{ deal.currency }}</div>
<small class="text-muted">Сумма сделки</small>
</div>
</div>
{% if deal.description %}
<div class="mb-4">
<h6 class="text-muted mb-2">Описание</h6>
<p class="mb-0" style="white-space: pre-wrap;">{{ deal.description }}</p>
</div>
{% endif %}
<div class="row text-sm">
<div class="col-md-6">
<span class="text-muted">Дата создания:</span>
<span class="ms-2">{{ deal.created_at|date('d.m.Y H:i') }}</span>
</div>
<div class="col-md-6">
<span class="text-muted">Ожидаемое закрытие:</span>
<span class="ms-2 {{ deal.is_overdue ? 'text-danger fw-bold' : '' }}">
{{ deal.expected_close_date ? deal.expected_close_date|date('d.m.Y') : '—' }}
</span>
</div>
</div>
</div>
</div>
{# История изменений #}
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">История изменений</h5>
</div>
<div class="card-body">
{% if history is defined and history|length > 0 %}
<div class="timeline">
{% for item in history %}
<div class="timeline-item d-flex gap-3 pb-3 {{ not loop.last ? 'border-bottom' : '' }}">
<div class="timeline-icon bg-secondary rounded-circle d-flex align-items-center justify-content-center"
style="width: 32px; height: 32px; min-width: 32px;">
<span class="text-white small">{{ item.user_name|default('С')|slice(0, 2) }}</span>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-medium">{{ item.user_name|default('Система') }}</span>
<small class="text-muted ms-2">{{ item.created_at|date('d.m.Y H:i') }}</small>
</div>
</div>
<p class="mb-0 mt-1">
<span class="{{ item.action_class }}">{{ item.action_label }}</span>
{% if item.change_description %}
— {{ item.change_description }}
{% endif %}
</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center py-4 mb-0">Нет записей в истории</p>
{% endif %}
</div>
</div>
</div>
{# Боковая панель #}
<div class="col-lg-4">
{# Клиент #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white">
<h6 class="mb-0">Клиент</h6>
</div>
<div class="card-body">
{% if deal.contact_name %}
<div class="d-flex align-items-center gap-3">
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; min-width: 40px;">
<span class="text-white">{{ deal.contact_name|slice(0, 2) }}</span>
</div>
<div>
<a href="#" class="fw-medium text-decoration-none">{{ deal.contact_name }}</a>
{% if deal.contact_email %}
<small class="d-block text-muted">{{ deal.contact_email }}</small>
{% endif %}
</div>
</div>
{% elseif deal.client_name %}
<div class="d-flex align-items-center gap-3">
<div class="bg-success rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; min-width: 40px;">
<span class="text-white">{{ deal.client_name|slice(0, 2) }}</span>
</div>
<div>
<a href="#" class="fw-medium text-decoration-none">{{ deal.client_name }}</a>
</div>
</div>
{% else %}
<p class="text-muted mb-0">Клиент не указан</p>
{% endif %}
</div>
</div>
{# Ответственный #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white">
<h6 class="mb-0">Ответственный</h6>
</div>
<div class="card-body">
{% if deal.assigned_user_name %}
<div class="d-flex align-items-center gap-3">
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center"
style="width: 40px; height: 40px; min-width: 40px;">
<span class="text-white">{{ deal.assigned_user_name|slice(0, 2) }}</span>
</div>
<div>
<div class="fw-medium">{{ deal.assigned_user_name }}</div>
{% if deal.assigned_user_email %}
<small class="text-muted">{{ deal.assigned_user_email }}</small>
{% endif %}
</div>
</div>
{% else %}
<p class="text-muted mb-0">Не назначен</p>
{% endif %}
</div>
</div>
{# Вероятность #}
{% if deal.stage_probability is defined and deal.stage_probability > 0 %}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white">
<h6 class="mb-0">Вероятность закрытия</h6>
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 8px;">
<div class="progress-bar bg-primary"
role="progressbar"
style="width: {{ deal.stage_probability }}%"></div>
</div>
<span class="fw-medium">{{ deal.stage_probability }}%</span>
</div>
</div>
</div>
{% endif %}
{# Действия #}
<div class="card shadow-sm">
<div class="card-body d-flex gap-2">
<a href="{{ site_url('/crm/deals/' ~ deal.id ~ '/edit') }}" class="btn btn-outline-primary flex-grow-1">
<i class="fa-solid fa-pen me-1"></i>Редактировать
</a>
<form action="{{ site_url('/crm/deals/' ~ deal.id) }}" method="POST" class="flex-grow-1">
{{ csrf_field()|raw }}
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-outline-danger w-100"
onclick="return confirm('Удалить сделку?')">
<i class="fa-solid fa-trash me-1"></i>Удалить
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.timeline {
position: relative;
}
.timeline-item:last-child {
padding-bottom: 0 !important;
}
</style>
{% endblock %}
// app/Modules/CRM/Views/deals/index.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0"><i class="fa-solid fa-file-contract text-primary me-2"></i>{{ title }}</h1>
<p class="text-muted mb-0">
Всего: {{ items|length }} |
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽
</p>
</div>
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Новая сделка
</a>
</div>
{# Переключатель видов #}
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link active" href="{{ site_url('/crm/deals') }}">
<i class="fa-solid fa-list me-2"></i>Список
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals/kanban') }}">
<i class="fa-solid fa-columns me-2"></i>Канбан
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals/calendar') }}">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</li>
</ul>
<div class="card shadow-sm">
<div class="card-body p-0">
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="{{ site_url('/assets/js/modules/DataTable.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Modules/CRM/Views/deals/calendar.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">{{ title }}</h1>
<p class="text-muted mb-0">План закрытия сделок</p>
</div>
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Новая сделка
</a>
</div>
{# Переключатель видов #}
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals') }}">
<i class="fa-solid fa-list me-2"></i>Список
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ site_url('/crm/deals/kanban') }}">
<i class="fa-solid fa-columns me-2"></i>Канбан
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ site_url('/crm/deals/calendar') }}">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</li>
</ul>
{# Календарь #}
{{ 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() }}
<style>
.calendar-events-more {
padding: 0.125rem 0.5rem;
}
</style>
{% endblock %}
// app/Modules/CRM/Views/contacts/form.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<a href="{{ site_url('/crm/contacts') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left me-2"></i>К списку
</a>
</div>
{# Сообщения об ошибках #}
{% if errors is defined and errors|length > 0 %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<ul class="mb-0">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ actionUrl }}">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="name" class="form-label fw-bold">Имя *</label>
<input type="text"
class="form-control"
id="name"
name="name"
value="{{ old.name|default(contact.name|default('')) }}"
required
placeholder="Иван Иванов">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="email" class="form-label fw-bold">Email</label>
<input type="email"
class="form-control"
id="email"
name="email"
value="{{ old.email|default(contact.email|default('')) }}"
placeholder="email@example.com">
</div>
<div class="col-md-6">
<label for="phone" class="form-label fw-bold">Телефон</label>
<input type="text"
class="form-control"
id="phone"
name="phone"
value="{{ old.phone|default(contact.phone|default('')) }}"
placeholder="+7 (999) 123-45-67">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="position" class="form-label fw-bold">Должность</label>
<input type="text"
class="form-control"
id="position"
name="position"
value="{{ old.position|default(contact.position|default('')) }}"
placeholder="Менеджер, Директор...">
</div>
<div class="col-md-6">
<label for="customer_id" class="form-label fw-bold">Клиент</label>
<select class="form-select" id="customer_id" name="customer_id">
<option value="">Не привязан</option>
{% for client in clients %}
<option value="{{ client.id }}"
{{ (old.customer_id|default(contact.customer_id|default(''))) == client.id ? 'selected' : '' }}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label for="notes" class="form-label fw-bold">Заметки</label>
<textarea class="form-control"
id="notes"
name="notes"
rows="3"
placeholder="Дополнительная информация...">{{ old.notes|default(contact.notes|default('')) }}</textarea>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="is_primary"
name="is_primary"
value="1"
{{ (old.is_primary|default(contact.is_primary|default(false))) ? 'checked' : '' }}>
<label class="form-check-label" for="is_primary">
Основной контакт клиента
</label>
</div>
</div>
<div class="d-flex justify-content-end gap-2 pt-3 border-top">
<a href="{{ site_url('/crm/contacts') }}" class="btn btn-outline-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-2"></i>
{{ contact is defined ? 'Сохранить' : 'Создать' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Modules/CRM/Views/contacts/index.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block styles %}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0"><i class="fa-solid fa-users text-info me-2"></i> {{ title }}</h1>
</div>
<a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить контакт
</a>
</div>
{# Сообщения #}
{% if session.success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session.success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if session.error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session.error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body p-0">
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ site_url('/assets/js/modules/DataTable.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Modules/CRM/Views/dashboard.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0"><i class="fa-solid fa-chart-line text-primary me-2"></i>CRM</h1>
<p class="text-muted mb-0">Управление продажами и клиентами</p>
</div>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-primary">{{ stats.open_count }}</div>
<div class="text-muted">Открытых сделок</div>
<div class="text-success small">{{ stats.open_total|number_format(0, ',', ' ') }} ₽</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-success">{{ stats.won_count }}</div>
<div class="text-muted">Успешных сделок</div>
<div class="text-success small">{{ stats.won_total|number_format(0, ',', ' ') }} ₽</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-secondary">{{ counts.clients }}</div>
<div class="text-muted">Клиентов</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-info">{{ counts.contacts }}</div>
<div class="text-muted">Контактов</div>
</div>
</div>
</div>
</div>
{# Меню #}
<div class="row g-3">
<div class="col-md-3">
<a href="{{ site_url('/crm/deals') }}" class="card shadow-sm text-decoration-none h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary bg-opacity-10 rounded p-3">
<i class="fa-solid fa-file-contract text-primary fs-4"></i>
</div>
<div>
<div class="fw-bold text-dark">Сделки</div>
<div class="text-muted small">Воронка продаж</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="{{ site_url('/clients') }}" class="card shadow-sm text-decoration-none h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="bg-success bg-opacity-10 rounded p-3">
<i class="fa-solid fa-building text-success fs-4"></i>
</div>
<div>
<div class="fw-bold text-dark">Клиенты</div>
<div class="text-muted small">Компании</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="{{ site_url('/crm/contacts') }}" class="card shadow-sm text-decoration-none h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="bg-info bg-opacity-10 rounded p-3">
<i class="fa-solid fa-users text-info fs-4"></i>
</div>
<div>
<div class="fw-bold text-dark">Контакты</div>
<div class="text-muted small">Люди в компаниях</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="{{ site_url('/crm/deals/stages') }}" class="card shadow-sm text-decoration-none h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-3">
<div class="bg-warning bg-opacity-10 rounded p-3">
<i class="fa-solid fa-list-check text-warning fs-4"></i>
</div>
<div>
<div class="fw-bold text-dark">Этапы</div>
<div class="text-muted small">Воронка продаж ({{ counts.stages }})</div>
</div>
</div>
</div>
</a>
</div>
</div>
{% endblock %}
// app/Modules/CRM/Services/DealStageService.php
<?php
namespace App\Modules\CRM\Services;
use App\Modules\CRM\Models\DealModel;
use App\Modules\CRM\Models\DealStageModel;
class DealStageService
{
protected DealStageModel $stageModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\CRM\Services;
use App\Modules\CRM\Models\DealModel;
use App\Modules\CRM\Models\DealStageModel;
use CodeIgniter\Events\Events;
class DealService
{
protected DealModel $dealModel;
protected DealStageModel $stageModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\CRM\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class ContactModel extends Model
{
use TenantScopedModel;
protected $table = 'contacts';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = \App\Modules\CRM\Entities\Contact::class;
protected $useSoftDeletes = true;
protected $allowedFields = [
'organization_id',
'customer_id',
'name',
'email',
'phone',
'position',
'is_primary',
'notes',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
/**
public function getContactsForCustomer(int $customerId): array
{
return $this->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
<?php
namespace App\Modules\CRM\Models;
use CodeIgniter\Model;
class DealModel extends Model
{
protected $table = 'deals';
protected $primaryKey = 'id';
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
protected $returnType = 'array';
protected $allowedFields = [
'organization_id',
'contact_id',
'company_id',
'title',
'description',
'amount',
'currency',
'stage_id',
'assigned_user_id',
'expected_close_date',
'created_by',
];
/**
public function getForTable(int $organizationId): array
{
return $this->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
<?php
namespace App\Modules\CRM\Models;
use CodeIgniter\Model;
class DealStageModel extends Model
{
protected $table = 'deal_stages';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'organization_id',
'name',
'color',
'order_index',
'type',
'probability',
];
protected $useTimestamps = false;
/**
public function getStagesByOrganization(int $organizationId): array
{
return $this->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
<?php
$routes->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
<?php
namespace App\Modules\CRM\Entities;
use CodeIgniter\Entity\Entity;
class Contact extends Entity
{
protected $attributes = [
'id' => 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
<?php
namespace App\Modules\CRM\Controllers;
use App\Controllers\BaseController;
use App\Modules\CRM\Models\DealModel;
use App\Modules\CRM\Models\DealStageModel;
use App\Modules\CRM\Services\DealService;
use App\Modules\CRM\Services\DealStageService;
use App\Modules\CRM\Models\ContactModel;
use App\Modules\Clients\Models\ClientModel;
class DealsController extends BaseController
{
protected DealService $dealService;
protected DealStageService $stageService;
protected DealModel $dealModel;
protected DealStageModel $stageModel;
protected ContactModel $contactModel;
protected ClientModel $clientModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\CRM\Controllers;
use App\Controllers\BaseController;
use App\Modules\CRM\Models\DealModel;
use App\Modules\CRM\Models\DealStageModel;
use App\Modules\CRM\Models\ContactModel;
use App\Modules\Clients\Models\ClientModel;
class DashboardController extends BaseController
{
protected DealModel $dealModel;
protected DealStageModel $stageModel;
protected ContactModel $contactModel;
protected ClientModel $clientModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\CRM\Controllers;
use App\Controllers\BaseController;
use App\Modules\CRM\Models\ContactModel;
use App\Modules\Clients\Models\ClientModel;
class ContactsController extends BaseController
{
protected ContactModel $contactModel;
protected ClientModel $clientModel;
public function __construct()
{
$this->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 %}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<div class="d-flex align-items-center">
<a href="{{ base_url('/clients') }}" class="btn btn-outline-secondary me-3">
<i class="fa-solid fa-arrow-left"></i>
</a>
<div>
<h1 class="h4 mb-0">{{ title }}</h1>
</div>
</div>
</div>
<div class="card-body">
{{ forms.form_open(client ? base_url('/clients/update/' ~ client.id) : base_url('/clients/create')) }}
{# Табы #}
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-main" type="button">
<i class="fa-solid fa-building me-2"></i>Основное
</button>
</li>
{% if crm_active %}
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-contacts" type="button">
<i class="fa-solid fa-users me-2"></i>Контакты
<span class="badge bg-primary ms-1" id="contacts-count">0</span>
</button>
</li>
{% endif %}
</ul>
{# Содержимое табов #}
<div class="tab-content">
{# Таб "Основное" #}
<div class="tab-pane fade show active" id="tab-main" role="tabpanel">
<div class="mb-3">
<label for="name" class="form-label fw-bold">Имя / Название *</label>
<input type="text" name="name" id="name" class="form-control {{ errors.name ? 'is-invalid' : '' }}"
value="{{ old.name ?? client.name ?? '' }}" required autofocus>
{% if errors.name %}
<div class="invalid-feedback">{{ errors.name }}</div>
{% endif %}
<div class="form-text">ФИО клиента или название компании</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" id="email" class="form-control {{ errors.email ? 'is-invalid' : '' }}"
value="{{ old.email ?? client.email ?? '' }}">
{% if errors.email %}
<div class="invalid-feedback">{{ errors.email }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" name="phone" id="phone" class="form-control {{ errors.phone ? 'is-invalid' : '' }}"
value="{{ old.phone ?? client.phone ?? '' }}">
{% if errors.phone %}
<div class="invalid-feedback">{{ errors.phone }}</div>
{% endif %}
</div>
</div>
<div class="mb-4">
<label for="notes" class="form-label">Заметки</label>
<textarea name="notes" id="notes" rows="4" class="form-control {{ errors.notes ? 'is-invalid' : '' }}"
placeholder="Дополнительная информация о клиенте...">{{ old.notes ?? client.notes ?? '' }}</textarea>
{% if errors.notes %}
<div class="invalid-feedback">{{ errors.notes }}</div>
{% endif %}
</div>
</div>
{# Таб "Контакты" (только при активном CRM) #}
{% if crm_active %}
<div class="tab-pane fade" id="tab-contacts" role="tabpanel">
<input type="hidden" name="customer_id" value="{{ client.id }}">
{# Скрипт инициализации контактов подключаем в конце #}
<div id="contacts-container"
data-client-id="{{ client.id }}"
data-api-url="{{ base_url('/crm/contacts') }}"
data-csrf-token="{{ csrf_hash }}">
{# Таблица контактов загружается через AJAX #}
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="text-muted mt-2">Загрузка контактов...</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="d-flex justify-content-end gap-2 pt-3 border-top mt-4">
<a href="{{ base_url('/clients') }}" class="btn btn-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-2"></i>
{{ client ? 'Сохранить изменения' : 'Добавить клиента' }}
</button>
</div>
{{ forms.form_close() }}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{# Inline-редактирование контактов #}
{% if crm_active %}
<script src="{{ base_url('/assets/js/modules/contacts.js') }}"></script>
{% endif %}
{% endblock %}
// app/Modules/Clients/Views/_client_modal.twig
{#
# Модальное окно просмотра клиента
#}
{# Скрытый модальный контейнер - будет показан при клике на строку таблицы #}
<div class="modal fade" id="viewClientModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
{# Header #}
<div class="modal-header">
<div class="d-flex align-items-center">
<div id="clientAvatar" class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
<i class="fa-solid fa-user fs-5"></i>
</div>
<div>
<h5 class="modal-title mb-0" id="clientName">—</h5>
<span id="clientStatus" class="badge bg-success">Активен</span>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
{# Body #}
<div class="modal-body">
{# Навигация по вкладкам #}
<ul class="nav nav-tabs mb-3" id="clientTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab">
<i class="fa-solid fa-user me-2"></i>Основное
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="contact-tab" data-bs-toggle="tab" data-bs-target="#contact" type="button" role="tab">
<i class="fa-solid fa-address-book me-2"></i>Контакты
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
<i class="fa-solid fa-note-sticky me-2"></i>Заметки
</button>
</li>
</ul>
{# Контент вкладок #}
<div class="tab-content" id="clientTabsContent">
{# Вкладка: Основное #}
<div class="tab-pane fade show active" id="general" role="tabpanel">
<div class="row">
<div class="col-md-6 mb-3">
<label class="text-muted small mb-1">Email</label>
<div id="clientEmail" class="fw-medium">—</div>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small mb-1">Телефон</label>
<div id="clientPhone" class="fw-medium">—</div>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small mb-1">Дата создания</label>
<div id="clientCreated" class="fw-medium">—</div>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small mb-1">Последнее обновление</label>
<div id="clientUpdated" class="fw-medium">—</div>
</div>
</div>
</div>
{# Вкладка: Контакты #}
<div class="tab-pane fade" id="contact" role="tabpanel">
<div class="text-center py-4 text-muted">
<i class="fa-solid fa-envelope fa-3x mb-3"></i>
<p class="mb-0">Email: <a id="contactEmail" href="#">—</a></p>
<p>Телефон: <a id="contactPhone" href="#">—</a></p>
</div>
</div>
{# Вкладка: Заметки #}
<div class="tab-pane fade" id="notes" role="tabpanel">
<div id="clientNotes" class="p-3 bg-light rounded">
</div>
</div>
</div>
</div>
{# Footer #}
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fa-solid fa-times me-2"></i>Закрыть
</button>
<a id="clientEditLink" href="#" class="btn btn-primary">
<i class="fa-solid fa-pen me-2"></i>Редактировать
</a>
</div>
</div>
</div>
</div>
<script>
let viewClientModal = null;
document.addEventListener('DOMContentLoaded', function() {
// Инициализируем модальное окно
viewClientModal = new bootstrap.Modal(document.getElementById('viewClientModal'));
});
/**
* Открыть карточку клиента
*/
function viewClient(clientId) {
// Показываем модалку с лоадером
showClientLoader();
viewClientModal.show();
// Загружаем данные
fetch('/clients/view/' + clientId)
.then(response => response.json())
.then(data => {
if (data.success) {
renderClientData(data.data);
} else {
showClientError(data.error || 'Ошибка загрузки');
}
})
.catch(error => {
console.error('Error:', error);
showClientError('Ошибка соединения');
});
}
/**
* Показать лоадер в модальном окне
*/
function showClientLoader() {
document.getElementById('clientName').textContent = 'Загрузка...';
document.getElementById('clientStatus').className = 'badge bg-secondary';
document.getElementById('clientStatus').textContent = '—';
// Очищаем поля
document.getElementById('clientEmail').textContent = '—';
document.getElementById('clientPhone').textContent = '—';
document.getElementById('clientCreated').textContent = '—';
document.getElementById('clientUpdated').textContent = '—';
document.getElementById('clientNotes').innerHTML = '—';
document.getElementById('clientNotes').classList.remove('bg-light');
document.getElementById('contactEmail').textContent = '—';
document.getElementById('contactEmail').href = '#';
document.getElementById('contactPhone').textContent = '—';
document.getElementById('contactPhone').href = '#';
document.getElementById('clientEditLink').href = '#';
}
/**
* Показать ошибку загрузки
*/
function showClientError(message) {
document.getElementById('clientName').textContent = 'Ошибка';
document.getElementById('clientNotes').innerHTML = '<span class="text-danger">' + message + '</span>';
document.getElementById('clientNotes').classList.add('bg-light');
}
/**
* Отобразить данные клиента
*/
function renderClientData(client) {
// Имя и аватар
const initials = client.name ? client.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '?';
const avatarEl = document.getElementById('clientAvatar');
avatarEl.innerHTML = initials;
document.getElementById('clientName').textContent = client.name || '—';
// Статус
const statusEl = document.getElementById('clientStatus');
if (client.status === 'active') {
statusEl.className = 'badge bg-success';
statusEl.textContent = 'Активен';
} else if (client.status === 'blocked') {
statusEl.className = 'badge bg-danger';
statusEl.textContent = 'Заблокирован';
} else {
statusEl.className = 'badge bg-secondary';
statusEl.textContent = client.status || '—';
}
// Основная информация
document.getElementById('clientEmail').textContent = client.email || '—';
document.getElementById('clientPhone').textContent = client.phone || '—';
document.getElementById('clientCreated').textContent = client.created_at || '—';
document.getElementById('clientUpdated').textContent = client.updated_at || '—';
// Заметки
const notesEl = document.getElementById('clientNotes');
if (client.notes && client.notes.trim()) {
notesEl.textContent = client.notes;
notesEl.classList.add('bg-light');
} else {
notesEl.innerHTML = '<em class="text-muted">Заметок нет</em>';
notesEl.classList.add('bg-light');
}
// Контакты для клика
const contactEmail = document.getElementById('contactEmail');
if (client.email) {
contactEmail.textContent = client.email;
contactEmail.href = 'mailto:' + client.email;
} else {
contactEmail.textContent = '—';
contactEmail.href = '#';
}
const contactPhone = document.getElementById('contactPhone');
if (client.phone) {
contactPhone.textContent = client.phone;
contactPhone.href = 'tel:' + client.phone;
} else {
contactPhone.textContent = '—';
contactPhone.href = '#';
}
// Ссылка на редактирование
document.getElementById('clientEditLink').href = '/clients/edit/' + client.id;
}
</script>
// app/Modules/Clients/Views/import.twig
{% extends 'layouts/base.twig' %}
{% block title %}Импорт клиентов - {{ parent() }}{% endblock %}
{% block content %}
<div class="container" style="max-width: 700px;">
<div class="card shadow">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fa-solid fa-file-import me-2"></i>Импорт клиентов</h5>
<a href="{{ base_url('/clients') }}" class="btn btn-light btn-sm">
<i class="fa-solid fa-arrow-left"></i> Назад
</a>
</div>
<div class="card-body p-4">
<form id="importForm" action="{{ base_url('/clients/import') }}" method="POST" enctype="multipart/form-data">
{{ csrf_field()|raw }}
{# Инструкция #}
<div class="alert alert-info mb-4">
<h6 class="alert-heading"><i class="fa-solid fa-info-circle me-2"></i>Инструкция по импорту</h6>
<ul class="mb-0">
<li>Загрузите файл в формате <strong>CSV</strong> (разделитель — точка с запятой)</li>
<li>Файл должен содержать заголовки в первой строке</li>
<li>Обязательные поля: <strong>Имя</strong></li>
<li>Опционально: Email, Телефон</li>
</ul>
</div>
{# Ссылка на шаблон #}
<div class="mb-4">
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-download me-2"></i>Скачать шаблон
</a>
</div>
{# Загрузка файла #}
<div class="mb-4">
<label for="file" class="form-label fw-medium">Выберите файл</label>
<input type="file"
class="form-control"
id="file"
name="file"
accept=".csv"
required>
<div class="form-text">Поддерживается только формат CSV</div>
</div>
{# Кнопка отправки #}
<div class="d-flex justify-content-end">
<a href="{{ base_url('/clients') }}" class="btn btn-secondary me-3">Отмена</a>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fa-solid fa-upload me-2"></i>Импортировать
</button>
</div>
</form>
{# Результат импорта (скрыт по умолчанию) #}
<div id="importResult" class="mt-4" style="display: none;">
<div class="alert alert-success">
<h6><i class="fa-solid fa-check-circle me-2"></i>Импорт завершён</h6>
<p class="mb-0" id="importMessage"></p>
</div>
<div id="importErrors" class="alert alert-warning" style="display: none;">
<h6><i class="fa-solid fa-exclamation-triangle me-2"></i>Ошибки</h6>
<ul class="mb-0 small" id="errorsList"></ul>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('importForm');
const submitBtn = document.getElementById('submitBtn');
const resultDiv = document.getElementById('importResult');
const messageEl = document.getElementById('importMessage');
const errorsDiv = document.getElementById('importErrors');
const errorsList = document.getElementById('errorsList');
form.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(form);
// Блокируем кнопку
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Загрузка...';
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData
});
const data = await response.json();
// Показываем результат
resultDiv.style.display = 'block';
messageEl.textContent = data.message;
if (data.errors && data.errors.length > 0) {
errorsDiv.style.display = 'block';
errorsList.innerHTML = data.errors.map(err => '<li>' + err + '</li>').join('');
} else {
errorsDiv.style.display = 'none';
}
if (data.success) {
// Очищаем форму
form.reset();
}
} catch (error) {
console.error('Error:', error);
resultDiv.style.display = 'block';
messageEl.innerHTML = '<span class="text-danger">Произошла ошибка при загрузке файла</span>';
errorsDiv.style.display = 'none';
} finally {
// Разблокируем кнопку
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fa-solid fa-upload me-2"></i>Импортировать';
}
});
});
</script>
{% endblock %}
// app/Modules/Clients/Views/index.twig
{% extends 'layouts/base.twig' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0"><i class="fa-solid fa-building text-success me-2"></i> {{ title }}</h1>
<p class="text-muted mb-0">Управление клиентами вашей организации</p>
</div>
<div class="d-flex gap-2">
{# Кнопки экспорта и импорта #}
<div class="btn-group">
<a href="{{ base_url('/clients/export?format=csv') }}" class="btn btn-outline-success" title="Экспорт CSV">
<i class="fa-solid fa-file-csv me-1"></i>CSV
</a>
<a href="{{ base_url('/clients/export?format=xlsx') }}" class="btn btn-outline-success" title="Экспорт Excel">
<i class="fa-solid fa-file-excel me-1"></i>Excel
</a>
</div>
<a href="{{ base_url('/clients/import') }}" class="btn btn-outline-primary" title="Импорт клиентов">
<i class="fa-solid fa-file-import me-1"></i>Импорт
</a>
{% if can_create %}
<a href="{{ base_url('/clients/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
</a>
{% endif %}
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<div class="d-flex align-items-center justify-content-between">
<div class="text-muted small">
Нажмите на <i class="fa-solid fa-search text-muted"></i> для поиска по столбцу
</div>
</div>
</div>
<div id="clients-table">
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
{# Модальное окно просмотра клиента #}
{% include '@Clients/_client_modal.twig' %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация DataTable
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}
// app/Modules/Clients/Models/ClientModel.php
<?php
namespace App\Modules\Clients\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class ClientModel extends Model
{
use TenantScopedModel;
protected $table = 'organizations_clients';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = true;
protected $allowedFields = ['organization_id', 'name', 'email', 'phone', 'notes'];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
/**
public function forOrganization(int $organizationId)
{
return $this->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
<?php
$routes->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
<?php
namespace App\Modules\Clients\Controllers;
use App\Controllers\BaseController;
use App\Modules\Clients\Models\ClientModel;
use App\Services\AccessService;
use App\Services\ModuleSubscriptionService;
class Clients extends BaseController
{
protected ClientModel $clientModel;
protected ModuleSubscriptionService $subscriptionService;
public function __construct()
{
$this->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 %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form action="{{ actionUrl }}" method="post" id="task-form">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="title" class="form-label">Название *</label>
<input type="text" class="form-control" id="title" name="title" required
value="{{ task.title|default('') }}">
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="4">{{ task.description|default('') }}</textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="board_id" class="form-label">Доска</label>
<select class="form-select" id="board_id" name="board_id" required>
{% for board in boards %}
<option value="{{ board.id }}" {{ (task.board_id|default(selectedBoard) == board.id) ? 'selected' : '' }}>
{{ board.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="column_id" class="form-label">Статус</label>
<select class="form-select" id="column_id" name="column_id" required>
{# Заполняется через JS #}
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="priority" class="form-label">Приоритет</label>
<select class="form-select" id="priority" name="priority">
{% for value, label in priorities %}
<option value="{{ value }}" {{ (task.priority|default('medium') == value) ? 'selected' : '' }}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="due_date" class="form-label">Срок выполнения</label>
<input type="date" class="form-control" id="due_date" name="due_date"
value="{{ task.due_date|default('') }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Исполнители</label>
<select class="form-select" id="assignees" name="assignees[]" multiple>
{% for userId, userName in users %}
<option value="{{ userId }}" {% if task.assignees and userId in task.assignees|map(a => a.user_id) %}selected{% endif %}>
{{ userName }}
</option>
{% endfor %}
</select>
<div class="form-text">Выберите нескольких исполнителей, удерживая Ctrl/Cmd</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-2"></i>Сохранить
</button>
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
{% if task %}
<p class="mb-1"><strong>Создано:</strong> {{ task.created_at|date('d.m.Y H:i') }}</p>
<p class="mb-1"><strong>Автор:</strong> {{ task.created_by_name|default('—') }}</p>
{% if task.completed_at %}
<p class="mb-1 text-success"><strong>Завершено:</strong> {{ task.completed_at|date('d.m.Y H:i') }}</p>
{% endif %}
{% else %}
<p class="text-muted mb-0">Заполните форму для создания новой задачи</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Загрузка колонок при изменении доски
const boardSelect = document.getElementById('board_id');
const columnSelect = document.getElementById('column_id');
function loadColumns(boardId, selectedColumnId) {
fetch(`/api/tasks/columns?board_id=${boardId}`)
.then(response => response.json())
.then(data => {
columnSelect.innerHTML = '';
data.columns.forEach(column => {
const option = document.createElement('option');
option.value = column.id;
option.textContent = column.name;
if (column.id == selectedColumnId) {
option.selected = true;
}
columnSelect.appendChild(option);
});
});
}
const currentColumnId = {{ task.column_id|default(0) }};
loadColumns(boardSelect.value, currentColumnId || null);
boardSelect.addEventListener('change', function() {
loadColumns(this.value, null);
});
});
</script>
{% endblock %}
// app/Modules/Tasks/Views/tasks/kanban.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<div class="d-flex gap-3 align-items-center">
{# Выбор доски #}
<select class="form-select" style="width: 200px" onchange="window.location.href = '{{ base_url('/tasks/kanban?board=') }}' + this.value">
{% for b in boards %}
<option value="{{ b.id }}" {{ board.id == b.id ? 'selected' : '' }}>{{ b.name }}</option>
{% endfor %}
</select>
<div class="btn-group">
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-list me-2"></i>Список
</a>
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</div>
<a href="{{ base_url('/tasks/new?board=' ~ board.id) }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить задачу
</a>
</div>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Всего</h5>
<h2 class="mb-0">{{ stats.total }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Выполнено</h5>
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">В ожидании</h5>
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Просрочено</h5>
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
</div>
</div>
</div>
</div>
{# Канбан доска #}
<div class="kanban-container" style="overflow-x: auto; padding-bottom: 1rem;">
<div class="d-flex gap-3" style="min-width: max-content;">
{% for column in kanbanColumns %}
<div class="kanban-column" style="width: 320px; min-width: 320px;">
<div class="card border-0 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: {{ column.color }}; color: white;">
<h6 class="mb-0 fw-bold">{{ column.name }}</h6>
<span class="badge bg-light text-dark">{{ column.items|length }}</span>
</div>
<div class="card-body p-2 kanban-items" data-column-id="{{ column.id }}"
style="min-height: 400px; max-height: 600px; overflow-y: auto;">
{% for item in column.items %}
<div class="card mb-2 kanban-item" data-task-id="{{ item.id }}" style="cursor: grab;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">{{ item.title }}</h6>
{% if item.priority == 'urgent' %}
<span class="badge bg-danger" style="font-size: 0.6rem;">Срочно</span>
{% elseif item.priority == 'high' %}
<span class="badge bg-warning text-dark" style="font-size: 0.6rem;">Высокий</span>
{% endif %}
</div>
{% if item.description %}
<p class="card-text text-muted small mb-2">
{{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }}
</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
{% if item.due_date %}
<small class="text-muted">
<i class="fa-regular fa-calendar me-1"></i>
{{ item.due_date|date('d.m') }}
{% if item.due_date < date('Y-m-d') %}
<span class="text-danger">!</span>
{% endif %}
</small>
{% endif %}
<a href="{{ base_url('/tasks/' ~ item.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fa-solid fa-arrow-right"></i>
</a>
</div>
</div>
</div>
{% endfor %}
{# Кнопка добавления #}
<a href="{{ base_url('/tasks/new?board=' ~ board.id ~ '&column=' ~ column.id) }}" class="btn btn-outline-secondary btn-sm w-100 mt-2">
<i class="fa-solid fa-plus me-1"></i>Добавить задачу
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<form id="move-task-form" action="{{ base_url('/tasks/move-column') }}" method="post" style="display: none;">
{{ csrf_field|raw }}
<input type="hidden" name="task_id" id="move-task-id">
<input type="hidden" name="column_id" id="move-column-id">
</form>
{% endblock %}
{% block scripts %}
<script>
// Drag and drop для Канбана
document.addEventListener('DOMContentLoaded', function() {
const columns = document.querySelectorAll('.kanban-items');
let draggedItem = null;
columns.forEach(column => {
column.addEventListener('dragover', function(e) {
e.preventDefault();
this.style.backgroundColor = '#f8f9fa';
});
column.addEventListener('dragleave', function() {
this.style.backgroundColor = '';
});
column.addEventListener('drop', function(e) {
e.preventDefault();
this.style.backgroundColor = '';
if (draggedItem && draggedItem.dataset.taskId) {
const taskId = draggedItem.dataset.taskId;
const newColumnId = this.dataset.columnId;
// Отправляем запрос на перемещение
document.getElementById('move-task-id').value = taskId;
document.getElementById('move-column-id').value = newColumnId;
fetch('{{ base_url('/tasks/move-column') }}', {
method: 'POST',
body: new FormData(document.getElementById('move-task-form'))
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Перезагружаем страницу для обновления
location.reload();
} else {
alert('Ошибка при перемещении задачи');
}
})
.catch(error => {
console.error('Error:', error);
alert('Ошибка при перемещении задачи');
});
}
draggedItem = null;
});
});
document.querySelectorAll('.kanban-item').forEach(item => {
item.addEventListener('dragstart', function() {
draggedItem = this;
this.style.opacity = '0.5';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
draggedItem = null;
});
});
});
</script>
{% endblock %}
// app/Modules/Tasks/Views/tasks/show.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<div class="btn-group">
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-list me-2"></i>Список
</a>
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-table-columns me-2"></i>Канбан
</a>
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{% if task.priority == 'urgent' %}
<span class="badge bg-danger me-2">Срочно</span>
{% elseif task.priority == 'high' %}
<span class="badge bg-warning text-dark me-2">Высокий</span>
{% elseif task.priority == 'low' %}
<span class="badge bg-secondary me-2">Низкий</span>
{% endif %}
{{ task.title }}
</h5>
<div>
{% if not task.completed_at %}
<form action="{{ base_url('/tasks/' ~ task.id ~ '/complete') }}" method="post" class="d-inline">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-success btn-sm">
<i class="fa-solid fa-check me-1"></i>Завершить
</button>
</form>
{% else %}
<form action="{{ base_url('/tasks/' ~ task.id ~ '/reopen') }}" method="post" class="d-inline">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-rotate-left me-1"></i>Вернуть в работу
</button>
</form>
{% endif %}
<a href="{{ base_url('/tasks/' ~ task.id ~ '/edit') }}" class="btn btn-outline-primary btn-sm">
<i class="fa-solid fa-pen me-1"></i>Редактировать
</a>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<p class="mb-1"><strong>Статус:</strong></p>
<span class="badge" style="background-color: {{ task.column_color|default('#6B7280') }}">
{{ task.column_name|default('—') }}
</span>
</div>
<div class="col-md-6">
<p class="mb-1"><strong>Приоритет:</strong></p>
<span class="badge {% if task.priority == 'urgent' %}bg-danger{% elseif task.priority == 'high' %}bg-warning text-dark{% elseif task.priority == 'low' %}bg-secondary{% else %}bg-info{% endif %}">
{{ task.priorityLabels[task.priority]|default(task.priority) }}
</span>
</div>
</div>
{% if task.description %}
<div class="mb-3">
<p class="mb-1"><strong>Описание:</strong></p>
<p class="text-muted">{{ task.description|nl2br }}</p>
</div>
{% endif %}
{% if task.assignees %}
<div class="mb-3">
<p class="mb-1"><strong>Исполнители:</strong></p>
<div class="d-flex flex-wrap gap-2">
{% for assignee in task.assignees %}
<span class="badge bg-light text-dark border">
<i class="fa-solid fa-user me-1"></i>
{{ assignee.user_name|default(assignee.user_email) }}
{% if assignee.role == 'watcher' %}
(наблюдатель)
{% endif %}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{# Комментарии #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Комментарии</h5>
</div>
<div class="card-body">
<p class="text-muted text-center">Комментарии будут доступны в следующей версии</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light">
<h6 class="mb-0">Детали</h6>
</div>
<div class="card-body">
<p class="mb-2">
<i class="fa-regular fa-calendar me-2 text-muted"></i>
<strong>Срок:</strong>
{% if task.due_date %}
{{ task.due_date|date('d.m.Y') }}
{% if task.due_date < date('now') and not task.completed_at %}
<span class="text-danger">(просрочено)</span>
{% endif %}
{% else %}
не указан
{% endif %}
</p>
<p class="mb-2">
<i class="fa-regular fa-user me-2 text-muted"></i>
<strong>Автор:</strong> {{ task.created_by_name|default('—') }}
</p>
<p class="mb-2">
<i class="fa-regular fa-clock me-2 text-muted"></i>
<strong>Создано:</strong> {{ task.created_at|date('d.m.Y H:i') }}
</p>
{% if task.completed_at %}
<p class="mb-0 text-success">
<i class="fa-solid fa-check me-2"></i>
<strong>Завершено:</strong> {{ task.completed_at|date('d.m.Y H:i') }}
</p>
{% endif %}
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0">Действия</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if not task.completed_at %}
<form action="{{ base_url('/tasks/' ~ task.id ~ '/complete') }}" method="post">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-success w-100">
<i class="fa-solid fa-check me-2"></i>Отметить как выполненное
</button>
</form>
{% else %}
<form action="{{ base_url('/tasks/' ~ task.id ~ '/reopen') }}" method="post">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="fa-solid fa-rotate-left me-2"></i>Вернуть в работу
</button>
</form>
{% endif %}
<a href="{{ base_url('/tasks/' ~ task.id ~ '/edit') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-pen me-2"></i>Редактировать
</a>
<form action="{{ base_url('/tasks/' ~ task.id ~ '/delete') }}" method="post"
onsubmit="return confirm('Вы уверены, что хотите удалить задачу?')">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-outline-danger w-100">
<i class="fa-solid fa-trash me-2"></i>Удалить задачу
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
// app/Modules/Tasks/Views/tasks/index.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<a href="{{ base_url('/tasks/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Создать задачу
</a>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Всего</h5>
<h2 class="mb-0">{{ stats.total }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Выполнено</h5>
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">В ожидании</h5>
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Просрочено</h5>
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
</div>
</div>
</div>
</div>
{# Переключатель видов #}
<div class="btn-group mb-4" role="group">
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-primary active">
<i class="fa-solid fa-list me-2"></i>Список
</a>
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-table-columns me-2"></i>Канбан
</a>
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-calendar me-2"></i>Календарь
</a>
</div>
{# Таблица задач #}
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
{{ tableHtml|raw }}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ base_url('assets/js/modules/DataTable.js') }}"></script>
{% endblock %}
// app/Modules/Tasks/Views/tasks/calendar.twig
{% extends 'layouts/base.twig' %}
{% block title %}{{ title }} — Бизнес.Точка{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<div class="btn-group">
<a href="{{ base_url('/tasks') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-list me-2"></i>Список
</a>
<a href="{{ base_url('/tasks/kanban') }}" class="btn btn-outline-primary">
<i class="fa-solid fa-table-columns me-2"></i>Канбан
</a>
</div>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Всего</h5>
<h2 class="mb-0">{{ stats.total }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Выполнено</h5>
<h2 class="mb-0 text-success">{{ stats.completed }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">В ожидании</h5>
<h2 class="mb-0 text-primary">{{ stats.pending }}</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<h5 class="card-title text-muted mb-0">Просрочено</h5>
<h2 class="mb-0 text-danger">{{ stats.overdue }}</h2>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div class="btn-group">
<a href="{{ base_url('/tasks/calendar?month=' ~ prevMonth) }}" class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-chevron-left"></i>
</a>
<a href="{{ base_url('/tasks/calendar?month=' ~ nextMonth) }}" class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-chevron-right"></i>
</a>
</div>
<h5 class="mb-0">{{ monthName }}</h5>
<a href="{{ base_url('/tasks/calendar') }}" class="btn btn-outline-secondary btn-sm">
Сегодня
</a>
</div>
<div class="card-body">
<div class="calendar-container">
{# Дни недели #}
<div class="calendar-weekdays mb-2">
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
<div class="calendar-weekday text-center text-muted fw-bold" style="flex: 1;">{{ day }}</div>
{% endfor %}
</div>
{# Календарная сетка #}
<div class="calendar-grid">
{% set firstDay = firstDayOfWeek %}
{% set daysInMonth = daysInMonth %}
{# Пустые ячейки до первого дня #}
{% for i in 0..(firstDay - 1) %}
<div class="calendar-day p-2 border bg-light"></div>
{% 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([]) %}
<div class="calendar-day p-2 border {% if isToday %}bg-primary bg-opacity-10{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="calendar-day-number fw-bold {% if isToday %}text-primary{% endif %}">{{ day }}</span>
{% if dayEvents|length > 0 %}
<span class="badge bg-primary" style="font-size: 0.6rem;">{{ dayEvents|length }}</span>
{% endif %}
</div>
<div class="calendar-events">
{% for event in dayEvents|slice(0, 3) %}
<a href="{{ base_url(event.url) }}"
class="calendar-event d-block text-decoration-none mb-1 px-1 py-1 rounded small"
style="font-size: 0.75rem; background-color: {{ event.column_color }}20; border-left: 3px solid {{ event.column_color }}; color: #333;"
title="{{ event.title }}">
<i class="fa-solid fa-circle me-1" style="font-size: 0.5rem; color: {{ event.column_color }};"></i>
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
{% if event.priority == 'urgent' or event.priority == 'high' %}
<i class="fa-solid fa-flag text-danger ms-1" style="font-size: 0.5rem;"></i>
{% endif %}
</a>
{% endfor %}
{% if dayEvents|length > 3 %}
<div class="text-muted small text-center">
+{{ dayEvents|length - 3 }} ещё
</div>
{% endif %}
</div>
</div>
{% endfor %}
{# Пустые ячейки после последнего дня #}
{% set remaining = 7 - ((firstDay + daysInMonth) % 7) %}
{% if remaining < 7 %}
{% for i in 1..remaining %}
<div class="calendar-day p-2 border bg-light"></div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
<style>
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #dee2e6;
border: 1px solid #dee2e6;
}
.calendar-day {
min-height: 100px;
background-color: white;
}
.calendar-day.bg-light {
background-color: #f8f9fa;
}
.calendar-event:hover {
background-color: {{ event.column_color }}40 !important;
}
</style>
{% endblock %}
{% block scripts %}
<script>
// Навигация по месяцам при клике на заголовок
document.querySelector('h5.mb-0').style.cursor = 'pointer';
document.querySelector('h5.mb-0').addEventListener('click', function() {
window.location.href = '{{ base_url('/tasks/calendar') }}';
});
</script>
{% endblock %}
// app/Modules/Tasks/Services/TaskBoardService.php
<?php
namespace App\Modules\Tasks\Services;
use App\Modules\Tasks\Models\TaskBoardModel;
use App\Modules\Tasks\Models\TaskColumnModel;
class TaskBoardService
{
protected TaskBoardModel $boardModel;
protected TaskColumnModel $columnModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\Tasks\Services;
use App\Modules\Tasks\Models\TaskModel;
use App\Modules\Tasks\Models\TaskAssigneeModel;
use App\Modules\Tasks\Models\TaskColumnModel;
use CodeIgniter\Events\Events;
class TaskService
{
protected TaskModel $taskModel;
protected TaskAssigneeModel $assigneeModel;
protected TaskColumnModel $columnModel;
public function __construct()
{
$this->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
<?php
namespace App\Modules\Tasks\Models;
use CodeIgniter\Model;
class TaskAssigneeModel extends Model
{
protected $table = 'task_assignees';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'task_id',
'user_id',
'role',
'assigned_at',
];
/**
public function getAssigneesByTask(int $taskId): array
{
return $this->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
<?php
namespace App\Modules\Tasks\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class TaskModel extends Model
{
use TenantScopedModel;
protected $table = 'tasks';
protected $primaryKey = 'id';
protected $useSoftDeletes = false;
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $returnType = 'array';
protected $tenantField = 'organization_id';
protected $allowedFields = [
'organization_id',
'board_id',
'column_id',
'title',
'description',
'priority',
'due_date',
'completed_at',
'order_index',
'created_by',
];
/**
public function getForTable(int $organizationId): array
{
return $this->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
<?php
namespace App\Modules\Tasks\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class TaskColumnModel extends Model
{
use TenantScopedModel;
protected $table = 'task_columns';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $tenantField = 'organization_id';
protected $allowedFields = [
'board_id',
'name',
'color',
'order_index',
'is_default',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/**
public function getColumnsByBoard(int $boardId): array
{
return $this->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
<?php
namespace App\Modules\Tasks\Models;
use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class TaskBoardModel extends Model
{
use TenantScopedModel;
protected $table = 'task_boards';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $tenantField = 'organization_id';
protected $allowedFields = [
'organization_id',
'name',
'description',
'is_default',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/**
public function getBoardsByOrganization(int $organizationId): array
{
return $this->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
<?php
$routes->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
<?php
/**
use CodeIgniter\Events\Events;
use App\Modules\Tasks\Services\TaskService;
Events::on('post_system', function () {
/**
Events::on('deal.created', function (array $data) {
$taskService = service('taskService');
$taskService->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
<?php
namespace App\Modules\Tasks\Controllers;
use App\Controllers\BaseController;
use App\Modules\Tasks\Services\TaskService;
use App\Modules\Tasks\Services\TaskBoardService;
use App\Models\OrganizationUserModel;
class TasksController extends BaseController
{
protected TaskService $taskService;
protected TaskBoardService $boardService;
public function __construct()
{
$this->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
<?php
namespace App\Modules\Tasks\Controllers;
use App\Controllers\BaseController;
use App\Modules\Tasks\Models\TaskColumnModel;
class TaskApiController extends BaseController
{
protected TaskColumnModel $columnModel;
public function __construct()
{
$this->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
<?php
namespace App\Controllers;
use App\Models\UserModel;
use App\Models\OrganizationModel;
use App\Models\OrganizationUserModel;
use App\Libraries\EmailLibrary;
use App\Services\RateLimitService;
class Auth extends BaseController
{
protected $emailLibrary;
protected ?RateLimitService $rateLimitService;
public function __construct()
{
$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 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 не подтверждён. Проверьте почту или <a href="/auth/resend-verification">запросите письмо повторно</a>.');
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
<?php
namespace App\Controllers;
use App\Models\OrganizationUserModel;
use App\Services\AccessService;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
abstract class BaseController extends Controller
{
/**
protected $session;
protected AccessService $access;
protected ?OrganizationUserModel $orgUserModel = null;
/**
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
$this->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
<?php
namespace App\Controllers;
use App\Models\OrganizationModel;
use App\Models\OrganizationSubscriptionModel;
use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Services\ModuleSubscriptionService;
/**
class Superadmin extends BaseController
{
protected $organizationModel;
protected $userModel;
protected $subscriptionModel;
protected ?OrganizationUserModel $orgUserModel = null;
protected ModuleSubscriptionService $subscriptionService;
public function __construct()
{
$this->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
<?php
namespace App\Controllers;
use App\Models\UserModel;
use App\Models\OrganizationModel;
use App\Models\OrganizationUserModel;
use App\Services\AccessService;
/**
class Profile extends BaseController
{
protected UserModel $userModel;
protected OrganizationModel $orgModel;
public function __construct()
{
$this->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
<?php
namespace App\Controllers;
use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Models\OrganizationModel;
use App\Services\InvitationService;
use App\Services\AccessService;
class InvitationController extends BaseController
{
protected InvitationService $invitationService;
public function __construct()
{
$this->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
<?php
namespace App\Controllers;
class Landing extends BaseController
{
public function index()
{
if (session()->get('isLoggedIn')) {
return redirect()->to('/organizations');
}
return $this->renderTwig('landing/index');
}
}
// app/Controllers/Organizations.php
<?php
namespace App\Controllers;
use App\Models\OrganizationModel;
use App\Models\UserModel;
use App\Services\AccessService;
class Organizations extends BaseController
{
public function index()
{
$orgModel = new OrganizationModel();
$userId = $this->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
<?php
namespace App\Controllers;
use App\Models\UserModel;
use App\Libraries\EmailLibrary;
use App\Services\RateLimitService;
/**
class ForgotPassword extends BaseController
{
protected UserModel $userModel;
protected EmailLibrary $emailLibrary;
protected ?RateLimitService $rateLimitService;
public function __construct()
{
$this->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
<?php
namespace App\Controllers;
use App\Models\OrganizationModel;
use App\Models\OrganizationUserModel;
class Home extends BaseController
{
public function index()
{
if (!session()->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') %}<button>...</button>{% 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
<?php
use CodeIgniter\Events\Events;
use CodeIgniter\Shield\Exceptions\RuntimeException;
/*
* --------------------------------------------------------------------
* Application Events
* --------------------------------------------------------------------
* Events::on() methods to register listeners for application events.
*/
// Загрузка событий модулей
if (is_file(APPPATH . 'Modules/Tasks/Config/Events.php')) {
require_once APPPATH . 'Modules/Tasks/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/CRM/Config/Events.php')) {
require_once APPPATH . 'Modules/CRM/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/Booking/Config/Events.php')) {
require_once APPPATH . 'Modules/Booking/Config/Events.php';
}
if (is_file(APPPATH . 'Modules/Proof/Config/Events.php')) {
require_once APPPATH . 'Modules/Proof/Config/Events.php';
}
```
Каждый модуль создаёт свой файл `Config/Events.php`:
```php
<?php
// app/Modules/CRM/Config/Events.php
use CodeIgniter\Events\Events;
use App\Services\EventManager;
if (!function_exists('register_crm_events')) {
function register_crm_events(): void
{
$em = service('eventManager');
// Интеграция CRM → Tasks
$em->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') %}
<p>Вы владелец организации</p>
{% endif %}
{% if access.isRole(['owner', 'admin']) %}
<p>Вы администратор или владелец</p>
{% endif %}
{# Удобные проверки #}
{% if access.isOwner() %}
Кнопка "Удалить организацию"
{% endif %}
{% if access.isAdmin() %}
Кнопка "Управление пользователями"
{% endif %}
```
### Проверка действий
```twig
{# Кнопка создания (видима только если есть право create) #}
{% if access.canCreate('clients') %}
<a href="{{ url('/clients/new') }}" class="btn btn-primary">
Добавить клиента
</a>
{% endif %}
{# Кнопка редактирования #}
{% if access.canEdit('clients') %}
<a href="{{ url('/clients/edit/' ~ client.id) }}">Редактировать</a>
{% endif %}
{# Кнопка удаления #}
{% if access.canDelete('clients') %}
<a href="{{ url('/clients/delete/' ~ client.id) }}">Удалить</a>
{% endif %}
```
### Проверка специальных прав
```twig
{# Управление пользователями #}
{% if access.canManageUsers() %}
<a href="{{ url('/organizations/' ~ currentOrg.id ~ '/users') }}">
Управление пользователями
</a>
{% endif %}
{# Управление модулями #}
{% if access.canManageModules() %}
<a href="{{ url('/modules') }}">Управление подписками</a>
{% endif %}
{# Удаление организации (только владелец) #}
{% if access.canDeleteOrganization() %}
<button class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteOrgModal">
Удалить организацию
</button>
{% 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 %}
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<h1 class="page-title">Клиенты</h1>
{% if can_create %}
<a href="{{ url('/clients/new') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить клиента
</a>
{% endif %}
</div>
<div class="card">
<div class="card-body p-0">
{{ tableHtml|raw }}
</div>
</div>
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="{{ url('/assets/js/modules/DataTable.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
new DataTable('clients-table', {
url: '/clients/table',
perPage: 10,
debounceTime: 300,
preserveSearchOnSort: true
});
});
</script>
{% endblock %}
```
### Прямое использование компонента
При необходимости таблицу можно подключить напрямую через `include`, передав все параметры вручную:
```twig
{% from '@components/table/macros.twig' import render_actions %}
<div class="table-responsive">
{{ 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',
}) }}
</div>
```
### Поддержка render_cell и render_actions
В шаблонах Twig доступны глобальные функции для рендеринга ячеек и действий. Функция `render_cell()` автоматически обрабатывает значение ячейки в зависимости от типа данных:
```twig
{# Ячейка с автоматическим форматированием #}
<td>{{ render_cell(item, 'name')|raw }}</td>
<td>{{ render_cell(item, 'email')|raw }}</td>
<td>{{ render_cell(item, 'price')|raw }}</td>
<td>{{ render_cell(item, 'created_at')|raw }}</td>
{# Ячейка с кастомным классом #}
{{ 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
{# В шаблоне ячейки с форматированием #}
<td>
<strong>{{ item.title }}</strong>
{% if item.description %}
<br><small class="text-muted">{{ item.description|slice(0, 50) }}...</small>
{% endif %}
</td>
<td class="text-end">
{{ item.amount|number_format(0, ',', ' ') }} ₽
</td>
<td>
{% if item.status == 'active' %}
<span class="badge bg-success">Активна</span>
{% elseif item.status == 'won' %}
<span class="badge bg-primary">Выиграна</span>
{% elseif item.status == 'lost' %}
<span class="badge bg-danger">Проиграна</span>
{% endif %}
</td>
```
---
## Проверка при создании модуля
### Чек-лист при добавлении новой таблицы
При создании нового модуля с таблицей необходимо выполнить следующие действия:
**В контроллере:**
- Определить метод `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.
<IfModule mod_rewrite.c>
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}]
</IfModule>
<IfModule !mod_rewrite.c>
# 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
</IfModule>
# Disable server signature start
ServerSignature Off
# Disable server signature end
// public/index.php
<?php
use CodeIgniter\Boot;
use Config\Paths;
/*
$minPhpVersion = '8.1';
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
$message = sprintf(
'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
$minPhpVersion,
PHP_VERSION,
);
header('HTTP/1.1 503 Service Unavailable.', true, 503);
echo $message;
exit(1);
}
/*
define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR);
if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) {
chdir(FCPATH);
}
/*
require FCPATH . '../app/Config/Paths.php';
$paths = new Paths();
require $paths->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