Many fixes

This commit is contained in:
root 2026-01-16 21:58:12 +03:00
parent b810a17649
commit 77f76c8c28
45 changed files with 3392 additions and 1243 deletions

View File

@ -0,0 +1,151 @@
<?php
namespace App\Config;
use CodeIgniter\Config\BaseConfig;
/**
* Конфигурация бизнес-модулей системы
*
* Определяет доступные модули, их параметры и цены.
*/
class BusinessModules extends BaseConfig
{
/**
* Список доступных модулей
*
* @var array<string, array{
* name: string,
* description: string,
* price_monthly: int,
* price_yearly: int,
* trial_days: int,
* features: string[]
* }>
*/
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' => [
'Согласование документов',
'Комментарии и версии',
'Утверждение',
],
],
];
/**
* Проверка существования модуля
*
* @param string $moduleCode
* @return bool
*/
public function exists(string $moduleCode): bool
{
return isset($this->modules[$moduleCode]);
}
/**
* Получение информации о модуле (без переопределений)
*
* @param string $moduleCode
* @return array|null
*/
public function getModule(string $moduleCode): ?array
{
return $this->modules[$moduleCode] ?? null;
}
/**
* Получение цены модуля
*
* @param string $moduleCode
* @param string $period 'monthly' или 'yearly'
* @return int
*/
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'];
}
/**
* Получение списка всех кодов модулей
*
* @return array
*/
public function getAllModuleCodes(): array
{
return array_keys($this->modules);
}
/**
* Получение списка платных модулей (с возможностью триала)
*
* @return array<string, array>
*/
public function getPaidModules(): array
{
return array_filter($this->modules, function ($module) {
return $module['trial_days'] > 0;
});
}
}

View File

@ -37,6 +37,7 @@ class Filters extends BaseFilters
'org' => \App\Filters\OrganizationFilter::class, 'org' => \App\Filters\OrganizationFilter::class,
'role' => \App\Filters\RoleFilter::class, 'role' => \App\Filters\RoleFilter::class,
'auth' => \App\Filters\AuthFilter::class, 'auth' => \App\Filters\AuthFilter::class,
'subscription' => \App\Filters\ModuleSubscriptionFilter::class,
]; ];
/** /**

View File

@ -88,26 +88,38 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
require_once APPPATH . 'Modules/Clients/Config/Routes.php'; require_once APPPATH . 'Modules/Clients/Config/Routes.php';
require_once APPPATH . 'Modules/CRM/Config/Routes.php'; require_once APPPATH . 'Modules/CRM/Config/Routes.php';
}); });
# ============================================================================= # =============================================================================
# СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin) # СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin)
# ============================================================================= # =============================================================================
$routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) { $routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) {
$routes->get('/', 'Superadmin::index'); $routes->get('/', 'Superadmin::index');
$routes->get('plans', 'Superadmin::plans');
$routes->get('plans/create', 'Superadmin::createPlan');
$routes->post('plans/store', 'Superadmin::storePlan');
$routes->get('plans/edit/(:num)', 'Superadmin::editPlan/$1');
$routes->post('plans/update/(:num)', 'Superadmin::updatePlan/$1');
$routes->get('plans/delete/(:num)', 'Superadmin::deletePlan/$1');
# Управление модулями
$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', 'Superadmin::organizations');
$routes->get('organizations/table', 'Superadmin::organizationsTable'); $routes->get('organizations/table', 'Superadmin::organizationsTable');
$routes->get('organizations/view/(:num)', 'Superadmin::viewOrganization/$1'); $routes->get('organizations/view/(:num)', 'Superadmin::viewOrganization/$1');
$routes->post('organizations/set-plan/(:num)', 'Superadmin::setOrganizationPlan/$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/block/(:num)', 'Superadmin::blockOrganization/$1');
$routes->get('organizations/unblock/(:num)', 'Superadmin::unblockOrganization/$1'); $routes->get('organizations/unblock/(:num)', 'Superadmin::unblockOrganization/$1');
$routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1'); $routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1');
# Управление пользователями
$routes->get('users', 'Superadmin::users'); $routes->get('users', 'Superadmin::users');
$routes->get('users/table', 'Superadmin::usersTable'); $routes->get('users/table', 'Superadmin::usersTable');
$routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1'); $routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1');

View File

@ -103,7 +103,7 @@ class Services extends BaseService
return static::getSharedInstance('moduleSubscription'); return static::getSharedInstance('moduleSubscription');
} }
return new ModuleSubscriptionService(); return new \App\Services\ModuleSubscriptionService();
} }
/** /**

View File

@ -2,22 +2,31 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\OrganizationModel;
use App\Models\OrganizationSubscriptionModel;
use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Services\ModuleSubscriptionService;
/** /**
* Superadmin - Панель суперадмина * Superadmin - Панель суперадмина
* *
* Управление системой: тарифы, организации, пользователи. * Управление системой: модули, подписки организаций, пользователи.
*/ */
class Superadmin extends BaseController class Superadmin extends BaseController
{ {
protected $organizationModel; protected $organizationModel;
protected $userModel; protected $userModel;
protected $planModel; protected $subscriptionModel;
protected ?OrganizationUserModel $orgUserModel = null;
protected ModuleSubscriptionService $subscriptionService;
public function __construct() public function __construct()
{ {
$this->organizationModel = new \App\Models\OrganizationModel(); $this->organizationModel = new OrganizationModel();
$this->userModel = new \App\Models\UserModel(); $this->userModel = new UserModel();
$this->planModel = new \App\Models\PlanModel(); $this->subscriptionModel = new OrganizationSubscriptionModel();
$this->subscriptionService = service('moduleSubscription');
} }
/** /**
@ -25,20 +34,17 @@ class Superadmin extends BaseController
*/ */
public function index() public function index()
{ {
// Статистика
$stats = [ $stats = [
'total_users' => $this->userModel->countAll(), 'total_users' => $this->userModel->countAll(),
'total_orgs' => $this->organizationModel->countAll(), 'total_orgs' => $this->organizationModel->countAll(),
'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(), 'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(),
'total_plans' => $this->planModel->countAll(), 'total_modules' => count($this->subscriptionService->getAllModules()),
]; ];
// Последние организации
$recentOrgs = $this->organizationModel $recentOrgs = $this->organizationModel
->orderBy('created_at', 'DESC') ->orderBy('created_at', 'DESC')
->findAll(5); ->findAll(5);
// Последние пользователи
$recentUsers = $this->userModel $recentUsers = $this->userModel
->orderBy('created_at', 'DESC') ->orderBy('created_at', 'DESC')
->findAll(5); ->findAll(5);
@ -47,135 +53,204 @@ class Superadmin extends BaseController
} }
// ========================================================================= // =========================================================================
// УПРАВЛЕНИЕ ТАРИФАМИ // УПРАВЛЕНИЕ МОДУЛЯМИ
// ========================================================================= // =========================================================================
/** /**
* Список тарифов * Список модулей с ценами
*/ */
public function plans() public function modules()
{ {
$plans = $this->planModel->findAll(); $modules = $this->subscriptionService->getAllModules();
// Декодируем features для Twig
foreach ($plans as &$plan) {
$plan['features'] = json_decode($plan['features'] ?? '[]', true);
}
return $this->renderTwig('superadmin/plans/index', compact('plans')); return $this->renderTwig('superadmin/modules/index', compact('modules'));
} }
/** /**
* Создание тарифа (форма) * Обновление параметров модуля
*/ */
public function createPlan() public function updateModule()
{ {
return $this->renderTwig('superadmin/plans/create'); $moduleCode = $this->request->getPost('module_code');
} $config = $this->subscriptionService->getModuleConfig($moduleCode);
/** if (!$moduleCode || !$config) {
* Сохранение тарифа return redirect()->back()->with('error', 'Модуль не найден');
*/
public function storePlan()
{
// Получаем features из текстового поля (каждая строка - отдельная возможность)
$featuresText = $this->request->getPost('features_list');
$features = [];
if ($featuresText) {
$lines = explode("\n", trim($featuresText));
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line)) {
$features[] = $line;
}
}
} }
$data = [ $this->subscriptionService->saveModuleSettings(
'name' => $this->request->getPost('name'), $moduleCode,
'description' => $this->request->getPost('description'), $this->request->getPost('name'),
'price' => (float) $this->request->getPost('price'), $this->request->getPost('description'),
'currency' => $this->request->getPost('currency') ?? 'RUB', (int) $this->request->getPost('price_monthly'),
'billing_period' => $this->request->getPost('billing_period') ?? 'monthly', (int) $this->request->getPost('price_yearly'),
'max_users' => (int) $this->request->getPost('max_users'), (int) $this->request->getPost('trial_days')
'max_clients' => (int) $this->request->getPost('max_clients'), );
'max_storage' => (int) $this->request->getPost('max_storage'),
'features' => json_encode($features), return redirect()->to('/superadmin/modules')->with('success', 'Модуль успешно обновлён');
'is_active' => $this->request->getPost('is_active') ?? 1, }
'is_default' => $this->request->getPost('is_default') ?? 0,
// =========================================================================
// УПРАВЛЕНИЕ ПОДПИСКАМИ
// =========================================================================
/**
* Конфигурация таблицы подписок
*/
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',
]; ];
if (!$this->planModel->insert($data)) {
return redirect()->back()->withInput()->with('error', 'Ошибка создания тарифа: ' . implode(', ', $this->planModel->errors()));
}
return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно создан');
} }
/** /**
* Редактирование тарифа (форма) * Список подписок
*/ */
public function editPlan($id) public function subscriptions()
{ {
$plan = $this->planModel->find($id); $config = $this->getSubscriptionsTableConfig();
if (!$plan) { $tableHtml = $this->renderTable($config);
throw new \CodeIgniter\Exceptions\PageNotFoundException('Тариф не найден'); $modules = $this->subscriptionService->getAllModules();
} $organizations = $this->organizationModel->findAll();
// Декодируем features для отображения в textarea return $this->renderTwig('superadmin/subscriptions/index', [
$plan['features'] = json_decode($plan['features'] ?? '[]', true); 'tableHtml' => $tableHtml,
'config' => $config,
return $this->renderTwig('superadmin/plans/edit', compact('plan')); 'modules' => $modules,
'organizations' => $organizations,
]);
} }
/** /**
* Обновление тарифа * AJAX таблица подписок
*/ */
public function updatePlan($id) public function subscriptionsTable()
{ {
// Получаем features из текстового поля (каждая строка - отдельная возможность) return parent::table($this->getSubscriptionsTableConfig(), '/superadmin/subscriptions');
$featuresText = $this->request->getPost('features_list');
$features = [];
if ($featuresText) {
$lines = explode("\n", trim($featuresText));
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line)) {
$features[] = $line;
}
}
}
$data = [
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
'price' => (float) $this->request->getPost('price'),
'currency' => $this->request->getPost('currency') ?? 'RUB',
'billing_period' => $this->request->getPost('billing_period') ?? 'monthly',
'max_users' => (int) $this->request->getPost('max_users'),
'max_clients' => (int) $this->request->getPost('max_clients'),
'max_storage' => (int) $this->request->getPost('max_storage'),
'features' => json_encode($features),
'is_active' => $this->request->getPost('is_active') ?? 1,
'is_default' => $this->request->getPost('is_default') ?? 0,
];
if (!$this->planModel->update($id, $data)) {
return redirect()->back()->withInput()->with('error', 'Ошибка обновления тарифа: ' . implode(', ', $this->planModel->errors()));
}
return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно обновлён');
} }
/** /**
* Удаление тарифа * Поиск организаций для autocomplete
*/ */
public function deletePlan($id) public function searchOrganizations()
{ {
if (!$this->planModel->delete($id)) { $query = $this->request->getGet('q') ?? '';
return redirect()->to('/superadmin/plans')->with('error', 'Ошибка удаления тарифа'); $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 redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно удалён'); 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', 'Подписка удалена');
} }
// ========================================================================= // =========================================================================
@ -194,19 +269,23 @@ class Superadmin extends BaseController
'columns' => [ 'columns' => [
'id' => ['label' => 'ID', 'width' => '60px'], 'id' => ['label' => 'ID', 'width' => '60px'],
'name' => ['label' => 'Название'], 'name' => ['label' => 'Название'],
'owner_login' => ['label' => 'Владелец', 'width' => '150px'],
'type' => ['label' => 'Тип', 'width' => '100px'], 'type' => ['label' => 'Тип', 'width' => '100px'],
'user_count' => ['label' => 'Пользователей', 'width' => '100px'], 'user_count' => ['label' => 'Пользователей', 'width' => '100px'],
'status' => ['label' => 'Статус', 'width' => '120px'], 'status' => ['label' => 'Статус', 'width' => '120px'],
'created_at' => ['label' => 'Дата', 'width' => '100px'], 'created_at' => ['label' => 'Дата', 'width' => '100px'],
], ],
'searchable' => ['name', 'id'], 'searchable' => ['name', 'id', 'owner_login'],
'sortable' => ['id', 'name', 'created_at'], 'sortable' => ['id', 'name', 'created_at'],
'defaultSort' => 'created_at', 'defaultSort' => 'created_at',
'order' => 'desc', 'order' => 'desc',
'scope' => function ($builder) { 'scope' => function ($builder) {
// JOIN с подсчётом пользователей организации $builder->resetQuery();
$builder->from('organizations') $builder->select('organizations.*,
->select('organizations.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count'); (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'], 'actions' => ['label' => 'Действия', 'width' => '140px'],
'actionsConfig' => [ 'actionsConfig' => [
@ -263,7 +342,7 @@ class Superadmin extends BaseController
} }
/** /**
* Просмотр организации * Просмотр организации с её подписками
*/ */
public function viewOrganization($id) public function viewOrganization($id)
{ {
@ -272,23 +351,49 @@ class Superadmin extends BaseController
throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена'); throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена');
} }
// Пользователи организации $users = $this->getOrgUserModel()->getOrganizationUsers($id);
$orgUserModel = new \App\Models\OrganizationUserModel(); $subscriptions = $this->subscriptionService->getOrganizationSubscriptions($id);
$users = $orgUserModel->getOrganizationUsers($id); $allModules = $this->subscriptionService->getAllModules();
// Список тарифов для выбора return $this->renderTwig('superadmin/organizations/view', compact(
$plans = $this->planModel->where('is_active', 1)->findAll(); 'organization',
'users',
'subscriptions',
'allModules'
));
}
// Текущая подписка организации из таблицы связей /**
$db = \Config\Database::connect(); * Быстрое добавление подписки организации из просмотра организации
$subscriptionTable = $db->table('organization_plan_subscriptions'); */
$currentSubscription = $subscriptionTable public function addOrganizationSubscription($organizationId)
->where('organization_id', $id) {
->orderBy('id', 'DESC') $moduleCode = $this->request->getPost('module_code');
->get() $durationDays = (int) $this->request->getPost('duration_days');
->getRowArray(); $status = $this->request->getPost('status') ?? 'active';
return $this->renderTwig('superadmin/organizations/view', compact('organization', 'users', 'plans', 'currentSubscription')); 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', 'Подписка удалена');
} }
/** /**
@ -312,72 +417,15 @@ class Superadmin extends BaseController
} }
/** /**
* Удаление организации (полное удаление) * Удаление организации
*/ */
public function deleteOrganization($id) public function deleteOrganization($id)
{ {
// Полное удаление без soft delete
$this->organizationModel->delete($id, true); $this->organizationModel->delete($id, true);
return redirect()->to('/superadmin/organizations')->with('success', 'Организация удалена'); return redirect()->to('/superadmin/organizations')->with('success', 'Организация удалена');
} }
/**
* Назначение тарифа организации
*/
public function setOrganizationPlan($id)
{
$planId = $this->request->getPost('plan_id');
$durationDays = (int) $this->request->getPost('duration_days');
if (!$planId) {
return redirect()->back()->with('error', 'Выберите тариф');
}
$plan = $this->planModel->find($planId);
if (!$plan) {
return redirect()->back()->with('error', 'Тариф не найден');
}
$db = \Config\Database::connect();
$subscriptionsTable = $db->table('organization_plan_subscriptions');
$startDate = date('Y-m-d H:i:s');
$endDate = $durationDays > 0
? date('Y-m-d H:i:s', strtotime("+{$durationDays} days"))
: null;
// Проверяем существующую подписку
$existingSub = $subscriptionsTable
->where('organization_id', $id)
->where('plan_id', $planId)
->get()
->getRowArray();
if ($existingSub) {
// Обновляем существующую подписку
$subscriptionsTable->where('id', $existingSub['id'])->update([
'status' => $durationDays > 0 ? 'active' : 'trial',
'trial_ends_at' => $endDate,
'expires_at' => $endDate,
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
// Создаём новую подписку
$subscriptionsTable->insert([
'organization_id' => $id,
'plan_id' => $planId,
'status' => $durationDays > 0 ? 'active' : 'trial',
'trial_ends_at' => $endDate,
'expires_at' => $endDate,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Тариф успешно назначен');
}
// ========================================================================= // =========================================================================
// УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ // УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ
// ========================================================================= // =========================================================================
@ -405,7 +453,6 @@ class Superadmin extends BaseController
'defaultSort' => 'created_at', 'defaultSort' => 'created_at',
'order' => 'desc', 'order' => 'desc',
'scope' => function ($builder) { 'scope' => function ($builder) {
// JOIN с подсчётом организаций пользователя
$builder->from('users') $builder->from('users')
->select('users.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.user_id = users.id) as org_count'); ->select('users.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.user_id = users.id) as org_count');
}, },
@ -494,11 +541,10 @@ class Superadmin extends BaseController
} }
/** /**
* Удаление пользователя (полное удаление) * Удаление пользователя
*/ */
public function deleteUser($id) public function deleteUser($id)
{ {
// Полное удаление без soft delete
$this->userModel->delete($id, true); $this->userModel->delete($id, true);
return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён'); return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён');
@ -513,7 +559,6 @@ class Superadmin extends BaseController
*/ */
public function statistics() public function statistics()
{ {
// Статистика по дням (последние 30 дней)
$dailyStats = []; $dailyStats = [];
for ($i = 29; $i >= 0; $i--) { for ($i = 29; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-{$i} days")); $date = date('Y-m-d', strtotime("-{$i} days"));
@ -524,35 +569,8 @@ class Superadmin extends BaseController
]; ];
} }
// Статистика по тарифам (через таблицу подписок) $moduleStats = $this->subscriptionService->getModuleStats();
$planStats = [];
$plans = $this->planModel->where('is_active', 1)->findAll();
// Проверяем существование таблицы подписок return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'moduleStats'));
$db = \Config\Database::connect();
$tableExists = $db->tableExists('organization_plan_subscriptions');
if ($tableExists) {
$subscriptionsTable = $db->table('organization_plan_subscriptions');
foreach ($plans as $plan) {
$count = $subscriptionsTable->where('plan_id', $plan['id'])
->where('status', 'active')
->countAllResults();
$planStats[$plan['id']] = [
'name' => $plan['name'],
'orgs_count' => $count,
];
}
} else {
// Таблица подписок ещё не создана - показываем 0 для всех тарифов
foreach ($plans as $plan) {
$planStats[$plan['id']] = [
'name' => $plan['name'],
'orgs_count' => 0,
];
}
}
return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'planStats'));
} }
} }

View File

@ -5,12 +5,19 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration; use CodeIgniter\Database\Migration;
/** /**
* Migration для создания таблицы подписок организаций на тарифы * Миграция для удаления таблицы подписок на тарифы
* Использует ту же структуру что и organization_subscriptions для модулей *
* Плановая система тарифов заменена на модульную систему подписок.
* Таблица organization_plan_subscriptions больше не используется.
*/ */
class CreateOrganizationPlanSubscriptionsTable extends Migration class DropOrganizationPlanSubscriptionsTable extends Migration
{ {
public function up() public function up()
{
$this->forge->dropTable('organization_plan_subscriptions', true);
}
public function down()
{ {
$this->forge->addField([ $this->forge->addField([
'id' => [ 'id' => [
@ -53,18 +60,10 @@ class CreateOrganizationPlanSubscriptionsTable extends Migration
]); ]);
$this->forge->addKey('id', true); $this->forge->addKey('id', true);
// Организация не может иметь две активные подписки на один тариф одновременно
$this->forge->addUniqueKey(['organization_id', 'plan_id']); $this->forge->addUniqueKey(['organization_id', 'plan_id']);
$this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE'); $this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('organization_plan_subscriptions'); $this->forge->createTable('organization_plan_subscriptions');
} }
public function down()
{
$this->forge->dropTable('organization_plan_subscriptions');
}
} }

View File

@ -0,0 +1,75 @@
<?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);
}
}

View File

@ -0,0 +1,23 @@
<?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');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use App\Services\ModuleSubscriptionService;
/**
* Фильтр проверки подписки на модуль
*
* Проверяет, есть ли у организации активная подписка на указанный модуль.
* Если подписки нет - перенаправляет на dashboard с сообщением об ошибке.
*/
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)
{
// Ничего не делаем после запроса
}
}

View File

@ -47,6 +47,10 @@ class TwigGlobalsExtension extends AbstractExtension
new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]), new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]),
new TwigFunction('is_system_admin', [$this, 'isSystemAdmin'], ['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('get_system_role', [$this, 'getSystemRole'], ['is_safe' => ['html']]),
// Module subscription functions
new TwigFunction('is_module_active', [$this, 'isModuleActive'], ['is_safe' => ['html']]),
new TwigFunction('is_module_available', [$this, 'isModuleAvailable'], ['is_safe' => ['html']]),
]; ];
} }
@ -161,6 +165,30 @@ class TwigGlobalsExtension extends AbstractExtension
return service('access')->getSystemRole(); return service('access')->getSystemRole();
} }
// ========================================
// Module Subscription Functions
// ========================================
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 public function statusBadge(string $status): string
{ {

View File

@ -0,0 +1,74 @@
<?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;
/**
* Получение настроек модуля по коду
*
* @param string $moduleCode
* @return array|null
*/
public function getByModuleCode(string $moduleCode): ?array
{
return $this->where('module_code', $moduleCode)->first();
}
/**
* Обновление или создание настроек модуля
*
* @param string $moduleCode
* @param array $data
* @return bool
*/
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);
}
/**
* Получение всех активных настроек модулей
*
* @return array
*/
public function getAllActive(): array
{
return $this->where('is_active', 1)->findAll();
}
}

View File

@ -0,0 +1,203 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* Модель подписок организаций на модули
*
* @property int $id
* @property int $organization_id
* @property string $module_code
* @property string $status
* @property string|null $expires_at
* @property string|null $created_at
*/
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;
/**
* Проверка активности модуля для организации
*
* @param int $organizationId
* @param string $moduleCode
* @return bool
*/
public function isModuleActive(int $organizationId, string $moduleCode): bool
{
$subscription = $this->getSubscription($organizationId, $moduleCode);
if (!$subscription) {
return false;
}
return $subscription['status'] === 'active';
}
/**
* Получение подписки организации на модуль
*
* @param int $organizationId
* @param string $moduleCode
* @return array|null
*/
public function getSubscription(int $organizationId, string $moduleCode): ?array
{
return $this->where('organization_id', $organizationId)
->where('module_code', $moduleCode)
->first();
}
/**
* Проверка что организация в триальном периоде для модуля
*
* @param int $organizationId
* @param string $moduleCode
* @return bool
*/
public function isInTrial(int $organizationId, string $moduleCode): bool
{
$subscription = $this->getSubscription($organizationId, $moduleCode);
if (!$subscription) {
return false;
}
return $subscription['status'] === 'trial';
}
/**
* Получение количества дней до окончания подписки/триала
*
* @param int $organizationId
* @param string $moduleCode
* @return int|null
*/
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;
}
/**
* Получение всех активных модулей для организации
*
* @param int $organizationId
* @return array
*/
public function getActiveModules(int $organizationId): array
{
$subscriptions = $this->where('organization_id', $organizationId)
->whereIn('status', ['active', 'trial'])
->findAll();
return array_column($subscriptions, 'module_code');
}
/**
* Запуск триального периода для модуля
*
* @param int $organizationId
* @param string $moduleCode
* @param int $trialDays
* @return bool
*/
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);
}
/**
* Активация подписки на модуль
*
* @param int $organizationId
* @param string $moduleCode
* @param int $months
* @return bool
*/
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);
}
/**
* Отмена подписки на модуль
*
* @param int $organizationId
* @param string $moduleCode
* @return bool
*/
public function cancel(int $organizationId, string $moduleCode): bool
{
$existing = $this->getSubscription($organizationId, $moduleCode);
if (!$existing) {
return false;
}
return $this->update($existing['id'], ['status' => 'cancelled']);
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* PlanModel - Модель тарифов
*/
class PlanModel extends Model
{
protected $table = 'plans';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'name',
'description',
'price',
'currency',
'billing_period',
'max_users',
'max_clients',
'max_storage',
'features',
'is_active',
'is_default',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'name' => 'required|min_length[2]|max_length[100]',
'price' => 'required|numeric|greater_than_equal[0]',
'max_users' => 'required|integer|greater_than[0]',
'max_clients' => 'required|integer|greater_than[0]',
'max_storage' => 'required|integer|greater_than[0]',
];
protected $validationMessages = [
'name' => [
'required' => 'Название тарифа обязательно',
'min_length' => 'Название должно быть минимум 2 символа',
],
'price' => [
'required' => 'Цена обязательна',
'numeric' => 'Цена должна быть числом',
],
];
/**
* Получение активных тарифов
*/
public function getActivePlans()
{
return $this->where('is_active', 1)->findAll();
}
/**
* Получение тарифа по умолчанию
*/
public function getDefaultPlan()
{
return $this->where('is_default', 1)->where('is_active', 1)->first();
}
/**
* Проверка, является ли тариф системным
*/
public function isSystemPlan($planId)
{
$plan = $this->find($planId);
return $plan !== null;
}
}

View File

@ -2,18 +2,25 @@
// CRM Module Routes // CRM Module Routes
$routes->group('crm', ['filter' => 'org', 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) { $routes->group('crm', ['filter' => ['org', 'subscription:crm'], 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) {
// Dashboard // Dashboard
$routes->get('/', 'DashboardController::index'); $routes->get('/', 'DashboardController::index');
// Contacts // Contacts
$routes->get('contacts', 'ContactsController::index'); $routes->get('contacts', 'ContactsController::index');
$routes->get('contacts/table', 'ContactsController::contactsTable');
$routes->get('contacts/create', 'ContactsController::create'); $routes->get('contacts/create', 'ContactsController::create');
$routes->post('contacts', 'ContactsController::store'); $routes->post('contacts', 'ContactsController::store');
$routes->get('contacts/(:num)/edit', 'ContactsController::edit/$1'); $routes->get('contacts/(:num)/edit', 'ContactsController::edit/$1');
$routes->post('contacts/(:num)', 'ContactsController::update/$1'); $routes->post('contacts/(:num)', 'ContactsController::update/$1');
$routes->get('contacts/(:num)/delete', 'ContactsController::destroy/$1'); $routes->get('contacts/(:num)/delete', 'ContactsController::destroy/$1');
// Contacts AJAX API (for inline editing in Clients module)
$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');
// Deals // Deals
$routes->group('deals', static function ($routes) { $routes->group('deals', static function ($routes) {

View File

@ -17,24 +17,84 @@ class ContactsController extends BaseController
$this->clientModel = new ClientModel(); $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() public function index()
{ {
$organizationId = $this->requireActiveOrg(); $config = $this->getContactsTableConfig();
$tableHtml = $this->renderTable($config);
$contacts = $this->contactModel
->where('organization_id', $organizationId)
->orderBy('created_at', 'DESC')
->findAll();
return $this->renderTwig('@CRM/contacts/index', [ return $this->renderTwig('@CRM/contacts/index', [
'title' => 'Контакты', 'title' => 'Контакты',
'contacts' => $contacts, 'tableHtml' => $tableHtml,
'config' => $config,
]); ]);
} }
/**
* AJAX таблица контактов
*/
public function contactsTable()
{
return parent::table($this->getContactsTableConfig(), '/crm/contacts');
}
/** /**
* Форма создания контакта * Форма создания контакта
*/ */
@ -151,4 +211,190 @@ class ContactsController extends BaseController
return redirect()->to('/crm/contacts')->with('success', 'Контакт удалён'); return redirect()->to('/crm/contacts')->with('success', 'Контакт удалён');
} }
// =========================================================================
// AJAX API для inline-редактирования в модуле Clients
// =========================================================================
/**
* Получить список контактов клиента (AJAX)
* GET /crm/contacts/list/{clientId}
*/
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->setJSON([
'success' => true,
'items' => $items,
'total' => count($items),
]);
}
/**
* Создать контакт (AJAX)
* POST /crm/contacts/store
*/
public function ajaxStore()
{
$organizationId = $this->requireActiveOrg();
$customerId = $this->request->getPost('customer_id');
// Проверяем клиента если указан
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' => $this->request->getPost('name'),
'email' => $this->request->getPost('email') ?: null,
'phone' => $this->request->getPost('phone') ?: null,
'position' => $this->request->getPost('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->setJSON([
'success' => true,
'message' => 'Контакт создан',
'item' => [
'id' => $contactId,
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'],
'position' => $data['position'],
],
]);
}
/**
* Обновить контакт (AJAX)
* POST /crm/contacts/update/{id}
*/
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);
}
$data = [
'name' => $this->request->getPost('name'),
'email' => $this->request->getPost('email') ?: null,
'phone' => $this->request->getPost('phone') ?: null,
'position' => $this->request->getPost('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->setJSON([
'success' => true,
'message' => 'Контакт обновлён',
'item' => [
'id' => $id,
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'],
'position' => $data['position'],
],
]);
}
/**
* Удалить контакт (AJAX)
* POST /crm/contacts/delete/{id}
*/
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->setJSON([
'success' => true,
'message' => 'Контакт удалён',
]);
}
} }

View File

@ -74,7 +74,7 @@ class DealsController extends BaseController
'amount' => [ 'amount' => [
'label' => 'Сумма', 'label' => 'Сумма',
'width' => '15%', 'width' => '15%',
'align' => 'text-end',
], ],
'client_name' => [ 'client_name' => [
'label' => 'Клиент', 'label' => 'Клиент',
@ -401,7 +401,14 @@ class DealsController extends BaseController
$result = $this->dealService->changeStage($dealId, $newStageId, $userId); $result = $this->dealService->changeStage($dealId, $newStageId, $userId);
return $this->response->setJSON(['success' => $result]); // Получаем новый CSRF токен для клиента
$csrfToken = csrf_hash();
$csrfHash = csrf_token();
return $this->response
->setHeader('X-CSRF-TOKEN', $csrfToken)
->setHeader('X-CSRF-HASH', $csrfHash)
->setJSON(['success' => $result]);
} }
/** /**

View File

@ -29,10 +29,6 @@
<form method="POST" action="{{ actionUrl }}"> <form method="POST" action="{{ actionUrl }}">
{{ csrf_field()|raw }} {{ csrf_field()|raw }}
{% if contact is defined %}
<input type="hidden" name="_method" value="PUT">
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label fw-bold">Имя *</label> <label for="name" class="form-label fw-bold">Имя *</label>
<input type="text" <input type="text"

View File

@ -2,10 +2,14 @@
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{% block styles %}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">{{ title }}</h1> <h1 class="h3 mb-0"><i class="fa-solid fa-users text-info me-2"></i> {{ title }}</h1>
</div> </div>
<a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary"> <a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary">
<i class="fa-solid fa-plus me-2"></i>Добавить контакт <i class="fa-solid fa-plus me-2"></i>Добавить контакт
@ -13,77 +17,50 @@
</div> </div>
{# Сообщения #} {# Сообщения #}
{% if success is defined %} {% if session.success %}
<div class="alert alert-success alert-dismissible fade show" role="alert"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }} {{ 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> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% endif %} {% endif %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> {{ tableHtml|raw }}
<table class="table table-hover mb-0"> {# CSRF токен для AJAX запросов #}
<thead class="bg-light"> {{ csrf_field()|raw }}
<tr>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Должность</th>
<th>Клиент</th>
<th class="text-end" style="width: 120px;">Действия</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
style="width: 32px; height: 32px; min-width: 32px;">
<span class="text-white small">{{ contact.name|slice(0, 2)|upper }}</span>
</div>
<span class="fw-medium">{{ contact.name }}</span>
{% if contact.is_primary %}
<span class="badge bg-success" style="font-size: 0.7rem;">Основной</span>
{% endif %}
</div>
</td>
<td>{{ contact.email ?: '—' }}</td>
<td>{{ contact.phone ?: '—' }}</td>
<td>{{ contact.position ?: '—' }}</td>
<td>
{% if contact.customer_id %}
<span class="text-muted">{{ contact.customer_id }}</span>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ site_url('/crm/contacts/' ~ contact.id ~ '/edit') }}"
class="btn btn-outline-primary btn-sm" title="Редактировать">
<i class="fa-solid fa-pen"></i>
</a>
<form action="{{ site_url('/crm/contacts/' ~ contact.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('Удалить контакт?')" title="Удалить">
<i class="fa-solid fa-trash"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-4 text-muted">
Контактов пока нет
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{% endblock %} {% 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 %}

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">CRM</h1> <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> <p class="text-muted mb-0">Управление продажами и клиентами</p>
</div> </div>
</div> </div>
@ -66,7 +66,7 @@
</a> </a>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<a href="{{ site_url('/crm/clients') }}" class="card shadow-sm text-decoration-none h-100"> <a href="{{ site_url('/clients') }}" class="card shadow-sm text-decoration-none h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<div class="bg-success bg-opacity-10 rounded p-3"> <div class="bg-success bg-opacity-10 rounded p-3">

View File

@ -45,31 +45,13 @@
showNavigation: true, showNavigation: true,
showLegend: true, showLegend: true,
legend: calendarLegend, legend: calendarLegend,
eventComponent: '@Deals/calendar_event.twig' eventComponent: '@CRM/deals/calendar_event.twig'
}) }} }) }}
{% endblock %} {% endblock %}
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<style> <style>
.calendar-event {
display: block;
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-event:hover {
background-color: #e5e7eb;
}
.calendar-events-more { .calendar-events-more {
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
} }

View File

@ -29,10 +29,6 @@
<form method="POST" action="{{ actionUrl }}"> <form method="POST" action="{{ actionUrl }}">
{{ csrf_field()|raw }} {{ csrf_field()|raw }}
{% if deal is defined %}
<input type="hidden" name="_method" value="PUT">
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label fw-bold">Название сделки *</label> <label for="title" class="form-label fw-bold">Название сделки *</label>
<input type="text" <input type="text"

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">{{ title }}</h1> <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"> <p class="text-muted mb-0">
Всего: {{ items|length }} | Всего: {{ items|length }} |
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽ Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽

View File

@ -34,7 +34,7 @@
</a> </a>
</li> </li>
</ul> </ul>
{{ csrf_field()|raw }}
{# Канбан доска #} {# Канбан доска #}
{{ include('@components/kanban/kanban.twig', { {{ include('@components/kanban/kanban.twig', {
columns: kanbanColumns, columns: kanbanColumns,

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">{{ title }}</h1> <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> <p class="text-muted mb-0">Настройка воронки продаж</p>
</div> </div>
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary"> <a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
@ -137,7 +137,6 @@
<div class="modal-content"> <div class="modal-content">
<form id="editForm" method="POST"> <form id="editForm" method="POST">
{{ csrf_field()|raw }} {{ csrf_field()|raw }}
<input type="hidden" name="_method" value="PUT">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Редактировать этап</h5> <h5 class="modal-title">Редактировать этап</h5>

View File

@ -5,14 +5,17 @@ namespace App\Modules\Clients\Controllers;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Modules\Clients\Models\ClientModel; use App\Modules\Clients\Models\ClientModel;
use App\Services\AccessService; use App\Services\AccessService;
use App\Services\ModuleSubscriptionService;
class Clients extends BaseController class Clients extends BaseController
{ {
protected ClientModel $clientModel; protected ClientModel $clientModel;
protected ModuleSubscriptionService $subscriptionService;
public function __construct() public function __construct()
{ {
$this->clientModel = new ClientModel(); $this->clientModel = new ClientModel();
$this->subscriptionService = service('moduleSubscription');
} }
public function index() public function index()
@ -98,8 +101,9 @@ class Clients extends BaseController
} }
$data = [ $data = [
'title' => 'Добавить клиента', 'title' => 'Добавить клиента',
'client' => null, 'client' => null,
'crm_active' => $this->subscriptionService->isModuleActive('crm'),
]; ];
return $this->renderTwig('@Clients/form', $data); return $this->renderTwig('@Clients/form', $data);
@ -154,8 +158,9 @@ class Clients extends BaseController
} }
$data = [ $data = [
'title' => 'Редактировать клиента', 'title' => 'Редактировать клиента',
'client' => $client, 'client' => $client,
'crm_active' => $this->subscriptionService->isModuleActive('crm'),
]; ];
return $this->renderTwig('@Clients/form', $data); return $this->renderTwig('@Clients/form', $data);

View File

@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-10">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-white py-3"> <div class="card-header bg-white py-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@ -19,55 +19,106 @@
<div class="card-body"> <div class="card-body">
{{ forms.form_open(client ? base_url('/clients/update/' ~ client.id) : base_url('/clients/create')) }} {{ forms.form_open(client ? base_url('/clients/update/' ~ client.id) : base_url('/clients/create')) }}
<div class="mb-3"> {# Табы #}
<label for="name" class="form-label fw-bold">Имя / Название *</label> <ul class="nav nav-tabs mb-4" role="tablist">
<input type="text" name="name" id="name" class="form-control {{ errors.name ? 'is-invalid' : '' }}" <li class="nav-item">
value="{{ old.name ?? client.name ?? '' }}" required autofocus> <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-main" type="button">
{% if errors.name %} <i class="fa-solid fa-building me-2"></i>Основное
<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 class="d-flex justify-content-end gap-2">
<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> </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> </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() }} {{ forms.form_close() }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
{# Inline-редактирование контактов #}
{% if crm_active %}
<script src="{{ base_url('/assets/js/modules/contacts.js') }}"></script>
{% endif %}
{% endblock %}

View File

@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">{{ title }}</h1> <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> <p class="text-muted mb-0">Управление клиентами вашей организации</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">

View File

@ -66,7 +66,7 @@ class EventManager
private function getModulesConfig(): \Config\BusinessModules private function getModulesConfig(): \Config\BusinessModules
{ {
if ($this->modulesConfig === null) { if ($this->modulesConfig === null) {
$this->modulesConfig = new \Config\BusinessModules(); $this->modulesConfig = config('BusinessModules');
} }
return $this->modulesConfig; return $this->modulesConfig;
@ -144,7 +144,9 @@ class EventManager
} }
// Если модуль отключен глобально, не подписываемся // Если модуль отключен глобально, не подписываемся
if (empty($modulesConfig->modules[$this->moduleCode]['enabled'])) { // Проверяем наличие поля enabled (опционально)
if (isset($modulesConfig->modules[$this->moduleCode]['enabled']) &&
empty($modulesConfig->modules[$this->moduleCode]['enabled'])) {
log_message( log_message(
'info', 'info',
"EventManager: Module '{$this->moduleCode}' is disabled globally" "EventManager: Module '{$this->moduleCode}' is disabled globally"

View File

@ -2,393 +2,377 @@
namespace App\Services; namespace App\Services;
use App\Config\BusinessModules; use CodeIgniter\Config\BaseConfig;
use App\Models\OrganizationSubscriptionModel;
/** /**
* Сервис для работы с подписками на модули * Сервис для работы с подписками организаций на модули
* *
* Предоставляет API для проверки статуса подписок, * Предоставляет API для проверки доступности модулей,
* активации модулей и управления триальными периодами. * управления подписками и получения информации о модулях.
*/ */
class ModuleSubscriptionService class ModuleSubscriptionService
{ {
protected OrganizationSubscriptionModel $subscriptionModel; /**
protected BusinessModules $modulesConfig; * Конфигурация модулей (базовые значения)
protected ?int $activeOrgId = null; */
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,
],
];
public function __construct( protected $db;
?OrganizationSubscriptionModel $subscriptionModel = null, protected string $moduleSettingsTable = 'module_settings';
?BusinessModules $modulesConfig = null protected string $subscriptionsTable = 'organization_subscriptions';
) {
$this->subscriptionModel = $subscriptionModel ?? new OrganizationSubscriptionModel(); public function __construct()
$this->modulesConfig = $modulesConfig ?? config('BusinessModules'); {
$this->db = \Config\Database::connect();
} }
/** /**
* Получение активного ID организации из сессии * Получение базовой конфигурации модуля
*/ */
protected function getActiveOrgId(): ?int public function getModuleConfig(string $moduleCode): ?array
{ {
if ($this->activeOrgId === null) { return $this->modulesConfig[$moduleCode] ?? null;
$this->activeOrgId = session()->get('active_org_id') ?? 0; }
/**
* Получение всех модулей (с учётом переопределений из БД)
*/
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 $this->activeOrgId ?: null; return $modules;
} }
/** /**
* Проверка активности модуля для текущей организации * Проверка активности модуля для организации
*
* @param string $moduleCode Код модуля (crm, booking, tasks, proof)
* @param int|null $organizationId ID организации (null = из сессии)
* @return bool
*/ */
public function isModuleActive(string $moduleCode, ?int $organizationId = null): bool public function isModuleActive(string $moduleCode, ?int $organizationId = null): bool
{ {
$orgId = $organizationId ?? $this->getActiveOrgId();
if (!$orgId) {
return false;
}
// Базовый модуль всегда активен
if ($moduleCode === 'base') { if ($moduleCode === 'base') {
return true; return true;
} }
return $this->subscriptionModel->isModuleActive($orgId, $moduleCode); $orgId = $organizationId ?? session()->get('active_org_id');
}
/**
* Проверка что модуль доступен (активен или в триале)
*
* @param string $moduleCode
* @param int|null $organizationId
* @return bool
*/
public function isModuleAvailable(string $moduleCode, ?int $organizationId = null): bool
{
$orgId = $organizationId ?? $this->getActiveOrgId();
if (!$orgId) { if (!$orgId) {
return false; return false;
} }
// Базовый модуль всегда доступен $subscription = $this->getSubscription($orgId, $moduleCode);
if ($moduleCode === 'base') {
return true;
}
// Проверяем подписку
$subscription = $this->subscriptionModel->getSubscription($orgId, $moduleCode);
if (!$subscription) { if (!$subscription) {
return false; return false;
} }
// Активен или в триале return $subscription['status'] === 'active';
return in_array($subscription['status'], ['trial', 'active'], true);
} }
/** /**
* Проверка что модуль в триальном периоде * Проверка доступности модуля (активен или в триале)
*
* @param string $moduleCode
* @param int|null $organizationId
* @return bool
*/ */
public function isInTrial(string $moduleCode, ?int $organizationId = null): bool public function isModuleAvailable(string $moduleCode, ?int $organizationId = null): bool
{ {
// Базовый модуль не имеет триала
if ($moduleCode === 'base') { if ($moduleCode === 'base') {
return false; return true;
} }
$orgId = $organizationId ?? $this->getActiveOrgId(); $orgId = $organizationId ?? session()->get('active_org_id');
if (!$orgId) { if (!$orgId) {
return false; return false;
} }
return $this->subscriptionModel->isInTrial($orgId, $moduleCode); $subscription = $this->getSubscription($orgId, $moduleCode);
if (!$subscription) {
return false;
}
return in_array($subscription['status'], ['active', 'trial'], true);
} }
/** /**
* Проверка что триал истекает скоро (для показа уведомлений) * Получение подписки организации на модуль
*
* @param string $moduleCode
* @param int $daysThreshold Порог в днях
* @param int|null $organizationId
* @return bool
*/ */
public function isTrialExpiringSoon( 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 $moduleCode,
int $daysThreshold = 3, string $status = 'active',
?int $organizationId = null ?int $days = null
): bool { ): bool {
if (!$this->isInTrial($moduleCode, $organizationId)) { $existing = $this->getSubscription($organizationId, $moduleCode);
return false;
$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);
} }
$orgId = $organizationId ?? $this->getActiveOrgId(); $data['created_at'] = date('Y-m-d H:i:s');
$daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode); return $this->db->table($this->subscriptionsTable)->insert($data);
return $daysLeft !== null && $daysLeft <= $daysThreshold && $daysLeft > 0;
} }
/** /**
* Получение дней до окончания подписки/триала * Удаление подписки
*
* @param string $moduleCode
* @param int|null $organizationId
* @return int|null Количество дней или null если не активна
*/ */
public function getDaysUntilExpire(string $moduleCode, ?int $organizationId = null): ?int public function deleteSubscription(int $subscriptionId): bool
{ {
$orgId = $organizationId ?? $this->getActiveOrgId(); return $this->db->table($this->subscriptionsTable)
->where('id', $subscriptionId)
if (!$orgId) { ->delete();
return null;
}
return $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode);
} }
/** /**
* Получение информации о модуле из конфигурации * Запуск триала для модуля
*
* @param string $moduleCode
* @return array|null
*/ */
public function getModuleInfo(string $moduleCode): ?array public function startTrial(int $organizationId, string $moduleCode, int $trialDays = 14): bool
{ {
return $this->modulesConfig->getModule($moduleCode); $config = $this->getModuleConfig($moduleCode);
} if (!$config || $config['trial_days'] <= 0) {
/**
* Получение всех доступных модулей для организации
*
* @param int|null $organizationId
* @return array Список кодов модулей
*/
public function getActiveModules(?int $organizationId = null): array
{
$orgId = $organizationId ?? $this->getActiveOrgId();
if (!$orgId) {
return ['base'];
}
$activeModules = $this->subscriptionModel->getActiveModules($orgId);
// Всегда добавляем базовый модуль
$activeModules[] = 'base';
return array_unique($activeModules);
}
/**
* Запуск триального периода для модуля
*
* @param string $moduleCode
* @param int|null $organizationId
* @param int $trialDays
* @return bool
*/
public function startTrial(
string $moduleCode,
?int $organizationId = null,
int $trialDays = 14
): bool {
$orgId = $organizationId ?? $this->getActiveOrgId();
if (!$orgId) {
return false; return false;
} }
// Проверяем что модуль существует return $this->upsertSubscription(
if (!$this->modulesConfig->exists($moduleCode)) { $organizationId,
return false;
}
// Проверяем что модуль платный
$moduleInfo = $this->modulesConfig->getModule($moduleCode);
if (!$moduleInfo || $moduleInfo['trial_days'] <= 0) {
return false;
}
// Проверяем что триал ещё не был использован
if ($this->subscriptionModel->isInTrial($orgId, $moduleCode)) {
return false; // Уже в триале
}
// Проверяем что нет активной подписки
if ($this->isModuleActive($moduleCode, $orgId)) {
return false; // Уже активна
}
return (bool) $this->subscriptionModel->startTrial(
$orgId,
$moduleCode, $moduleCode,
$moduleInfo['trial_days'] 'trial',
$trialDays
); );
} }
/** /**
* Активация платной подписки (для ручного включения или после платежа) * Активация подписки
*
* @param string $moduleCode
* @param int $months
* @param int|null $organizationId
* @return bool
*/ */
public function activate( public function activate(int $organizationId, string $moduleCode, int $months = 1): bool
string $moduleCode, {
int $months = 1, return $this->upsertSubscription(
?int $organizationId = null $organizationId,
): bool { $moduleCode,
$orgId = $organizationId ?? $this->getActiveOrgId(); 'active',
$months * 30
if (!$orgId) { );
return false;
}
// Проверяем что модуль существует
if (!$this->modulesConfig->exists($moduleCode)) {
return false;
}
return $this->subscriptionModel->activate($orgId, $moduleCode, $months);
} }
/** /**
* Отмена подписки * Отмена подписки
*
* @param string $moduleCode
* @param int|null $organizationId
* @return bool
*/ */
public function cancel(string $moduleCode, ?int $organizationId = null): bool public function cancel(int $organizationId, string $moduleCode): bool
{ {
$orgId = $organizationId ?? $this->getActiveOrgId(); $existing = $this->getSubscription($organizationId, $moduleCode);
if (!$existing) {
if (!$orgId) {
return false; return false;
} }
return $this->subscriptionModel->cancel($orgId, $moduleCode); return $this->db->table($this->subscriptionsTable)
->where('id', $existing['id'])
->update(['status' => 'cancelled', 'updated_at' => date('Y-m-d H:i:s')]);
} }
/** /**
* Проверка доступа к функции модуля * Получение всех подписок (для суперадмина)
*
* @param string $moduleCode
* @param string $feature Опционально - конкретная фича
* @return bool
*/ */
public function canUseModule(string $moduleCode, string $feature = ''): bool public function getAllSubscriptions(): array
{ {
if (!$this->isModuleActive($moduleCode)) { $builder = $this->db->table($this->subscriptionsTable);
return false; return $builder
} ->select('organization_subscriptions.*, organizations.name as organization_name')
->join('organizations', 'organizations.id = organization_subscriptions.organization_id')
// Для триальных версий могут быть ограничения на некоторые фичи ->orderBy('organization_subscriptions.created_at', 'DESC')
if ($this->isInTrial($moduleCode)) { ->get()
// Проверяем доступность фичи в триале ->getResultArray();
// (можно расширить через конфигурацию)
}
return true;
} }
/** /**
* Получение статуса подписки для отображения в UI * Статистика по модулям (для суперадмина)
*
* @param string $moduleCode
* @param int|null $organizationId
* @return array
*/ */
public function getSubscriptionStatus( 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 $moduleCode,
?int $organizationId = null ?string $name = null,
): array { ?string $description = null,
$orgId = $organizationId ?? $this->getActiveOrgId(); ?int $priceMonthly = null,
?int $priceYearly = null,
?int $trialDays = null
): bool {
$existing = $this->db->table($this->moduleSettingsTable)
->where('module_code', $moduleCode)
->get()
->getRowArray();
$moduleInfo = $this->modulesConfig->getModule($moduleCode); $data = [
$subscription = $orgId 'module_code' => $moduleCode,
? $this->subscriptionModel->getSubscription($orgId, $moduleCode) 'updated_at' => date('Y-m-d H:i:s'),
: null;
$status = 'unavailable';
$daysLeft = null;
$message = '';
if ($moduleCode === 'base') {
$status = 'active';
$message = 'Базовый модуль';
} elseif (!$subscription) {
if ($moduleInfo && $moduleInfo['trial_days'] > 0) {
$status = 'trial_available';
$message = 'Доступен триал ' . $moduleInfo['trial_days'] . ' дней';
} else {
$status = 'locked';
$message = 'Приобретите модуль';
}
} elseif ($subscription['status'] === 'trial') {
$daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode);
if ($daysLeft && $daysLeft > 0) {
$status = 'trial';
$message = "Триал: {$daysLeft} дн.";
} else {
$status = 'expired';
$message = 'Триал истёк';
}
} elseif ($subscription['status'] === 'active') {
$daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode);
$status = 'active';
$message = $daysLeft ? "Осталось {$daysLeft} дн." : 'Активна';
} elseif (in_array($subscription['status'], ['expired', 'cancelled'])) {
$status = 'expired';
$message = 'Подписка завершена';
}
return [
'status' => $status,
'message' => $message,
'days_left' => $daysLeft,
'module' => $moduleInfo,
'subscription' => $subscription,
]; ];
}
/** if ($name !== null) {
* Получение цены модуля $data['name'] = $name;
* }
* @param string $moduleCode if ($description !== null) {
* @param string $period $data['description'] = $description;
* @return int }
*/ if ($priceMonthly !== null) {
public function getPrice(string $moduleCode, string $period = 'monthly'): int $data['price_monthly'] = $priceMonthly;
{ }
return $this->modulesConfig->getPrice($moduleCode, $period); if ($priceYearly !== null) {
} $data['price_yearly'] = $priceYearly;
}
/** if ($trialDays !== null) {
* Проверка что пользователь может управлять подписками организации $data['trial_days'] = $trialDays;
*
* @param int|null $organizationId
* @return bool
*/
public function canManageSubscriptions(?int $organizationId = null): bool
{
$orgId = $organizationId ?? $this->getActiveOrgId();
if (!$orgId) {
return false;
} }
// Проверяем права через AccessService if ($existing) {
return service('access')->canManageModules(); 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();
} }
} }

View File

@ -101,7 +101,86 @@
</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) %} {% if showLegend|default(true) and (legend is defined or events is defined) %}
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">

View File

@ -131,20 +131,42 @@ function handleDrop(e) {
if (itemId && newColumnId) { if (itemId && newColumnId) {
if (moveUrl) { if (moveUrl) {
// AJAX перемещение 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, { fetch(moveUrl, {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest'
}, },
body: 'item_id=' + itemId + '&column_id=' + newColumnId body: 'deal_id=' + itemId + '&stage_id=' + newColumnId
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
if (onMove) { if (onMove) {
window[onMove](itemId, newColumnId, data); 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 { } else {
location.reload(); location.reload();
} }
@ -162,4 +184,22 @@ function handleDrop(e) {
} }
} }
} }
/**
* Обновление счётчиков колонки (количество карточек и сумма)
*/
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> </script>

View File

@ -16,10 +16,21 @@
<div class="col-md-6 col-lg-3 mb-4"> <div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100 text-center p-3"> <div class="card h-100 text-center p-3">
<div class="card-body"> <div class="card-body">
<i class="fa-solid fa-users fa-3x text-primary mb-3"></i> <i class="fa-solid fa-building fa-3x text-primary mb-3"></i>
<h5 class="card-title">CRM</h5> <h5 class="card-title">Клиенты</h5>
<p class="card-text text-muted">Управление клиентами</p> <p class="card-text text-muted">Управление клиентами</p>
<a href="#" class="btn btn-outline-primary btn-sm">Скоро</a> <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>
</div> </div>

View File

@ -37,23 +37,62 @@
<a href="{{ base_url('/clients') }}" <a href="{{ base_url('/clients') }}"
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
{{ is_active_route('clients') ? 'active' : '' }}"> {{ is_active_route('clients') ? 'active' : '' }}">
<i class="fa-solid fa-users me-2"></i> Клиенты <i class="fa-solid fa-building text-primary me-2"></i> Клиенты
</a> </a>
{# Модули #}
<!-- Будущие модули --> {# CRM модуль #}
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled"> {% if is_module_available('crm') %}
<i class="fa-solid fa-chart-line me-2"></i> 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> </a>
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled"> {% 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 <i class="fa-solid fa-calendar me-2"></i> Booking
</a> </a>
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled"> {% else %}
<i class="fa-solid fa-file-contract me-2"></i> Proof <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> </a>
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled"> {% 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 <i class="fa-solid fa-check-square me-2"></i> Tasks
</a> </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>
</div> </div>
<!-- /SIDEBAR --> <!-- /SIDEBAR -->

View File

@ -30,9 +30,9 @@
<div class="icon">📅</div> <div class="icon">📅</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>Всего тарифов</h3> <h3>Всего модулей</h3>
<div class="value">{{ stats.total_plans|number_format(0, '', ' ') }}</div> <div class="value">{{ stats.total_modules|number_format(0, '', ' ') }}</div>
<div class="icon">📋</div> <div class="icon">📦</div>
</div> </div>
</div> </div>

View File

@ -71,7 +71,8 @@
<nav> <nav>
<ul> <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') }}" class="{{ is_active_route('superadmin') and not is_active_route('superadmin/') ? 'active' : '' }}">📊 Дашборд</a></li>
<li><a href="{{ base_url('/superadmin/plans') }}" class="{{ is_active_route('superadmin/plans') ? '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/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/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><a href="{{ base_url('/superadmin/statistics') }}" class="{{ is_active_route('superadmin/statistics') ? 'active' : '' }}">📈 Статистика</a></li>

View File

@ -0,0 +1,92 @@
{% 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 %}

View File

@ -1,220 +1,245 @@
{% extends 'superadmin/layout.twig' %} {% extends 'superadmin/layout.twig' %}
{% block content %} {% block content %}
<div class="sa-header"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1>Организация: {{ organization.name }}</h1> <h1>Организация: {{ organization.name }}</h1>
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-primary">← Назад</a> <a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left"></i> Назад к списку
</a>
</div> </div>
{% for alert in get_alerts() %} {% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div> <div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %} {% endfor %}
<div class="grid-2"> <div class="row">
<div class="sa-card"> <div class="col-md-8">
<div class="sa-card-header"> <div class="card mb-4">
<h2>Информация об организации</h2> <div class="card-header">
</div> <h5 class="card-title mb-0">Подписки на модули</h5>
<div class="sa-card-body"> </div>
<table class="table"> <div class="card-body">
<tr> {% if subscriptions is empty %}
<th style="width: 150px;">ID</th> <p class="text-muted text-center py-4">У организации нет активных подписок</p>
<td>{{ organization.id }}</td>
</tr>
<tr>
<th>Название</th>
<td>{{ organization.name }}</td>
</tr>
<tr>
<th>Тип</th>
<td>
{% if organization.type == 'business' %}
<span class="badge badge-info">Бизнес</span>
{% else %}
<span class="badge badge-warning">Личное пространство</span>
{% endif %}
</td>
</tr>
<tr>
<th>Тариф</th>
<td>
{% if currentSubscription %}
{% set plan = plans|filter(p => p.id == currentSubscription.plan_id)|first %}
{% if plan %}
<span class="badge badge-primary">{{ plan.name }}</span>
{% else %}
<span class="badge badge-secondary">ID: {{ currentSubscription.plan_id }}</span>
{% endif %}
{% else %}
<span class="badge badge-secondary">Не назначен</span>
{% endif %}
</td>
</tr>
<tr>
<th>Статус подписки</th>
<td>
{% if currentSubscription %}
{% if currentSubscription.status == 'active' %}
<span class="badge badge-success">Активна</span>
{% elseif currentSubscription.status == 'expired' %}
<span class="badge badge-danger">Истёкшая</span>
{% elseif currentSubscription.status == 'trial' %}
<span class="badge badge-info">Пробный период</span>
{% else %}
<span class="badge badge-warning">{{ currentSubscription.status }}</span>
{% endif %}
{% else %}
<span class="badge badge-secondary">Нет подписки</span>
{% endif %}
</td>
</tr>
<tr>
<th>Срок действия</th>
<td>
{% if currentSubscription and currentSubscription.expires_at %}
до {{ currentSubscription.expires_at|date('d.m.Y H:i') }}
{% else %}
Не ограничен
{% endif %}
</td>
</tr>
<tr>
<th>Статус организации</th>
<td>
{% if organization.status == 'active' %}
<span class="badge badge-success">Активна</span>
{% elseif organization.status == 'blocked' %}
<span class="badge badge-danger">Заблокирована</span>
{% else %}
<span class="badge badge-warning">{{ organization.status|default('Не определён') }}</span>
{% endif %}
</td>
</tr>
<tr>
<th>Создана</th>
<td>{{ organization.created_at|date('d.m.Y H:i') }}</td>
</tr>
</table>
<div style="margin-top: 20px; display: flex; gap: 10px; flex-wrap: wrap;">
{% if organization.status == 'active' %}
<a href="{{ base_url('/superadmin/organizations/block/' ~ organization.id) }}" class="btn btn-warning" onclick="return confirm('Заблокировать организацию?')">🚫 Заблокировать</a>
{% else %} {% else %}
<a href="{{ base_url('/superadmin/organizations/unblock/' ~ organization.id) }}" class="btn btn-success" onclick="return confirm('Разблокировать организацию?')">✅ Разблокировать</a> <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 %} {% endif %}
<a href="{{ base_url('/superadmin/organizations/delete/' ~ organization.id) }}" class="btn btn-danger" onclick="return confirm('Удалить организацию? Это действие нельзя отменить!')">🗑️ Удалить</a>
</div> </div>
</div> </div>
</div> </div>
<div class="sa-card"> <div class="col-md-4">
<div class="sa-card-header"> <div class="card mb-4">
<h2>Управление тарифом</h2> <div class="card-header">
</div> <h5 class="card-title mb-0">Добавить подписку</h5>
<div class="sa-card-body"> </div>
<form action="{{ base_url('/superadmin/organizations/set-plan/' ~ organization.id) }}" method="post"> <div class="card-body">
{{ csrf_field()|raw }} <form action="{{ base_url('/superadmin/organizations/' ~ organization.id ~ '/add-subscription') }}" method="post">
{{ csrf_field()|raw }}
<div class="form-group">
<label for="plan_id">Выберите тариф</label> <div class="mb-3">
<select name="plan_id" id="plan_id" class="form-control" required> <label class="form-label">Модуль</label>
<option value="">-- Выберите тариф --</option> <select name="module_code" class="form-select" required>
{% for plan in plans %} <option value="">Выберите модуль...</option>
<option value="{{ plan.id }}" {{ currentSubscription and currentSubscription.plan_id == plan.id ? 'selected' : '' }}> {% for code, module in allModules %}
{{ plan.name }} - {{ plan.price }} {{ plan.currency }}/{{ plan.billing_period }} {% 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> </option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<div class="form-group">
<label for="duration_days">Срок действия (дней)</label>
<input type="number" name="duration_days" id="duration_days" class="form-control"
value="30" min="0" max="365000" placeholder="30">
<small class="text-muted">Оставьте пустым или 0 для неограниченного срока</small>
</div>
<button type="submit" class="btn btn-primary">Назначить тариф</button>
</form>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<h4>Статистика</h4>
<div style="text-align: center; padding: 20px;">
<div style="font-size: 48px; font-weight: bold; color: #3498db;">{{ users|length }}</div>
<div style="color: #7f8c8d;">Участников</div>
</div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<div style="display: flex; justify-content: space-around; text-align: center;">
<div>
<div style="font-size: 24px; font-weight: bold; color: #27ae60;">
{% set owner_count = users|filter(u => u.role == 'owner')|length %}
{{ owner_count }}
</div> </div>
<div style="color: #7f8c8d; font-size: 12px;">Владельцы</div>
</div> <div class="mb-3">
<div> <label class="form-label">Срок действия (дней)</label>
<div style="font-size: 24px; font-weight: bold; color: #f39c12;"> <input type="number" name="duration_days" class="form-control" value="30" min="0">
{% set admin_count = users|filter(u => u.role == 'admin')|length %} <div class="form-text">0 - бессрочно</div>
{{ admin_count }}
</div> </div>
<div style="color: #7f8c8d; font-size: 12px;">Админы</div>
</div> <div class="mb-3">
<div> <label class="form-label">Статус</label>
<div style="font-size: 24px; font-weight: bold; color: #9b59b6;"> <select name="status" class="form-select">
{% set manager_count = users|filter(u => u.role == 'manager')|length %} <option value="active">Активна</option>
{{ manager_count }} <option value="trial">Триал</option>
<option value="expired">Истекла</option>
<option value="cancelled">Отменена</option>
</select>
</div> </div>
<div style="color: #7f8c8d; font-size: 12px;">Менеджеры</div>
</div> <button type="submit" class="btn btn-primary w-100">
<i class="fa-solid fa-plus"></i> Добавить подписку
</button>
</form>
</div> </div>
</div> </div>
</div>
</div>
<div class="sa-card" style="margin-top: 20px;"> <div class="card mb-4">
<div class="sa-card-header"> <div class="card-header">
<h2>Участники организации</h2> <h5 class="card-title mb-0">Информация</h5>
</div> </div>
<div class="sa-card-body"> <div class="card-body">
{% if users is empty %} <table class="table table-sm">
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Участников пока нет</p>
{% else %}
<table class="table">
<thead>
<tr> <tr>
<th>Пользователь</th> <td>ID</td>
<th>Email</th> <td class="text-end">{{ organization.id }}</td>
<th>Роль</th>
<th>Статус</th>
<th>Дата добавления</th>
</tr> </tr>
</thead>
<tbody>
{% for user in users %}
<tr> <tr>
<td>{{ user.name|default('—') }}</td> <td>Тип</td>
<td>{{ user.email }}</td> <td class="text-end">
<td> {% if organization.type == 'business' %}
<span class="badge {{ user.role == 'owner' ? 'badge-danger' : (user.role == 'admin' ? 'badge-warning' : 'badge-info') }}"> <span class="badge bg-info">Бизнес</span>
{{ user.role }}
</span>
</td>
<td>
{% if user.status == 'active' %}
<span class="badge badge-success">Активен</span>
{% elseif user.status == 'blocked' %}
<span class="badge badge-danger">Заблокирован</span>
{% else %} {% else %}
<span class="badge badge-warning">{{ user.status }}</span> <span class="badge bg-warning">Личное</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ user.created_at|date('d.m.Y') }}</td>
</tr> </tr>
{% endfor %} <tr>
</tbody> <td>Статус</td>
</table> <td class="text-end">
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,95 +0,0 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Создание тарифа</h1>
<a href="{{ base_url('/superadmin/plans') }}" class="btn btn-primary">← Назад</a>
</div>
<div class="sa-card">
<div class="sa-card-body">
<form action="{{ base_url('/superadmin/plans/store') }}" method="post">
{{ csrf_field()|raw }}
<div class="grid-2">
<div class="form-group">
<label for="name">Название тарифа *</label>
<input type="text" name="name" id="name" class="form-control" value="{{ old('name') }}" required>
</div>
<div class="form-group">
<label for="price">Цена *</label>
<input type="number" name="price" id="price" class="form-control" value="{{ old('price', 0) }}" step="0.01" min="0" required>
</div>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea name="description" id="description" class="form-control" rows="3">{{ old('description') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label for="currency">Валюта</label>
<select name="currency" id="currency" class="form-control">
<option value="RUB" {{ old('currency', 'RUB') == 'RUB' ? 'selected' : '' }}>Рубль (RUB)</option>
<option value="USD" {{ old('currency') == 'USD' ? 'selected' : '' }}>Доллар (USD)</option>
<option value="EUR" {{ old('currency') == 'EUR' ? 'selected' : '' }}>Евро (EUR)</option>
</select>
</div>
<div class="form-group">
<label for="billing_period">Период оплаты</label>
<select name="billing_period" id="billing_period" class="form-control">
<option value="monthly" {{ old('billing_period', 'monthly') == 'monthly' ? 'selected' : '' }}>Ежемесячно</option>
<option value="yearly" {{ old('billing_period') == 'yearly' ? 'selected' : '' }}>Ежегодно</option>
<option value="quarterly" {{ old('billing_period') == 'quarterly' ? 'selected' : '' }}>Ежеквартально</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label for="max_users">Максимум пользователей *</label>
<input type="number" name="max_users" id="max_users" class="form-control" value="{{ old('max_users', 5) }}" min="1" required>
</div>
<div class="form-group">
<label for="max_clients">Максимум клиентов *</label>
<input type="number" name="max_clients" id="max_clients" class="form-control" value="{{ old('max_clients', 100) }}" min="1" required>
</div>
</div>
<div class="form-group">
<label for="max_storage">Максимум хранилища (ГБ) *</label>
<input type="number" name="max_storage" id="max_storage" class="form-control" value="{{ old('max_storage', 10) }}" min="1" required>
</div>
<div class="form-group">
<label for="features">Возможности (каждая с новой строки)</label>
<textarea name="features_list" id="features" class="form-control" rows="5" placeholder="Неограниченные проекты&#10;Приоритетная поддержка&#10;Экспорт в PDF">{{ old('features_list') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label>
<input type="checkbox" name="is_active" value="1" {{ old('is_active', 1) ? 'checked' : '' }}>
Активен (доступен для выбора)
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" {{ old('is_default') ? 'checked' : '' }}>
Тариф по умолчанию
</label>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-block">Создать тариф</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,96 +0,0 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Редактирование тарифа: {{ plan.name }}</h1>
<a href="{{ base_url('/superadmin/plans') }}" class="btn btn-primary">← Назад</a>
</div>
<div class="sa-card">
<div class="sa-card-body">
<form action="{{ base_url('/superadmin/plans/update/' ~ plan.id) }}" method="post">
{{ csrf_field()|raw }}
<div class="grid-2">
<div class="form-group">
<label for="name">Название тарифа *</label>
<input type="text" name="name" id="name" class="form-control" value="{{ plan.name }}" required>
</div>
<div class="form-group">
<label for="price">Цена *</label>
<input type="number" name="price" id="price" class="form-control" value="{{ plan.price }}" step="0.01" min="0" required>
</div>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea name="description" id="description" class="form-control" rows="3">{{ plan.description|default('') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label for="currency">Валюта</label>
<select name="currency" id="currency" class="form-control">
<option value="RUB" {{ plan.currency == 'RUB' ? 'selected' : '' }}>Рубль (RUB)</option>
<option value="USD" {{ plan.currency == 'USD' ? 'selected' : '' }}>Доллар (USD)</option>
<option value="EUR" {{ plan.currency == 'EUR' ? 'selected' : '' }}>Евро (EUR)</option>
</select>
</div>
<div class="form-group">
<label for="billing_period">Период оплаты</label>
<select name="billing_period" id="billing_period" class="form-control">
<option value="monthly" {{ plan.billing_period == 'monthly' ? 'selected' : '' }}>Ежемесячно</option>
<option value="yearly" {{ plan.billing_period == 'yearly' ? 'selected' : '' }}>Ежегодно</option>
<option value="quarterly" {{ plan.billing_period == 'quarterly' ? 'selected' : '' }}>Ежеквартально</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label for="max_users">Максимум пользователей *</label>
<input type="number" name="max_users" id="max_users" class="form-control" value="{{ plan.max_users }}" min="1" required>
</div>
<div class="form-group">
<label for="max_clients">Максимум клиентов *</label>
<input type="number" name="max_clients" id="max_clients" class="form-control" value="{{ plan.max_clients }}" min="1" required>
</div>
</div>
<div class="form-group">
<label for="max_storage">Максимум хранилища (ГБ) *</label>
<input type="number" name="max_storage" id="max_storage" class="form-control" value="{{ plan.max_storage }}" min="1" required>
</div>
<div class="form-group">
<label for="features">Возможности (каждая с новой строки)</label>
<textarea name="features_list" id="features" class="form-control" rows="5" placeholder="Неограниченные проекты&#10;Приоритетная поддержка&#10;Экспорт в PDF">{% if plan.features is iterable %}{% for feature in plan.features %}
{{ feature }}{% endfor %}{% endif %}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label>
<input type="checkbox" name="is_active" value="1" {{ plan.is_active ? 'checked' : '' }}>
Активен (доступен для выбора)
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" {{ plan.is_default ? 'checked' : '' }}>
Тариф по умолчанию
</label>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-block">Сохранить изменения</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -1,77 +0,0 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Тарифы</h1>
<a href="{{ base_url('/superadmin/plans/create') }}" class="btn btn-success">+ Добавить тариф</a>
</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">
{% if plans is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Тарифов пока нет. Создайте первый тариф.</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th>Цена</th>
<th>Период</th>
<th>Лимиты</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for plan in plans %}
<tr>
<td>
<strong>{{ plan.name }}</strong>
{% if plan.is_default %}
<span class="badge badge-success">По умолчанию</span>
{% endif %}
</td>
<td>{{ plan.description|default('—') }}</td>
<td>
<strong>{{ plan.price|number_format(0, '', ' ') }}</strong> {{ plan.currency }}
</td>
<td>
{% if plan.billing_period == 'monthly' %}
Месяц
{% elseif plan.billing_period == 'yearly' %}
Год
{% else %}
{{ plan.billing_period }}
{% endif %}
</td>
<td>
<small style="color: #7f8c8d;">
👥 до {{ plan.max_users }} чел.<br>
👤 до {{ plan.max_clients }} клиентов<br>
💾 до {{ plan.max_storage }} ГБ
</small>
</td>
<td>
{% if plan.is_active %}
<span class="badge badge-success">Активен</span>
{% else %}
<span class="badge badge-danger">Неактивен</span>
{% endif %}
</td>
<td>
<a href="{{ base_url('/superadmin/plans/edit/' ~ plan.id) }}" class="btn btn-primary btn-sm">✏️</a>
<a href="{{ base_url('/superadmin/plans/delete/' ~ plan.id) }}" class="btn btn-danger btn-sm" onclick="return confirm('Вы уверены?')">🗑️</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,209 @@
{% 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 %}

View File

@ -0,0 +1,63 @@
{% 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 %}

587
dump.sql Normal file
View File

@ -0,0 +1,587 @@
/*M!999999\- enable the sandbox mode */
-- MariaDB dump 10.19-11.8.3-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: bp_mirv_db
-- ------------------------------------------------------
-- Server version 11.8.3-MariaDB-0+deb13u1 from Debian
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */;
--
-- Table structure for table `ci_sessions`
--
DROP TABLE IF EXISTS `ci_sessions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `ci_sessions` (
`id` varchar(128) NOT NULL,
`ip_address` varchar(45) NOT NULL,
`timestamp` int(10) unsigned NOT NULL DEFAULT 0,
`data` blob DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `timestamp` (`timestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `ci_sessions`
--
LOCK TABLES `ci_sessions` WRITE;
/*!40000 ALTER TABLE `ci_sessions` DISABLE KEYS */;
set autocommit=0;
/*!40000 ALTER TABLE `ci_sessions` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `contacts`
--
DROP TABLE IF EXISTS `contacts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `contacts` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`customer_id` int(11) unsigned NOT NULL COMMENT 'Ссылка на клиента (компанию)',
`name` varchar(255) NOT NULL COMMENT 'Имя контакта',
`email` varchar(255) DEFAULT NULL,
`phone` varchar(50) DEFAULT NULL,
`position` varchar(255) DEFAULT NULL COMMENT 'Должность',
`is_primary` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Основной контакт',
`notes` text DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `organization_id` (`organization_id`),
KEY `customer_id` (`customer_id`),
CONSTRAINT `contacts_customer_id_foreign` FOREIGN KEY (`customer_id`) REFERENCES `organizations_clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `contacts_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `contacts`
--
LOCK TABLES `contacts` WRITE;
/*!40000 ALTER TABLE `contacts` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `contacts` VALUES
(1,12,1,'Петров',NULL,'+79999999955','Вахтер',0,NULL,'2026-01-15 02:50:50','2026-01-15 02:50:50',NULL);
/*!40000 ALTER TABLE `contacts` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `deal_history`
--
DROP TABLE IF EXISTS `deal_history`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `deal_history` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`deal_id` bigint(20) unsigned NOT NULL,
`user_id` int(11) unsigned NOT NULL,
`action` varchar(50) NOT NULL,
`field_name` varchar(50) DEFAULT NULL,
`old_value` text DEFAULT NULL,
`new_value` text DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `deal_id` (`deal_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `deal_history_deal_id_foreign` FOREIGN KEY (`deal_id`) REFERENCES `deals` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `deal_history_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `deal_history`
--
LOCK TABLES `deal_history` WRITE;
/*!40000 ALTER TABLE `deal_history` DISABLE KEYS */;
set autocommit=0;
/*!40000 ALTER TABLE `deal_history` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `deal_stages`
--
DROP TABLE IF EXISTS `deal_stages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `deal_stages` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`color` varchar(7) NOT NULL DEFAULT '#6B7280',
`order_index` int(11) NOT NULL DEFAULT 0,
`type` enum('progress','won','lost') NOT NULL DEFAULT 'progress',
`probability` int(3) unsigned NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `organization_id` (`organization_id`),
CONSTRAINT `deal_stages_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `deal_stages`
--
LOCK TABLES `deal_stages` WRITE;
/*!40000 ALTER TABLE `deal_stages` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `deal_stages` VALUES
(1,12,'Первый контакт','#6b7280',1,'progress',1,NULL,NULL,NULL),
(2,12,'Второй контакт','#9a9996',2,'progress',5,NULL,NULL,NULL),
(3,12,'Переговоры по договору','#c061cb',3,'progress',10,NULL,NULL,NULL);
/*!40000 ALTER TABLE `deal_stages` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `deals`
--
DROP TABLE IF EXISTS `deals`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `deals` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`contact_id` int(11) unsigned DEFAULT NULL,
`company_id` int(11) unsigned DEFAULT NULL,
`title` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`amount` decimal(15,2) NOT NULL DEFAULT 0.00,
`currency` char(3) NOT NULL DEFAULT 'RUB',
`stage_id` int(11) unsigned NOT NULL,
`assigned_user_id` int(11) unsigned DEFAULT NULL,
`expected_close_date` date DEFAULT NULL,
`created_by` int(11) unsigned NOT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `organization_id` (`organization_id`),
KEY `stage_id` (`stage_id`),
KEY `assigned_user_id` (`assigned_user_id`),
CONSTRAINT `deals_assigned_user_id_foreign` FOREIGN KEY (`assigned_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE SET NULL,
CONSTRAINT `deals_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `deals_stage_id_foreign` FOREIGN KEY (`stage_id`) REFERENCES `deal_stages` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `deals`
--
LOCK TABLES `deals` WRITE;
/*!40000 ALTER TABLE `deals` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `deals` VALUES
(1,12,1,1,'Раскрутка сайта','',1000.00,'RUB',3,NULL,NULL,11,'2026-01-15 03:07:32','2026-01-15 03:07:32',NULL),
(2,12,NULL,1,'Пупупу','',15000.00,'RUB',1,11,'2026-02-28',11,'2026-01-15 03:45:08','2026-01-15 07:43:07',NULL);
/*!40000 ALTER TABLE `deals` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `migrations`
--
DROP TABLE IF EXISTS `migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `migrations` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(255) NOT NULL,
`class` varchar(255) NOT NULL,
`group` varchar(255) NOT NULL,
`namespace` varchar(255) NOT NULL,
`time` int(11) NOT NULL,
`batch` int(11) unsigned NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `migrations`
--
LOCK TABLES `migrations` WRITE;
/*!40000 ALTER TABLE `migrations` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `migrations` VALUES
(1,'2026-01-07-053357','App\\Database\\Migrations\\CreateUsersTable','default','App',1767769689,1),
(2,'2026-01-07-053401','App\\Database\\Migrations\\CreateOrganizationsTable','default','App',1767769689,1),
(3,'2026-01-07-053407','App\\Database\\Migrations\\CreateOrganizationUsersTable','default','App',1767769689,1),
(4,'2026-01-07-053412','App\\Database\\Migrations\\CreateOrganizationSubscriptionsTable','default','App',1767769689,1),
(5,'2026-01-07-053413','App\\Database\\Migrations\\CreateOrganizationSubscriptionsTable','default','App',1767769974,2),
(6,'2026-01-08-000001','App\\Database\\Migrations\\AddEmailVerificationToUsers','default','App',1767870811,3),
(7,'2026-01-08-200001','App\\Database\\Migrations\\CreateOrganizationsClientsTable','default','App',1767878430,4),
(8,'2026-01-12-000001','App\\Database\\Migrations\\AddInviteFieldsToOrganizationUsers','default','App',1768267451,5),
(9,'2026-01-13-000001','App\\Database\\Migrations\\CreateRememberTokensTable','default','App',1768267451,5),
(10,'2026-01-13-163701','App\\Database\\Migrations\\AddTrialEndsAtToSubscriptions','default','App',1768295204,6),
(11,'2026-01-13-200001','App\\Database\\Migrations\\CreateCiSessionsTable','default','App',1768313545,7),
(12,'2026-01-13-200002','App\\Database\\Migrations\\AddPasswordResetFieldsToUsers','default','App',1768313545,7),
(13,'2026-01-14-000001','App\\Database\\Migrations\\AddSystemRoleToUsers','default','App',1768317531,8),
(14,'2026-01-15-000001','App\\Database\\Migrations\\AddPlansTable','default','App',1768317531,8),
(15,'2026-01-15-000002','App\\Database\\Migrations\\CreateOrganizationPlanSubscriptionsTable','default','App',1768320790,9),
(16,'2026-01-15-000003','App\\Database\\Migrations\\AddTokenExpiresToUsers','default','App',1768372597,10),
(17,'2026-01-15-000004','App\\Database\\Migrations\\AddInviteExpiresToOrganizationUsers','default','App',1768372597,10),
(18,'2026-01-15-000005','App\\Database\\Migrations\\AddStatusToOrganizations','default','App',1768376017,11),
(19,'2026-01-15-000006','App\\Database\\Migrations\\CreateDealsTables','default','App',1768439721,12),
(21,'2026-01-15-000007','App\\Database\\Migrations\\CreateContactsTable','default','App',1768440786,13),
(22,'2026-01-16-210001','App\\Database\\Migrations\\DropOrganizationPlanSubscriptionsTable','default','App',1768572350,14),
(23,'2026-01-16-220001','App\\Database\\Migrations\\CreateModuleSettingsTable','default','App',1768573190,15);
/*!40000 ALTER TABLE `migrations` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `module_settings`
--
DROP TABLE IF EXISTS `module_settings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `module_settings` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`module_code` varchar(50) NOT NULL,
`name` varchar(100) NOT NULL,
`description` varchar(255) NOT NULL,
`price_monthly` int(11) NOT NULL DEFAULT 0,
`price_yearly` int(11) NOT NULL DEFAULT 0,
`trial_days` int(11) NOT NULL DEFAULT 0,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `module_code` (`module_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `module_settings`
--
LOCK TABLES `module_settings` WRITE;
/*!40000 ALTER TABLE `module_settings` DISABLE KEYS */;
set autocommit=0;
/*!40000 ALTER TABLE `module_settings` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `organization_subscriptions`
--
DROP TABLE IF EXISTS `organization_subscriptions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `organization_subscriptions` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`module_code` varchar(50) NOT NULL,
`status` enum('trial','active','expired','cancelled') NOT NULL DEFAULT 'trial',
`trial_ends_at` datetime DEFAULT NULL COMMENT 'Дата окончания триального периода',
`expires_at` datetime DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `organization_id_module_code` (`organization_id`,`module_code`),
CONSTRAINT `organization_subscriptions_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `organization_subscriptions`
--
LOCK TABLES `organization_subscriptions` WRITE;
/*!40000 ALTER TABLE `organization_subscriptions` DISABLE KEYS */;
set autocommit=0;
/*!40000 ALTER TABLE `organization_subscriptions` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `organization_users`
--
DROP TABLE IF EXISTS `organization_users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `organization_users` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`user_id` int(11) unsigned NOT NULL,
`role` enum('owner','admin','manager','guest') NOT NULL DEFAULT 'manager',
`invite_token` varchar(64) DEFAULT NULL,
`invited_by` int(10) unsigned DEFAULT NULL,
`invited_at` datetime DEFAULT NULL,
`invite_expires_at` datetime DEFAULT NULL,
`status` enum('active','pending','invited','blocked') NOT NULL DEFAULT 'pending',
`joined_at` datetime DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `organization_id_user_id` (`organization_id`,`user_id`),
KEY `organization_users_user_id_foreign` (`user_id`),
KEY `idx_org_users_token` (`invite_token`),
CONSTRAINT `organization_users_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `organization_users_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `organization_users`
--
LOCK TABLES `organization_users` WRITE;
/*!40000 ALTER TABLE `organization_users` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `organization_users` VALUES
(12,12,11,'owner',NULL,NULL,NULL,NULL,'active','2026-01-08 12:48:27',NULL),
(13,13,11,'owner',NULL,NULL,NULL,NULL,'active','2026-01-08 15:29:08',NULL);
/*!40000 ALTER TABLE `organization_users` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `organizations`
--
DROP TABLE IF EXISTS `organizations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `organizations` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`owner_id` int(11) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`type` enum('business','personal') NOT NULL DEFAULT 'business',
`logo` varchar(255) DEFAULT NULL,
`requisites` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`requisites`)),
`trial_ends_at` datetime DEFAULT NULL,
`settings` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`settings`)),
`status` enum('active','blocked') NOT NULL DEFAULT 'active',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `organizations_owner_id_foreign` (`owner_id`),
CONSTRAINT `organizations_owner_id_foreign` FOREIGN KEY (`owner_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `organizations`
--
LOCK TABLES `organizations` WRITE;
/*!40000 ALTER TABLE `organizations` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `organizations` VALUES
(12,11,'Личное пространство','personal',NULL,NULL,NULL,NULL,'active','2026-01-08 12:48:27','2026-01-08 12:48:27',NULL),
(13,11,'Редька','business',NULL,'{\"inn\":\"1112223334\",\"ogrn\":\"1231231231230\",\"kpp\":\"\",\"legal_address\":\"\\u041f\\u0438\\u0442\\u0435\\u0440, \\u041c\\u043e\\u0439\\u043a\\u0430 13\",\"actual_address\":\"\",\"phone\":\"\",\"email\":\"\",\"website\":\"\",\"bank_name\":\"\",\"bank_bik\":\"\",\"checking_account\":\"\",\"correspondent_account\":\"\"}',NULL,'[]','active','2026-01-08 15:29:08','2026-01-14 07:34:02',NULL);
/*!40000 ALTER TABLE `organizations` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `organizations_clients`
--
DROP TABLE IF EXISTS `organizations_clients`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `organizations_clients` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`organization_id` int(11) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(50) DEFAULT NULL,
`notes` text DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `organization_id` (`organization_id`),
CONSTRAINT `organizations_clients_organization_id_foreign` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `organizations_clients`
--
LOCK TABLES `organizations_clients` WRITE;
/*!40000 ALTER TABLE `organizations_clients` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `organizations_clients` VALUES
(1,12,'РогаКопыта','','','Текст','2026-01-08 13:40:47','2026-01-11 15:52:48',NULL),
(2,12,'Третий','ttt@ttt.com','','','2026-01-08 14:15:49','2026-01-08 14:15:49',NULL),
(3,12,'ыва','ddd@ddd.com','','','2026-01-08 21:34:38','2026-01-08 21:34:38',NULL),
(4,12,'ппп','','','','2026-01-12 01:22:38','2026-01-12 01:22:38',NULL),
(5,12,'ккк','','','','2026-01-12 01:22:43','2026-01-12 01:22:43',NULL),
(6,12,'еее','','','','2026-01-12 01:22:49','2026-01-12 01:22:49',NULL),
(7,12,'ннн','','','','2026-01-12 01:22:53','2026-01-12 01:22:53',NULL),
(8,12,'ггг','test3@test.com','','Вот такие вот заметки ','2026-01-12 01:22:56','2026-01-15 03:51:00',NULL),
(9,12,'шшш','','','','2026-01-12 01:22:59','2026-01-12 01:22:59',NULL),
(10,12,'щщщ','','','','2026-01-12 01:23:04','2026-01-12 01:23:04',NULL),
(11,12,'ффф','','','','2026-01-12 01:23:08','2026-01-12 01:23:08',NULL),
(12,13,'Супер','','','','2026-01-12 02:56:33','2026-01-12 02:56:33',NULL),
(13,13,'Супер222','','','','2026-01-12 09:04:04','2026-01-12 09:04:16',NULL);
/*!40000 ALTER TABLE `organizations_clients` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `plans`
--
DROP TABLE IF EXISTS `plans`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `plans` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`description` text DEFAULT NULL,
`price` decimal(10,2) NOT NULL DEFAULT 0.00,
`currency` varchar(3) NOT NULL DEFAULT 'RUB',
`billing_period` enum('monthly','yearly','quarterly') NOT NULL DEFAULT 'monthly',
`max_users` int(11) NOT NULL DEFAULT 5,
`max_clients` int(11) NOT NULL DEFAULT 100,
`max_storage` int(11) NOT NULL DEFAULT 10,
`features` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`features`)),
`is_active` tinyint(4) NOT NULL DEFAULT 1,
`is_default` tinyint(4) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `plans`
--
LOCK TABLES `plans` WRITE;
/*!40000 ALTER TABLE `plans` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `plans` VALUES
(1,'Бесплатный','Базовый тариф для небольших команд',0.00,'RUB','monthly',3,50,5,'[\"\\u0411\\u0430\\u0437\\u043e\\u0432\\u044b\\u0435 \\u043c\\u043e\\u0434\\u0443\\u043b\\u0438\",\"Email \\u043f\\u043e\\u0434\\u0434\\u0435\\u0440\\u0436\\u043a\\u0430\",\"\\u042d\\u043a\\u0441\\u043f\\u043e\\u0440\\u0442 \\u0432 CSV\"]',1,1,'2026-01-13 15:18:51',NULL),
(2,'Старт','Тариф для растущих компаний',990.00,'RUB','monthly',10,500,50,'[\"\\u0412\\u0441\\u0435 \\u043c\\u043e\\u0434\\u0443\\u043b\\u0438\",\"\\u041f\\u0440\\u0438\\u043e\\u0440\\u0438\\u0442\\u0435\\u0442\\u043d\\u0430\\u044f \\u043f\\u043e\\u0434\\u0434\\u0435\\u0440\\u0436\\u043a\\u0430\",\"\\u042d\\u043a\\u0441\\u043f\\u043e\\u0440\\u0442 \\u0432 PDF \\u0438 Excel\",\"API \\u0434\\u043e\\u0441\\u0442\\u0443\\u043f\"]',1,0,'2026-01-13 15:18:51',NULL),
(3,'Бизнес','Полный функционал для крупных компаний',4990.00,'RUB','monthly',50,5000,500,'[\"\\u0412\\u0441\\u0435 \\u043c\\u043e\\u0434\\u0443\\u043b\\u0438\",\"\\u041f\\u0435\\u0440\\u0441\\u043e\\u043d\\u0430\\u043b\\u044c\\u043d\\u044b\\u0439 \\u043c\\u0435\\u043d\\u0435\\u0434\\u0436\\u0435\\u0440\",\"\\u042d\\u043a\\u0441\\u043f\\u043e\\u0440\\u0442 \\u0432 PDF \\u0438 Excel\",\"\\u041f\\u043e\\u043b\\u043d\\u044b\\u0439 API \\u0434\\u043e\\u0441\\u0442\\u0443\\u043f\",\"\\u0418\\u043d\\u0442\\u0435\\u0433\\u0440\\u0430\\u0446\\u0438\\u0438\",\"\\u0411\\u0440\\u0435\\u043d\\u0434\\u0438\\u043d\\u0433\"]',1,0,'2026-01-13 15:18:51',NULL);
/*!40000 ALTER TABLE `plans` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `remember_tokens`
--
DROP TABLE IF EXISTS `remember_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `remember_tokens` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL,
`selector` varchar(64) NOT NULL,
`token_hash` varchar(128) NOT NULL,
`expires_at` datetime NOT NULL,
`created_at` datetime DEFAULT NULL,
`user_agent` varchar(500) DEFAULT NULL,
`ip_address` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `selector` (`selector`),
KEY `expires_at` (`expires_at`),
CONSTRAINT `remember_tokens_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `remember_tokens`
--
LOCK TABLES `remember_tokens` WRITE;
/*!40000 ALTER TABLE `remember_tokens` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `remember_tokens` VALUES
(4,11,'508781e7a1d7bfb7b15c4ed38a71e205','dc94c9825eed3efd9d960fdefb9e9048203a2b837d8168e1c78ce1cc3e38f90d','2026-02-13 07:17:58','2026-01-14 07:17:58','Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0','91.234.172.241');
/*!40000 ALTER TABLE `remember_tokens` ENABLE KEYS */;
UNLOCK TABLES;
commit;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `users` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`system_role` enum('user','admin','superadmin') DEFAULT 'user',
`name` varchar(100) NOT NULL,
`phone` varchar(20) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`verification_token` varchar(255) DEFAULT NULL COMMENT 'Токен для подтверждения email',
`token_expires_at` datetime DEFAULT NULL,
`email_verified` tinyint(1) DEFAULT 0 COMMENT 'Статус подтверждения email (0 - не подтвержден, 1 - подтвержден)',
`verified_at` datetime DEFAULT NULL COMMENT 'Дата и время подтверждения email',
`reset_token` varchar(255) DEFAULT NULL,
`reset_expires_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `users`
--
LOCK TABLES `users` WRITE;
/*!40000 ALTER TABLE `users` DISABLE KEYS */;
set autocommit=0;
INSERT INTO `users` VALUES
(11,'mirvtop@yandex.ru','$2y$12$lPkp/tIPEgltYiyIw5ZWuukNPrMdGzfzerRG1oSXbKuBSpVbBmhhu','superadmin','Mirivlad',NULL,'avatar_11_1768273671.jpg','2026-01-08 12:48:27','2026-01-13 15:29:21',NULL,NULL,1,'2026-01-08 12:48:38',NULL,NULL);
/*!40000 ALTER TABLE `users` ENABLE KEYS */;
UNLOCK TABLES;
commit;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
-- Dump completed on 2026-01-16 17:43:45

View File

@ -98,21 +98,61 @@ document.addEventListener("DOMContentLoaded", function () {
'use strict'; 'use strict';
/** /**
* Получение CSRF токена из мета-тега * Получение CSRF токена из нескольких источников (с fallback)
*/ */
function getCsrfToken() { function getCsrfToken() {
// 1. Пробуем из мета-тега
const meta = document.querySelector('meta[name="csrf-token"]'); const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : ''; if (meta && meta.getAttribute('content')) {
return meta.getAttribute('content');
}
// 2. Пробуем из data-атрибута body
if (document.body && document.body.dataset.csrfToken) {
return document.body.dataset.csrfToken;
}
// 3. Пробуем из скрытого input на странице
const csrfInput = document.querySelector('input[name*="csrf"]');
if (csrfInput && csrfInput.value) {
return csrfInput.value;
}
// 4. Пробуем из cookie - CodeIgniter 4 использует 'csrf_cookie_name'
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrf_cookie_name' && value) {
return decodeURIComponent(value);
}
}
console.warn('CSRF token not found anywhere');
return '';
} }
/** /**
* Обновление CSRF токена в мета-теге * Обновление CSRF токена во всех источниках
*/ */
function updateCsrfToken(token, hash) { function updateCsrfToken(token, hash) {
// Обновляем мета-теги
const tokenMeta = document.querySelector('meta[name="csrf-token"]'); const tokenMeta = document.querySelector('meta[name="csrf-token"]');
const hashMeta = document.querySelector('meta[name="csrf-hash"]'); const hashMeta = document.querySelector('meta[name="csrf-hash"]');
if (tokenMeta) tokenMeta.setAttribute('content', token); if (tokenMeta) tokenMeta.setAttribute('content', token);
if (hashMeta) hashMeta.setAttribute('content', hash); if (hashMeta) hashMeta.setAttribute('content', hash);
// Обновляем data-атрибут body
if (document.body) {
document.body.dataset.csrfToken = token;
}
// Обновляем все input поля с CSRF
document.querySelectorAll('input[name*="csrf"]').forEach(input => {
input.value = token;
});
// Обновляем cookie
document.cookie = 'csrf_cookie_name=' + encodeURIComponent(token) + '; path=/; SameSite=Lax';
} }
// Перехват fetch() // Перехват fetch()

View File

@ -0,0 +1,399 @@
/**
* Inline-редактирование контактов в карточке клиента
*
* Использование:
* <div id="contacts-container"
* data-client-id="123"
* data-api-url="/crm/contacts"
* data-csrf-token="...">
* </div>
*/
class ContactsManager {
constructor(container) {
this.container = container;
this.clientId = container.dataset.clientId;
this.apiUrl = container.dataset.apiUrl;
this.csrfToken = container.dataset.csrfToken;
this.contacts = [];
this.init();
}
init() {
this.loadContacts();
}
/**
* Загрузить список контактов
*/
async loadContacts() {
try {
const response = await fetch(`${this.apiUrl}/list/${this.clientId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
const data = await response.json();
if (data.success) {
this.contacts = data.items || [];
this.render();
} else {
this.showError(data.message || 'Ошибка загрузки контактов');
}
} catch (error) {
console.error('Ошибка загрузки контактов:', error);
this.showError('Ошибка соединения с сервером');
}
}
/**
* Отобразить таблицу контактов
*/
render() {
// Обновляем счётчик
const countBadge = document.getElementById('contacts-count');
if (countBadge) {
countBadge.textContent = this.contacts.length;
}
// Формируем HTML
const emptyState = `
<div class="text-center py-4 text-muted">
<i class="fa-solid fa-users fa-3x mb-3 text-muted opacity-50"></i>
<p>Контактов пока нет</p>
<button type="button" class="btn btn-primary btn-sm" onclick="contactsManager.addNew()">
<i class="fa-solid fa-plus me-1"></i>Добавить контакт
</button>
</div>
`;
const tableHtml = `
<div class="d-flex justify-content-end mb-3">
<button type="button" class="btn btn-primary btn-sm" onclick="contactsManager.addNew()">
<i class="fa-solid fa-plus me-1"></i>Добавить контакт
</button>
</div>
<div class="table-responsive">
<table class="table table-hover" id="contacts-table">
<thead class="bg-light">
<tr>
<th style="width: 30%;">Имя</th>
<th style="width: 25%;">Email</th>
<th style="width: 25%;">Телефон</th>
<th style="width: 20%;">Должность</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody id="contacts-tbody">
${this.contacts.length > 0
? this.contacts.map(contact => this.renderRow(contact)).join('')
: `<tr><td colspan="5" class="text-center py-4 text-muted">Нет контактов</td></tr>`
}
</tbody>
</table>
</div>
`;
this.container.innerHTML = this.contacts.length > 0 ? tableHtml : emptyState;
}
/**
* Отобразить одну строку контакта
*/
renderRow(contact) {
return `
<tr data-id="${contact.id}" class="contact-row">
<td>
<span class="contact-display contact-name">${this.escapeHtml(contact.name)}</span>
<input type="text" class="form-control form-control-sm contact-edit contact-name-input"
value="${this.escapeHtml(contact.name)}" style="display: none;" placeholder="Имя">
</td>
<td>
<span class="contact-display contact-email">${this.escapeHtml(contact.email || '—')}</span>
<input type="email" class="form-control form-control-sm contact-edit contact-email-input"
value="${this.escapeHtml(contact.email || '')}" style="display: none;" placeholder="Email">
</td>
<td>
<span class="contact-display contact-phone">${this.escapeHtml(contact.phone || '—')}</span>
<input type="text" class="form-control form-control-sm contact-edit contact-phone-input"
value="${this.escapeHtml(contact.phone || '')}" style="display: none;" placeholder="Телефон">
</td>
<td>
<span class="contact-display contact-position">${this.escapeHtml(contact.position || '—')}</span>
<input type="text" class="form-control form-control-sm contact-edit contact-position-input"
value="${this.escapeHtml(contact.position || '')}" style="display: none;" placeholder="Должность">
</td>
<td class="text-end">
<div class="contact-actions">
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="contactsManager.edit(${contact.id})" title="Редактировать">
<i class="fa-solid fa-pen"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="contactsManager.remove(${contact.id})" title="Удалить">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div class="edit-actions" style="display: none;">
<button type="button" class="btn btn-outline-success btn-sm"
onclick="contactsManager.save(${contact.id})" title="Сохранить">
<i class="fa-solid fa-check"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="contactsManager.cancel(${contact.id})" title="Отмена">
<i class="fa-solid fa-times"></i>
</button>
</div>
</td>
</tr>
`;
}
/**
* Добавить новый контакт
*/
addNew() {
const newId = 'new_' + Date.now();
const emptyRow = {
id: newId,
name: '',
email: '',
phone: '',
position: '',
};
this.contacts.push(emptyRow);
this.render();
// Переключаем новую строку в режим редактирования
this.edit(newId);
}
/**
* Начать редактирование контакта
*/
edit(contactId) {
const row = this.container.querySelector(`tr[data-id="${contactId}"]`);
if (!row) return;
// Показываем инпуты, скрываем текст
row.querySelectorAll('.contact-display').forEach(el => el.style.display = 'none');
row.querySelectorAll('.contact-edit').forEach(el => el.style.display = 'block');
// Скрываем кнопки действий, показываем кнопки редактирования
row.querySelector('.contact-actions').style.display = 'none';
row.querySelector('.edit-actions').style.display = 'inline-flex';
// Фокус на поле имени
const nameInput = row.querySelector('.contact-name-input');
if (nameInput) {
nameInput.focus();
}
}
/**
* Сохранить изменения контакта
*/
async save(contactId) {
const row = this.container.querySelector(`tr[data-id="${contactId}"]`);
if (!row) return;
const data = {
customer_id: this.clientId,
name: row.querySelector('.contact-name-input').value.trim(),
email: row.querySelector('.contact-email-input').value.trim(),
phone: row.querySelector('.contact-phone-input').value.trim(),
position: row.querySelector('.contact-position-input').value.trim(),
};
// Валидация
if (!data.name) {
this.showError('Имя контакта обязательно');
row.querySelector('.contact-name-input').focus();
return;
}
try {
let response;
if (contactId.toString().startsWith('new_')) {
// Создание нового
response = await fetch(`${this.apiUrl}/store`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
} else {
// Обновление существующего
response = await fetch(`${this.apiUrl}/update/${contactId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
}
const result = await response.json();
if (result.success) {
// Обновляем локальный массив
const index = this.contacts.findIndex(c => c.id === contactId);
if (index !== -1) {
if (result.item) {
// Обновляем с реальным ID от сервера
this.contacts[index] = { ...data, id: result.item.id };
} else {
this.contacts[index] = data;
}
}
this.render();
this.showSuccess(result.message || 'Сохранено');
} else {
this.showError(result.message || 'Ошибка сохранения');
}
} catch (error) {
console.error('Ошибка сохранения контакта:', error);
this.showError('Ошибка соединения с сервером');
}
}
/**
* Отменить редактирование
*/
cancel(contactId) {
if (contactId.toString().startsWith('new_')) {
// Удаляем новую строку
this.contacts = this.contacts.filter(c => c.id !== contactId);
this.render();
} else {
// Перезагружаем данные
this.loadContacts();
}
}
/**
* Удалить контакт
*/
async remove(contactId) {
if (!confirm('Удалить контакт?')) {
return;
}
try {
const response = await fetch(`${this.apiUrl}/delete/${contactId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
const result = await response.json();
if (result.success) {
this.contacts = this.contacts.filter(c => c.id !== contactId);
this.render();
this.showSuccess(result.message || 'Контакт удалён');
} else {
this.showError(result.message || 'Ошибка удаления');
}
} catch (error) {
console.error('Ошибка удаления контакта:', error);
this.showError('Ошибка соединения с сервером');
}
}
/**
* Показать сообщение об ошибке
*/
showError(message) {
this.showNotification(message, 'danger');
}
/**
* Показать сообщение об успехе
*/
showSuccess(message) {
this.showNotification(message, 'success');
}
/**
* Показать уведомление
*/
showNotification(message, type) {
// Удаляем предыдущие уведомления
const existing = this.container.querySelector('.contacts-alert');
if (existing) existing.remove();
const alert = document.createElement('div');
alert.className = `contacts-alert alert alert-${type} alert-dismissible fade show mt-3`;
alert.role = 'alert';
alert.innerHTML = `
${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
this.container.insertBefore(alert, this.container.firstChild);
// Автоудаление через 3 секунды
setTimeout(() => {
if (alert.parentNode) {
alert.remove();
}
}, 3000);
}
/**
* Экранирование HTML
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('contacts-container');
if (container) {
window.contactsManager = new ContactsManager(container);
}
});
// Обработка Enter в полях редактирования
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const target = e.target;
if (target.classList.contains('contact-edit')) {
e.preventDefault();
const row = target.closest('tr');
if (row) {
const contactId = parseInt(row.dataset.id) || row.dataset.id;
window.contactsManager.save(contactId);
}
}
}
if (e.key === 'Escape') {
const target = e.target;
if (target.classList.contains('contact-edit')) {
e.preventDefault();
const row = target.closest('tr');
if (row) {
const contactId = parseInt(row.dataset.id) || row.dataset.id;
window.contactsManager.cancel(contactId);
}
}
}
});