Many fixes
This commit is contained in:
parent
b810a17649
commit
77f76c8c28
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ class Filters extends BaseFilters
|
|||
'org' => \App\Filters\OrganizationFilter::class,
|
||||
'role' => \App\Filters\RoleFilter::class,
|
||||
'auth' => \App\Filters\AuthFilter::class,
|
||||
'subscription' => \App\Filters\ModuleSubscriptionFilter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -88,26 +88,38 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
|
|||
require_once APPPATH . 'Modules/Clients/Config/Routes.php';
|
||||
require_once APPPATH . 'Modules/CRM/Config/Routes.php';
|
||||
});
|
||||
|
||||
# =============================================================================
|
||||
# СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin)
|
||||
# =============================================================================
|
||||
$routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) {
|
||||
$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/table', 'Superadmin::organizationsTable');
|
||||
$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/unblock/(:num)', 'Superadmin::unblockOrganization/$1');
|
||||
$routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1');
|
||||
|
||||
# Управление пользователями
|
||||
$routes->get('users', 'Superadmin::users');
|
||||
$routes->get('users/table', 'Superadmin::usersTable');
|
||||
$routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1');
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class Services extends BaseService
|
|||
return static::getSharedInstance('moduleSubscription');
|
||||
}
|
||||
|
||||
return new ModuleSubscriptionService();
|
||||
return new \App\Services\ModuleSubscriptionService();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@
|
|||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\OrganizationModel;
|
||||
use App\Models\OrganizationSubscriptionModel;
|
||||
use App\Models\OrganizationUserModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\ModuleSubscriptionService;
|
||||
|
||||
/**
|
||||
* Superadmin - Панель суперадмина
|
||||
*
|
||||
* Управление системой: тарифы, организации, пользователи.
|
||||
* Управление системой: модули, подписки организаций, пользователи.
|
||||
*/
|
||||
class Superadmin extends BaseController
|
||||
{
|
||||
protected $organizationModel;
|
||||
protected $userModel;
|
||||
protected $planModel;
|
||||
protected $subscriptionModel;
|
||||
protected ?OrganizationUserModel $orgUserModel = null;
|
||||
protected ModuleSubscriptionService $subscriptionService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->organizationModel = new \App\Models\OrganizationModel();
|
||||
$this->userModel = new \App\Models\UserModel();
|
||||
$this->planModel = new \App\Models\PlanModel();
|
||||
$this->organizationModel = new OrganizationModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->subscriptionModel = new OrganizationSubscriptionModel();
|
||||
$this->subscriptionService = service('moduleSubscription');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -25,20 +34,17 @@ class Superadmin extends BaseController
|
|||
*/
|
||||
public function index()
|
||||
{
|
||||
// Статистика
|
||||
$stats = [
|
||||
'total_users' => $this->userModel->countAll(),
|
||||
'total_orgs' => $this->organizationModel->countAll(),
|
||||
'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(),
|
||||
'total_plans' => $this->planModel->countAll(),
|
||||
'total_modules' => count($this->subscriptionService->getAllModules()),
|
||||
];
|
||||
|
||||
// Последние организации
|
||||
$recentOrgs = $this->organizationModel
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll(5);
|
||||
|
||||
// Последние пользователи
|
||||
$recentUsers = $this->userModel
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll(5);
|
||||
|
|
@ -47,135 +53,204 @@ class Superadmin extends BaseController
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// УПРАВЛЕНИЕ ТАРИФАМИ
|
||||
// УПРАВЛЕНИЕ МОДУЛЯМИ
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Список тарифов
|
||||
* Список модулей с ценами
|
||||
*/
|
||||
public function plans()
|
||||
public function modules()
|
||||
{
|
||||
$plans = $this->planModel->findAll();
|
||||
// Декодируем features для Twig
|
||||
foreach ($plans as &$plan) {
|
||||
$plan['features'] = json_decode($plan['features'] ?? '[]', true);
|
||||
}
|
||||
$modules = $this->subscriptionService->getAllModules();
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* Сохранение тарифа
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!$moduleCode || !$config) {
|
||||
return redirect()->back()->with('error', 'Модуль не найден');
|
||||
}
|
||||
|
||||
$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,
|
||||
$this->subscriptionService->saveModuleSettings(
|
||||
$moduleCode,
|
||||
$this->request->getPost('name'),
|
||||
$this->request->getPost('description'),
|
||||
(int) $this->request->getPost('price_monthly'),
|
||||
(int) $this->request->getPost('price_yearly'),
|
||||
(int) $this->request->getPost('trial_days')
|
||||
);
|
||||
|
||||
return redirect()->to('/superadmin/modules')->with('success', 'Модуль успешно обновлён');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// УПРАВЛЕНИЕ ПОДПИСКАМИ
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы подписок
|
||||
*/
|
||||
protected function getSubscriptionsTableConfig(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'subscriptions-table',
|
||||
'url' => '/superadmin/subscriptions/table',
|
||||
'model' => $this->subscriptionModel,
|
||||
'columns' => [
|
||||
'id' => ['label' => 'ID', 'width' => '60px'],
|
||||
'organization_name' => ['label' => 'Организация'],
|
||||
'module_code' => ['label' => 'Модуль', 'width' => '100px'],
|
||||
'status' => ['label' => 'Статус', 'width' => '100px'],
|
||||
'expires_at' => ['label' => 'Истекает', 'width' => '120px'],
|
||||
'created_at' => ['label' => 'Создана', 'width' => '120px'],
|
||||
],
|
||||
'searchable' => ['id', 'organization_name', 'module_code'],
|
||||
'sortable' => ['id', 'created_at', 'expires_at'],
|
||||
'defaultSort' => 'created_at',
|
||||
'order' => 'desc',
|
||||
'fieldMap' => [
|
||||
'organization_name' => 'organizations.name',
|
||||
'id' => 'organization_subscriptions.id',
|
||||
'module_code' => 'organization_subscriptions.module_code',
|
||||
'status' => 'organization_subscriptions.status',
|
||||
'expires_at' => 'organization_subscriptions.expires_at',
|
||||
'created_at' => 'organization_subscriptions.created_at',
|
||||
],
|
||||
'scope' => function ($builder) {
|
||||
$builder->from('organization_subscriptions')
|
||||
->select('organization_subscriptions.*, organizations.name as organization_name')
|
||||
->join('organizations', 'organizations.id = organization_subscriptions.organization_id');
|
||||
},
|
||||
'actions' => ['label' => 'Действия', 'width' => '100px'],
|
||||
'actionsConfig' => [
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/subscriptions/delete/{id}',
|
||||
'icon' => 'fa-solid fa-trash',
|
||||
'class' => 'btn-outline-danger',
|
||||
'title' => 'Удалить',
|
||||
'confirm' => 'Удалить подписку?',
|
||||
],
|
||||
],
|
||||
'emptyMessage' => 'Подписки не найдены',
|
||||
'emptyIcon' => 'fa-solid fa-credit-card',
|
||||
];
|
||||
|
||||
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);
|
||||
if (!$plan) {
|
||||
throw new \CodeIgniter\Exceptions\PageNotFoundException('Тариф не найден');
|
||||
}
|
||||
$config = $this->getSubscriptionsTableConfig();
|
||||
$tableHtml = $this->renderTable($config);
|
||||
$modules = $this->subscriptionService->getAllModules();
|
||||
$organizations = $this->organizationModel->findAll();
|
||||
|
||||
// Декодируем features для отображения в textarea
|
||||
$plan['features'] = json_decode($plan['features'] ?? '[]', true);
|
||||
|
||||
return $this->renderTwig('superadmin/plans/edit', compact('plan'));
|
||||
return $this->renderTwig('superadmin/subscriptions/index', [
|
||||
'tableHtml' => $tableHtml,
|
||||
'config' => $config,
|
||||
'modules' => $modules,
|
||||
'organizations' => $organizations,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление тарифа
|
||||
* AJAX таблица подписок
|
||||
*/
|
||||
public function updatePlan($id)
|
||||
public function subscriptionsTable()
|
||||
{
|
||||
// Получаем 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 = [
|
||||
'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', 'Тариф успешно обновлён');
|
||||
return parent::table($this->getSubscriptionsTableConfig(), '/superadmin/subscriptions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление тарифа
|
||||
* Поиск организаций для autocomplete
|
||||
*/
|
||||
public function deletePlan($id)
|
||||
public function searchOrganizations()
|
||||
{
|
||||
if (!$this->planModel->delete($id)) {
|
||||
return redirect()->to('/superadmin/plans')->with('error', 'Ошибка удаления тарифа');
|
||||
$query = $this->request->getGet('q') ?? '';
|
||||
$limit = 20;
|
||||
|
||||
$builder = $this->organizationModel->db()->table('organizations');
|
||||
|
||||
$builder->select('organizations.*, users.email as owner_email')
|
||||
->join('organization_users', 'organization_users.organization_id = organizations.id AND organization_users.role = "owner"')
|
||||
->join('users', 'users.id = organization_users.user_id')
|
||||
->groupStart()
|
||||
->like('organizations.name', $query)
|
||||
->orLike('organizations.id', $query)
|
||||
->orLike('users.email', $query)
|
||||
->groupEnd()
|
||||
->limit($limit);
|
||||
|
||||
$results = [];
|
||||
foreach ($builder->get()->getResultArray() as $org) {
|
||||
$results[] = [
|
||||
'id' => $org['id'],
|
||||
'text' => $org['name'] . ' (ID: ' . $org['id'] . ') — ' . $org['owner_email'],
|
||||
];
|
||||
}
|
||||
|
||||
return 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' => [
|
||||
'id' => ['label' => 'ID', 'width' => '60px'],
|
||||
'name' => ['label' => 'Название'],
|
||||
'owner_login' => ['label' => 'Владелец', 'width' => '150px'],
|
||||
'type' => ['label' => 'Тип', 'width' => '100px'],
|
||||
'user_count' => ['label' => 'Пользователей', 'width' => '100px'],
|
||||
'status' => ['label' => 'Статус', 'width' => '120px'],
|
||||
'created_at' => ['label' => 'Дата', 'width' => '100px'],
|
||||
],
|
||||
'searchable' => ['name', 'id'],
|
||||
'searchable' => ['name', 'id', 'owner_login'],
|
||||
'sortable' => ['id', 'name', 'created_at'],
|
||||
'defaultSort' => 'created_at',
|
||||
'order' => 'desc',
|
||||
'scope' => function ($builder) {
|
||||
// JOIN с подсчётом пользователей организации
|
||||
$builder->from('organizations')
|
||||
->select('organizations.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count');
|
||||
$builder->resetQuery();
|
||||
$builder->select('organizations.*,
|
||||
(SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count,
|
||||
owner_users.email as owner_login')
|
||||
->join('organization_users as ou', 'ou.organization_id = organizations.id AND ou.role = "owner"')
|
||||
->join('users as owner_users', 'owner_users.id = ou.user_id', 'left');
|
||||
},
|
||||
'actions' => ['label' => 'Действия', 'width' => '140px'],
|
||||
'actionsConfig' => [
|
||||
|
|
@ -263,7 +342,7 @@ class Superadmin extends BaseController
|
|||
}
|
||||
|
||||
/**
|
||||
* Просмотр организации
|
||||
* Просмотр организации с её подписками
|
||||
*/
|
||||
public function viewOrganization($id)
|
||||
{
|
||||
|
|
@ -272,23 +351,49 @@ class Superadmin extends BaseController
|
|||
throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена');
|
||||
}
|
||||
|
||||
// Пользователи организации
|
||||
$orgUserModel = new \App\Models\OrganizationUserModel();
|
||||
$users = $orgUserModel->getOrganizationUsers($id);
|
||||
$users = $this->getOrgUserModel()->getOrganizationUsers($id);
|
||||
$subscriptions = $this->subscriptionService->getOrganizationSubscriptions($id);
|
||||
$allModules = $this->subscriptionService->getAllModules();
|
||||
|
||||
// Список тарифов для выбора
|
||||
$plans = $this->planModel->where('is_active', 1)->findAll();
|
||||
return $this->renderTwig('superadmin/organizations/view', compact(
|
||||
'organization',
|
||||
'users',
|
||||
'subscriptions',
|
||||
'allModules'
|
||||
));
|
||||
}
|
||||
|
||||
// Текущая подписка организации из таблицы связей
|
||||
$db = \Config\Database::connect();
|
||||
$subscriptionTable = $db->table('organization_plan_subscriptions');
|
||||
$currentSubscription = $subscriptionTable
|
||||
->where('organization_id', $id)
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
/**
|
||||
* Быстрое добавление подписки организации из просмотра организации
|
||||
*/
|
||||
public function addOrganizationSubscription($organizationId)
|
||||
{
|
||||
$moduleCode = $this->request->getPost('module_code');
|
||||
$durationDays = (int) $this->request->getPost('duration_days');
|
||||
$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)
|
||||
{
|
||||
// Полное удаление без soft delete
|
||||
$this->organizationModel->delete($id, true);
|
||||
|
||||
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',
|
||||
'order' => 'desc',
|
||||
'scope' => function ($builder) {
|
||||
// JOIN с подсчётом организаций пользователя
|
||||
$builder->from('users')
|
||||
->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)
|
||||
{
|
||||
// Полное удаление без soft delete
|
||||
$this->userModel->delete($id, true);
|
||||
|
||||
return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён');
|
||||
|
|
@ -513,7 +559,6 @@ class Superadmin extends BaseController
|
|||
*/
|
||||
public function statistics()
|
||||
{
|
||||
// Статистика по дням (последние 30 дней)
|
||||
$dailyStats = [];
|
||||
for ($i = 29; $i >= 0; $i--) {
|
||||
$date = date('Y-m-d', strtotime("-{$i} days"));
|
||||
|
|
@ -524,35 +569,8 @@ class Superadmin extends BaseController
|
|||
];
|
||||
}
|
||||
|
||||
// Статистика по тарифам (через таблицу подписок)
|
||||
$planStats = [];
|
||||
$plans = $this->planModel->where('is_active', 1)->findAll();
|
||||
$moduleStats = $this->subscriptionService->getModuleStats();
|
||||
|
||||
// Проверяем существование таблицы подписок
|
||||
$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'));
|
||||
return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'moduleStats'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ namespace App\Database\Migrations;
|
|||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Migration для создания таблицы подписок организаций на тарифы
|
||||
* Использует ту же структуру что и organization_subscriptions для модулей
|
||||
* Миграция для удаления таблицы подписок на тарифы
|
||||
*
|
||||
* Плановая система тарифов заменена на модульную систему подписок.
|
||||
* Таблица organization_plan_subscriptions больше не используется.
|
||||
*/
|
||||
class CreateOrganizationPlanSubscriptionsTable extends Migration
|
||||
class DropOrganizationPlanSubscriptionsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->forge->dropTable('organization_plan_subscriptions', true);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
|
|
@ -53,18 +60,10 @@ class CreateOrganizationPlanSubscriptionsTable extends Migration
|
|||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
|
||||
// Организация не может иметь две активные подписки на один тариф одновременно
|
||||
$this->forge->addUniqueKey(['organization_id', 'plan_id']);
|
||||
|
||||
$this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE');
|
||||
$this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE');
|
||||
|
||||
$this->forge->createTable('organization_plan_subscriptions');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('organization_plan_subscriptions');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
// Ничего не делаем после запроса
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,10 @@ class TwigGlobalsExtension extends AbstractExtension
|
|||
new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('is_system_admin', [$this, 'isSystemAdmin'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('get_system_role', [$this, 'getSystemRole'], ['is_safe' => ['html']]),
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,25 @@
|
|||
|
||||
// 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
|
||||
$routes->get('/', 'DashboardController::index');
|
||||
|
||||
// Contacts
|
||||
$routes->get('contacts', 'ContactsController::index');
|
||||
$routes->get('contacts/table', 'ContactsController::contactsTable');
|
||||
$routes->get('contacts/create', 'ContactsController::create');
|
||||
$routes->post('contacts', 'ContactsController::store');
|
||||
$routes->get('contacts/(:num)/edit', 'ContactsController::edit/$1');
|
||||
$routes->post('contacts/(:num)', 'ContactsController::update/$1');
|
||||
$routes->get('contacts/(:num)/delete', 'ContactsController::destroy/$1');
|
||||
|
||||
// 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
|
||||
$routes->group('deals', static function ($routes) {
|
||||
|
|
|
|||
|
|
@ -17,24 +17,84 @@ class ContactsController extends BaseController
|
|||
$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()
|
||||
{
|
||||
$organizationId = $this->requireActiveOrg();
|
||||
|
||||
$contacts = $this->contactModel
|
||||
->where('organization_id', $organizationId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
$config = $this->getContactsTableConfig();
|
||||
$tableHtml = $this->renderTable($config);
|
||||
|
||||
return $this->renderTwig('@CRM/contacts/index', [
|
||||
'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', 'Контакт удалён');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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' => 'Контакт удалён',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class DealsController extends BaseController
|
|||
'amount' => [
|
||||
'label' => 'Сумма',
|
||||
'width' => '15%',
|
||||
'align' => 'text-end',
|
||||
|
||||
],
|
||||
'client_name' => [
|
||||
'label' => 'Клиент',
|
||||
|
|
@ -401,7 +401,14 @@ class DealsController extends BaseController
|
|||
|
||||
$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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@
|
|||
<form method="POST" action="{{ actionUrl }}">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
{% if contact is defined %}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-bold">Имя *</label>
|
||||
<input type="text"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||
<h1 class="h3 mb-0"><i class="fa-solid fa-users text-info me-2"></i> {{ title }}</h1>
|
||||
</div>
|
||||
<a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-2"></i>Добавить контакт
|
||||
|
|
@ -13,77 +17,50 @@
|
|||
</div>
|
||||
|
||||
{# Сообщения #}
|
||||
{% if success is defined %}
|
||||
{% if session.success %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<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>
|
||||
{{ 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 %}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
</a>
|
||||
</div>
|
||||
<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="d-flex align-items-center gap-3">
|
||||
<div class="bg-success bg-opacity-10 rounded p-3">
|
||||
|
|
|
|||
|
|
@ -45,31 +45,13 @@
|
|||
showNavigation: true,
|
||||
showLegend: true,
|
||||
legend: calendarLegend,
|
||||
eventComponent: '@Deals/calendar_event.twig'
|
||||
eventComponent: '@CRM/deals/calendar_event.twig'
|
||||
}) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<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 {
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@
|
|||
<form method="POST" action="{{ actionUrl }}">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
{% if deal is defined %}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label fw-bold">Название сделки *</label>
|
||||
<input type="text"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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">
|
||||
Всего: {{ items|length }} |
|
||||
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{ csrf_field()|raw }}
|
||||
{# Канбан доска #}
|
||||
{{ include('@components/kanban/kanban.twig', {
|
||||
columns: kanbanColumns,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
</div>
|
||||
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
|
||||
|
|
@ -137,7 +137,6 @@
|
|||
<div class="modal-content">
|
||||
<form id="editForm" method="POST">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Редактировать этап</h5>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,17 @@ namespace App\Modules\Clients\Controllers;
|
|||
use App\Controllers\BaseController;
|
||||
use App\Modules\Clients\Models\ClientModel;
|
||||
use App\Services\AccessService;
|
||||
use App\Services\ModuleSubscriptionService;
|
||||
|
||||
class Clients extends BaseController
|
||||
{
|
||||
protected ClientModel $clientModel;
|
||||
protected ModuleSubscriptionService $subscriptionService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clientModel = new ClientModel();
|
||||
$this->subscriptionService = service('moduleSubscription');
|
||||
}
|
||||
|
||||
public function index()
|
||||
|
|
@ -98,8 +101,9 @@ class Clients extends BaseController
|
|||
}
|
||||
|
||||
$data = [
|
||||
'title' => 'Добавить клиента',
|
||||
'client' => null,
|
||||
'title' => 'Добавить клиента',
|
||||
'client' => null,
|
||||
'crm_active' => $this->subscriptionService->isModuleActive('crm'),
|
||||
];
|
||||
|
||||
return $this->renderTwig('@Clients/form', $data);
|
||||
|
|
@ -154,8 +158,9 @@ class Clients extends BaseController
|
|||
}
|
||||
|
||||
$data = [
|
||||
'title' => 'Редактировать клиента',
|
||||
'client' => $client,
|
||||
'title' => 'Редактировать клиента',
|
||||
'client' => $client,
|
||||
'crm_active' => $this->subscriptionService->isModuleActive('crm'),
|
||||
];
|
||||
|
||||
return $this->renderTwig('@Clients/form', $data);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-lg-10">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
|
|
@ -19,55 +19,106 @@
|
|||
<div class="card-body">
|
||||
{{ 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>
|
||||
<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 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 ? 'Сохранить изменения' : 'Добавить клиента' }}
|
||||
{# Табы #}
|
||||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-main" type="button">
|
||||
<i class="fa-solid fa-building me-2"></i>Основное
|
||||
</button>
|
||||
</li>
|
||||
{% if crm_active %}
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-contacts" type="button">
|
||||
<i class="fa-solid fa-users me-2"></i>Контакты
|
||||
<span class="badge bg-primary ms-1" id="contacts-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{# Содержимое табов #}
|
||||
<div class="tab-content">
|
||||
{# Таб "Основное" #}
|
||||
<div class="tab-pane fade show active" id="tab-main" role="tabpanel">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-bold">Имя / Название *</label>
|
||||
<input type="text" name="name" id="name" class="form-control {{ errors.name ? 'is-invalid' : '' }}"
|
||||
value="{{ old.name ?? client.name ?? '' }}" required autofocus>
|
||||
{% if errors.name %}
|
||||
<div class="invalid-feedback">{{ errors.name }}</div>
|
||||
{% endif %}
|
||||
<div class="form-text">ФИО клиента или название компании</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" name="email" id="email" class="form-control {{ errors.email ? 'is-invalid' : '' }}"
|
||||
value="{{ old.email ?? client.email ?? '' }}">
|
||||
{% if errors.email %}
|
||||
<div class="invalid-feedback">{{ errors.email }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="phone" class="form-label">Телефон</label>
|
||||
<input type="tel" name="phone" id="phone" class="form-control {{ errors.phone ? 'is-invalid' : '' }}"
|
||||
value="{{ old.phone ?? client.phone ?? '' }}">
|
||||
{% if errors.phone %}
|
||||
<div class="invalid-feedback">{{ errors.phone }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label">Заметки</label>
|
||||
<textarea name="notes" id="notes" rows="4" class="form-control {{ errors.notes ? 'is-invalid' : '' }}"
|
||||
placeholder="Дополнительная информация о клиенте...">{{ old.notes ?? client.notes ?? '' }}</textarea>
|
||||
{% if errors.notes %}
|
||||
<div class="invalid-feedback">{{ errors.notes }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Таб "Контакты" (только при активном CRM) #}
|
||||
{% if crm_active %}
|
||||
<div class="tab-pane fade" id="tab-contacts" role="tabpanel">
|
||||
<input type="hidden" name="customer_id" value="{{ client.id }}">
|
||||
|
||||
{# Скрипт инициализации контактов подключаем в конце #}
|
||||
<div id="contacts-container"
|
||||
data-client-id="{{ client.id }}"
|
||||
data-api-url="{{ base_url('/crm/contacts') }}"
|
||||
data-csrf-token="{{ csrf_hash }}">
|
||||
{# Таблица контактов загружается через AJAX #}
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
<p class="text-muted mt-2">Загрузка контактов...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 pt-3 border-top mt-4">
|
||||
<a href="{{ base_url('/clients') }}" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa-solid fa-check me-2"></i>
|
||||
{{ client ? 'Сохранить изменения' : 'Добавить клиента' }}
|
||||
</button>
|
||||
</div>
|
||||
{{ forms.form_close() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{# Inline-редактирование контактов #}
|
||||
{% if crm_active %}
|
||||
<script src="{{ base_url('/assets/js/modules/contacts.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class EventManager
|
|||
private function getModulesConfig(): \Config\BusinessModules
|
||||
{
|
||||
if ($this->modulesConfig === null) {
|
||||
$this->modulesConfig = new \Config\BusinessModules();
|
||||
$this->modulesConfig = config('BusinessModules');
|
||||
}
|
||||
|
||||
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(
|
||||
'info',
|
||||
"EventManager: Module '{$this->moduleCode}' is disabled globally"
|
||||
|
|
|
|||
|
|
@ -2,393 +2,377 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Config\BusinessModules;
|
||||
use App\Models\OrganizationSubscriptionModel;
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Сервис для работы с подписками на модули
|
||||
* Сервис для работы с подписками организаций на модули
|
||||
*
|
||||
* Предоставляет API для проверки статуса подписок,
|
||||
* активации модулей и управления триальными периодами.
|
||||
* Предоставляет API для проверки доступности модулей,
|
||||
* управления подписками и получения информации о модулях.
|
||||
*/
|
||||
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(
|
||||
?OrganizationSubscriptionModel $subscriptionModel = null,
|
||||
?BusinessModules $modulesConfig = null
|
||||
) {
|
||||
$this->subscriptionModel = $subscriptionModel ?? new OrganizationSubscriptionModel();
|
||||
$this->modulesConfig = $modulesConfig ?? config('BusinessModules');
|
||||
protected $db;
|
||||
protected string $moduleSettingsTable = 'module_settings';
|
||||
protected string $subscriptionsTable = 'organization_subscriptions';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение активного ID организации из сессии
|
||||
* Получение базовой конфигурации модуля
|
||||
*/
|
||||
protected function getActiveOrgId(): ?int
|
||||
public function getModuleConfig(string $moduleCode): ?array
|
||||
{
|
||||
if ($this->activeOrgId === null) {
|
||||
$this->activeOrgId = session()->get('active_org_id') ?? 0;
|
||||
return $this->modulesConfig[$moduleCode] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех модулей (с учётом переопределений из БД)
|
||||
*/
|
||||
public function getAllModules(): array
|
||||
{
|
||||
$modules = $this->modulesConfig;
|
||||
|
||||
$builder = $this->db->table($this->moduleSettingsTable);
|
||||
$settings = $builder->get()->getResultArray();
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
$code = $setting['module_code'];
|
||||
if (isset($modules[$code])) {
|
||||
if (!empty($setting['name'])) {
|
||||
$modules[$code]['name'] = $setting['name'];
|
||||
}
|
||||
if (isset($setting['description']) && $setting['description'] !== '') {
|
||||
$modules[$code]['description'] = $setting['description'];
|
||||
}
|
||||
if (isset($setting['price_monthly'])) {
|
||||
$modules[$code]['price_monthly'] = (int) $setting['price_monthly'];
|
||||
}
|
||||
if (isset($setting['price_yearly'])) {
|
||||
$modules[$code]['price_yearly'] = (int) $setting['price_yearly'];
|
||||
}
|
||||
if (isset($setting['trial_days'])) {
|
||||
$modules[$code]['trial_days'] = (int) $setting['trial_days'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $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
|
||||
{
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
|
||||
if (!$orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Базовый модуль всегда активен
|
||||
if ($moduleCode === 'base') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->subscriptionModel->isModuleActive($orgId, $moduleCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка что модуль доступен (активен или в триале)
|
||||
*
|
||||
* @param string $moduleCode
|
||||
* @param int|null $organizationId
|
||||
* @return bool
|
||||
*/
|
||||
public function isModuleAvailable(string $moduleCode, ?int $organizationId = null): bool
|
||||
{
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
|
||||
$orgId = $organizationId ?? session()->get('active_org_id');
|
||||
if (!$orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Базовый модуль всегда доступен
|
||||
if ($moduleCode === 'base') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем подписку
|
||||
$subscription = $this->subscriptionModel->getSubscription($orgId, $moduleCode);
|
||||
|
||||
$subscription = $this->getSubscription($orgId, $moduleCode);
|
||||
if (!$subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Активен или в триале
|
||||
return in_array($subscription['status'], ['trial', 'active'], true);
|
||||
return $subscription['status'] === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка что модуль в триальном периоде
|
||||
*
|
||||
* @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') {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
|
||||
$orgId = $organizationId ?? session()->get('active_org_id');
|
||||
if (!$orgId) {
|
||||
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,
|
||||
int $daysThreshold = 3,
|
||||
?int $organizationId = null
|
||||
string $status = 'active',
|
||||
?int $days = null
|
||||
): bool {
|
||||
if (!$this->isInTrial($moduleCode, $organizationId)) {
|
||||
return false;
|
||||
$existing = $this->getSubscription($organizationId, $moduleCode);
|
||||
|
||||
$data = [
|
||||
'organization_id' => $organizationId,
|
||||
'module_code' => $moduleCode,
|
||||
'status' => $status,
|
||||
'expires_at' => $days > 0 ? date('Y-m-d H:i:s', strtotime("+{$days} days")) : null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
return $this->db->table($this->subscriptionsTable)
|
||||
->where('id', $existing['id'])
|
||||
->update($data);
|
||||
}
|
||||
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
$daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode);
|
||||
|
||||
return $daysLeft !== null && $daysLeft <= $daysThreshold && $daysLeft > 0;
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
return $this->db->table($this->subscriptionsTable)->insert($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение дней до окончания подписки/триала
|
||||
*
|
||||
* @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();
|
||||
|
||||
if (!$orgId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode);
|
||||
return $this->db->table($this->subscriptionsTable)
|
||||
->where('id', $subscriptionId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации о модуле из конфигурации
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех доступных модулей для организации
|
||||
*
|
||||
* @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) {
|
||||
$config = $this->getModuleConfig($moduleCode);
|
||||
if (!$config || $config['trial_days'] <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем что модуль существует
|
||||
if (!$this->modulesConfig->exists($moduleCode)) {
|
||||
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,
|
||||
return $this->upsertSubscription(
|
||||
$organizationId,
|
||||
$moduleCode,
|
||||
$moduleInfo['trial_days']
|
||||
'trial',
|
||||
$trialDays
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Активация платной подписки (для ручного включения или после платежа)
|
||||
*
|
||||
* @param string $moduleCode
|
||||
* @param int $months
|
||||
* @param int|null $organizationId
|
||||
* @return bool
|
||||
* Активация подписки
|
||||
*/
|
||||
public function activate(
|
||||
string $moduleCode,
|
||||
int $months = 1,
|
||||
?int $organizationId = null
|
||||
): bool {
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
|
||||
if (!$orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем что модуль существует
|
||||
if (!$this->modulesConfig->exists($moduleCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->subscriptionModel->activate($orgId, $moduleCode, $months);
|
||||
public function activate(int $organizationId, string $moduleCode, int $months = 1): bool
|
||||
{
|
||||
return $this->upsertSubscription(
|
||||
$organizationId,
|
||||
$moduleCode,
|
||||
'active',
|
||||
$months * 30
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отмена подписки
|
||||
*
|
||||
* @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();
|
||||
|
||||
if (!$orgId) {
|
||||
$existing = $this->getSubscription($organizationId, $moduleCode);
|
||||
if (!$existing) {
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Для триальных версий могут быть ограничения на некоторые фичи
|
||||
if ($this->isInTrial($moduleCode)) {
|
||||
// Проверяем доступность фичи в триале
|
||||
// (можно расширить через конфигурацию)
|
||||
}
|
||||
|
||||
return true;
|
||||
$builder = $this->db->table($this->subscriptionsTable);
|
||||
return $builder
|
||||
->select('organization_subscriptions.*, organizations.name as organization_name')
|
||||
->join('organizations', 'organizations.id = organization_subscriptions.organization_id')
|
||||
->orderBy('organization_subscriptions.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статуса подписки для отображения в 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,
|
||||
?int $organizationId = null
|
||||
): array {
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
?string $name = null,
|
||||
?string $description = null,
|
||||
?int $priceMonthly = null,
|
||||
?int $priceYearly = null,
|
||||
?int $trialDays = null
|
||||
): bool {
|
||||
$existing = $this->db->table($this->moduleSettingsTable)
|
||||
->where('module_code', $moduleCode)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$moduleInfo = $this->modulesConfig->getModule($moduleCode);
|
||||
$subscription = $orgId
|
||||
? $this->subscriptionModel->getSubscription($orgId, $moduleCode)
|
||||
: 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,
|
||||
$data = [
|
||||
'module_code' => $moduleCode,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение цены модуля
|
||||
*
|
||||
* @param string $moduleCode
|
||||
* @param string $period
|
||||
* @return int
|
||||
*/
|
||||
public function getPrice(string $moduleCode, string $period = 'monthly'): int
|
||||
{
|
||||
return $this->modulesConfig->getPrice($moduleCode, $period);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка что пользователь может управлять подписками организации
|
||||
*
|
||||
* @param int|null $organizationId
|
||||
* @return bool
|
||||
*/
|
||||
public function canManageSubscriptions(?int $organizationId = null): bool
|
||||
{
|
||||
$orgId = $organizationId ?? $this->getActiveOrgId();
|
||||
|
||||
if (!$orgId) {
|
||||
return false;
|
||||
if ($name !== null) {
|
||||
$data['name'] = $name;
|
||||
}
|
||||
if ($description !== null) {
|
||||
$data['description'] = $description;
|
||||
}
|
||||
if ($priceMonthly !== null) {
|
||||
$data['price_monthly'] = $priceMonthly;
|
||||
}
|
||||
if ($priceYearly !== null) {
|
||||
$data['price_yearly'] = $priceYearly;
|
||||
}
|
||||
if ($trialDays !== null) {
|
||||
$data['trial_days'] = $trialDays;
|
||||
}
|
||||
|
||||
// Проверяем права через AccessService
|
||||
return service('access')->canManageModules();
|
||||
if ($existing) {
|
||||
return $this->db->table($this->moduleSettingsTable)
|
||||
->where('id', $existing['id'])
|
||||
->update($data);
|
||||
}
|
||||
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['is_active'] = 1;
|
||||
return $this->db->table($this->moduleSettingsTable)->insert($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение настроек модуля
|
||||
*/
|
||||
public function getModuleSettings(string $moduleCode): ?array
|
||||
{
|
||||
return $this->db->table($this->moduleSettingsTable)
|
||||
->where('module_code', $moduleCode)
|
||||
->get()
|
||||
->getRowArray();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,86 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{# Легенда #}
|
||||
{% block stylesheets %}
|
||||
<style>
|
||||
.calendar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar .calendar-header {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(7, 1fr) !important;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.calendar .calendar-header-cell {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar .calendar-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(7, 1fr) !important;
|
||||
}
|
||||
|
||||
.calendar .calendar-cell {
|
||||
min-height: 100px;
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.calendar .calendar-cell:nth-child(7n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar .calendar-cell.bg-light {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.calendar .calendar-cell-today {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.calendar .calendar-day-number {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar .calendar-events {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar .calendar-event {
|
||||
display: block !important;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background-color: #f3f4f6;
|
||||
border-left: 3px solid #6b7280;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar .calendar-event:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.calendar .calendar-events-more {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% if showLegend|default(true) and (legend is defined or events is defined) %}
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
|
|
|
|||
|
|
@ -131,20 +131,42 @@ function handleDrop(e) {
|
|||
|
||||
if (itemId && newColumnId) {
|
||||
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, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: 'item_id=' + itemId + '&column_id=' + newColumnId
|
||||
body: 'deal_id=' + itemId + '&stage_id=' + newColumnId
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (onMove) {
|
||||
window[onMove](itemId, newColumnId, data);
|
||||
} else if (draggedCard && sourceColumn) {
|
||||
// Перемещаем карточку в новую колонку
|
||||
const targetContainer = this;
|
||||
targetContainer.appendChild(draggedCard);
|
||||
|
||||
// Обновляем счётчики колонок
|
||||
updateColumnCounters(sourceColumn);
|
||||
updateColumnCounters(targetContainer);
|
||||
|
||||
// Анимация успешного перемещения
|
||||
draggedCard.style.transition = 'all 0.2s ease';
|
||||
draggedCard.style.transform = 'scale(1.02)';
|
||||
setTimeout(() => {
|
||||
draggedCard.style.transform = '';
|
||||
}, 200);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -16,10 +16,21 @@
|
|||
<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-users fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">CRM</h5>
|
||||
<i class="fa-solid fa-building fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">Клиенты</h5>
|
||||
<p class="card-text text-muted">Управление клиентами</p>
|
||||
<a href="#" 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>
|
||||
|
|
|
|||
|
|
@ -37,23 +37,62 @@
|
|||
<a href="{{ base_url('/clients') }}"
|
||||
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
|
||||
{{ is_active_route('clients') ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-users me-2"></i> Клиенты
|
||||
<i class="fa-solid fa-building text-primary me-2"></i> Клиенты
|
||||
</a>
|
||||
|
||||
{# Модули #}
|
||||
|
||||
<!-- Будущие модули -->
|
||||
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled">
|
||||
<i class="fa-solid fa-chart-line me-2"></i> CRM
|
||||
{# CRM модуль #}
|
||||
{% if is_module_available('crm') %}
|
||||
<a href="{{ base_url('/crm') }}"
|
||||
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
|
||||
{{ is_active_route('crm') ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-chart-line text-success me-2"></i> CRM
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled">
|
||||
<i class="fa-solid fa-file-contract me-2"></i> Proof
|
||||
{% else %}
|
||||
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Бронирования не активен">
|
||||
<i class="fa-solid fa-lock text-muted me-2"></i> Booking
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Задачи не активен">
|
||||
<i class="fa-solid fa-lock text-muted me-2"></i> Tasks
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Proof модуль #}
|
||||
{% if is_module_available('proof') %}
|
||||
<a href="{{ base_url('/proof') }}"
|
||||
class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link
|
||||
{{ is_active_route('proof') ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-file-contract me-2"></i> Proof
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="list-group-item list-group-item-action list-group-item-light py-3 sidebar-link disabled" title="Модуль Proof не активен">
|
||||
<i class="fa-solid fa-lock text-muted me-2"></i> Proof
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- /SIDEBAR -->
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@
|
|||
<div class="icon">📅</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Всего тарифов</h3>
|
||||
<div class="value">{{ stats.total_plans|number_format(0, '', ' ') }}</div>
|
||||
<div class="icon">📋</div>
|
||||
<h3>Всего модулей</h3>
|
||||
<div class="value">{{ stats.total_modules|number_format(0, '', ' ') }}</div>
|
||||
<div class="icon">📦</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ base_url('/superadmin') }}" class="{{ is_active_route('superadmin') and not is_active_route('superadmin/') ? 'active' : '' }}">📊 Дашборд</a></li>
|
||||
<li><a href="{{ base_url('/superadmin/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/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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -1,220 +1,245 @@
|
|||
{% extends 'superadmin/layout.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="sa-header">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Организация: {{ organization.name }}</h1>
|
||||
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-primary">← Назад</a>
|
||||
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-outline-secondary">
|
||||
<i class="fa-solid fa-arrow-left"></i> Назад к списку
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% for alert in get_alerts() %}
|
||||
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
|
||||
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="sa-card">
|
||||
<div class="sa-card-header">
|
||||
<h2>Информация об организации</h2>
|
||||
</div>
|
||||
<div class="sa-card-body">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th style="width: 150px;">ID</th>
|
||||
<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>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Подписки на модули</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if subscriptions is empty %}
|
||||
<p class="text-muted text-center py-4">У организации нет активных подписок</p>
|
||||
{% else %}
|
||||
<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 %}
|
||||
<a href="{{ base_url('/superadmin/organizations/delete/' ~ organization.id) }}" class="btn btn-danger" onclick="return confirm('Удалить организацию? Это действие нельзя отменить!')">🗑️ Удалить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sa-card">
|
||||
<div class="sa-card-header">
|
||||
<h2>Управление тарифом</h2>
|
||||
</div>
|
||||
<div class="sa-card-body">
|
||||
<form action="{{ base_url('/superadmin/organizations/set-plan/' ~ organization.id) }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="plan_id">Выберите тариф</label>
|
||||
<select name="plan_id" id="plan_id" class="form-control" required>
|
||||
<option value="">-- Выберите тариф --</option>
|
||||
{% for plan in plans %}
|
||||
<option value="{{ plan.id }}" {{ currentSubscription and currentSubscription.plan_id == plan.id ? 'selected' : '' }}>
|
||||
{{ plan.name }} - {{ plan.price }} {{ plan.currency }}/{{ plan.billing_period }}
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Добавить подписку</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ base_url('/superadmin/organizations/' ~ organization.id ~ '/add-subscription') }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Модуль</label>
|
||||
<select name="module_code" class="form-select" required>
|
||||
<option value="">Выберите модуль...</option>
|
||||
{% for code, module in allModules %}
|
||||
{% set hasSub = false %}
|
||||
{% for sub in subscriptions %}
|
||||
{% if sub.module_code == code %}
|
||||
{% set hasSub = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="{{ code }}" {{ hasSub ? 'disabled style="background-color: #f8f9fa;"' : '' }}>
|
||||
{{ module.name }} - {{ module.price_monthly }} руб/мес {{ hasSub ? '(уже есть)' : '' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="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 }}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div style="color: #7f8c8d; font-size: 12px;">Владельцы</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #f39c12;">
|
||||
{% set admin_count = users|filter(u => u.role == 'admin')|length %}
|
||||
{{ admin_count }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Срок действия (дней)</label>
|
||||
<input type="number" name="duration_days" class="form-control" value="30" min="0">
|
||||
<div class="form-text">0 - бессрочно</div>
|
||||
</div>
|
||||
<div style="color: #7f8c8d; font-size: 12px;">Админы</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #9b59b6;">
|
||||
{% set manager_count = users|filter(u => u.role == 'manager')|length %}
|
||||
{{ manager_count }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Статус</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="active">Активна</option>
|
||||
<option value="trial">Триал</option>
|
||||
<option value="expired">Истекла</option>
|
||||
<option value="cancelled">Отменена</option>
|
||||
</select>
|
||||
</div>
|
||||
<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 class="sa-card" style="margin-top: 20px;">
|
||||
<div class="sa-card-header">
|
||||
<h2>Участники организации</h2>
|
||||
</div>
|
||||
<div class="sa-card-body">
|
||||
{% if users is empty %}
|
||||
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Участников пока нет</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата добавления</th>
|
||||
<td>ID</td>
|
||||
<td class="text-end">{{ organization.id }}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.name|default('—') }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="badge {{ user.role == 'owner' ? 'badge-danger' : (user.role == 'admin' ? 'badge-warning' : 'badge-info') }}">
|
||||
{{ 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>
|
||||
<td>Тип</td>
|
||||
<td class="text-end">
|
||||
{% if organization.type == 'business' %}
|
||||
<span class="badge bg-info">Бизнес</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">{{ user.status }}</span>
|
||||
<span class="badge bg-warning">Личное</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at|date('d.m.Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Статус</td>
|
||||
<td class="text-end">
|
||||
{% if organization.status == 'active' %}
|
||||
<span class="badge bg-success">Активна</span>
|
||||
{% elseif organization.status == 'blocked' %}
|
||||
<span class="badge bg-danger">Заблокирована</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">{{ organization.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Создана</td>
|
||||
<td class="text-end">{{ organization.created_at|date('d.m.Y H:i') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Участников</td>
|
||||
<td class="text-end">{{ users|length }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column gap-2">
|
||||
{% if organization.status == 'active' %}
|
||||
<a href="{{ base_url('/superadmin/organizations/block/' ~ organization.id) }}"
|
||||
class="btn btn-warning"
|
||||
onclick="return confirm('Заблокировать организацию?')">
|
||||
<i class="fa-solid fa-ban"></i> Заблокировать
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ base_url('/superadmin/organizations/unblock/' ~ organization.id) }}"
|
||||
class="btn btn-success"
|
||||
onclick="return confirm('Разблокировать организацию?')">
|
||||
<i class="fa-solid fa-check"></i> Разблокировать
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ base_url('/superadmin/organizations/delete/' ~ organization.id) }}"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Удалить организацию? Это действие нельзя отменить!')">
|
||||
<i class="fa-solid fa-trash"></i> Удалить организацию
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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="Неограниченные проекты Приоритетная поддержка Экспорт в 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 %}
|
||||
|
|
@ -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="Неограниченные проекты Приоритетная поддержка Экспорт в 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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
|
||||
|
|
@ -98,21 +98,61 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Получение CSRF токена из мета-тега
|
||||
* Получение CSRF токена из нескольких источников (с fallback)
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
// 1. Пробуем из мета-тега
|
||||
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) {
|
||||
// Обновляем мета-теги
|
||||
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const hashMeta = document.querySelector('meta[name="csrf-hash"]');
|
||||
if (tokenMeta) tokenMeta.setAttribute('content', token);
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue