diff --git a/app/Config/BusinessModules.php b/app/Config/BusinessModules.php new file mode 100644 index 0000000..6e6a02c --- /dev/null +++ b/app/Config/BusinessModules.php @@ -0,0 +1,151 @@ + + */ + 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 + */ + public function getPaidModules(): array + { + return array_filter($this->modules, function ($module) { + return $module['trial_days'] > 0; + }); + } +} diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 2cbd4ae..a6a91cf 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -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, ]; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f8489e4..257394b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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'); diff --git a/app/Config/Services.php b/app/Config/Services.php index 725b287..2a9eda5 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -103,7 +103,7 @@ class Services extends BaseService return static::getSharedInstance('moduleSubscription'); } - return new ModuleSubscriptionService(); + return new \App\Services\ModuleSubscriptionService(); } /** diff --git a/app/Controllers/Superadmin.php b/app/Controllers/Superadmin.php index 395caf9..d475965 100644 --- a/app/Controllers/Superadmin.php +++ b/app/Controllers/Superadmin.php @@ -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')); } } diff --git a/app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php b/app/Database/Migrations/2026-01-16-210001_DropOrganizationPlanSubscriptionsTable.php similarity index 80% rename from app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php rename to app/Database/Migrations/2026-01-16-210001_DropOrganizationPlanSubscriptionsTable.php index d7720ac..73b19fd 100644 --- a/app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php +++ b/app/Database/Migrations/2026-01-16-210001_DropOrganizationPlanSubscriptionsTable.php @@ -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'); - } } diff --git a/app/Database/Migrations/2026-01-16-220001_CreateModuleSettingsTable.php b/app/Database/Migrations/2026-01-16-220001_CreateModuleSettingsTable.php new file mode 100644 index 0000000..8c97fe2 --- /dev/null +++ b/app/Database/Migrations/2026-01-16-220001_CreateModuleSettingsTable.php @@ -0,0 +1,75 @@ +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); + } +} diff --git a/app/Database/Migrations/2026-01-16-233001_AddUpdatedAtToSubscriptions.php b/app/Database/Migrations/2026-01-16-233001_AddUpdatedAtToSubscriptions.php new file mode 100644 index 0000000..cd945b9 --- /dev/null +++ b/app/Database/Migrations/2026-01-16-233001_AddUpdatedAtToSubscriptions.php @@ -0,0 +1,23 @@ +forge->addColumn('organization_subscriptions', [ + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + } + + public function down() + { + $this->forge->dropColumn('organization_subscriptions', 'updated_at'); + } +} diff --git a/app/Filters/ModuleSubscriptionFilter.php b/app/Filters/ModuleSubscriptionFilter.php new file mode 100644 index 0000000..ef99fab --- /dev/null +++ b/app/Filters/ModuleSubscriptionFilter.php @@ -0,0 +1,75 @@ +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) + { + // Ничего не делаем после запроса + } +} diff --git a/app/Libraries/Twig/TwigGlobalsExtension.php b/app/Libraries/Twig/TwigGlobalsExtension.php index fd78c3e..5db223c 100644 --- a/app/Libraries/Twig/TwigGlobalsExtension.php +++ b/app/Libraries/Twig/TwigGlobalsExtension.php @@ -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 { diff --git a/app/Models/ModuleSettingsModel.php b/app/Models/ModuleSettingsModel.php new file mode 100644 index 0000000..73da0c5 --- /dev/null +++ b/app/Models/ModuleSettingsModel.php @@ -0,0 +1,74 @@ +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(); + } +} diff --git a/app/Models/OrganizationSubscriptionModel.php b/app/Models/OrganizationSubscriptionModel.php new file mode 100644 index 0000000..410d258 --- /dev/null +++ b/app/Models/OrganizationSubscriptionModel.php @@ -0,0 +1,203 @@ +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']); + } +} diff --git a/app/Models/PlanModel.php b/app/Models/PlanModel.php deleted file mode 100644 index bb2bff7..0000000 --- a/app/Models/PlanModel.php +++ /dev/null @@ -1,79 +0,0 @@ - '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; - } -} diff --git a/app/Modules/CRM/Config/Routes.php b/app/Modules/CRM/Config/Routes.php index b057daf..e39068c 100644 --- a/app/Modules/CRM/Config/Routes.php +++ b/app/Modules/CRM/Config/Routes.php @@ -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) { diff --git a/app/Modules/CRM/Controllers/ContactsController.php b/app/Modules/CRM/Controllers/ContactsController.php index 4aa90d3..0c389a7 100644 --- a/app/Modules/CRM/Controllers/ContactsController.php +++ b/app/Modules/CRM/Controllers/ContactsController.php @@ -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' => 'Контакт удалён', + ]); + } } diff --git a/app/Modules/CRM/Controllers/DealsController.php b/app/Modules/CRM/Controllers/DealsController.php index 7787ef1..b30c132 100644 --- a/app/Modules/CRM/Controllers/DealsController.php +++ b/app/Modules/CRM/Controllers/DealsController.php @@ -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]); } /** diff --git a/app/Modules/CRM/Views/contacts/form.twig b/app/Modules/CRM/Views/contacts/form.twig index 87edfbe..13a2a66 100644 --- a/app/Modules/CRM/Views/contacts/form.twig +++ b/app/Modules/CRM/Views/contacts/form.twig @@ -29,10 +29,6 @@
{{ csrf_field()|raw }} - {% if contact is defined %} - - {% endif %} -
+{% endblock %} + {% block content %} {# Сообщения #} -{% if success is defined %} +{% if session.success %} +{% endif %} + +{% if session.error %} + {% endif %}
-
- - - - - - - - - - - - - {% for contact in contacts %} - - - - - - - - - {% else %} - - - - {% endfor %} - -
ИмяEmailТелефонДолжностьКлиентДействия
-
-
- {{ contact.name|slice(0, 2)|upper }} -
- {{ contact.name }} - {% if contact.is_primary %} - Основной - {% endif %} -
-
{{ contact.email ?: '—' }}{{ contact.phone ?: '—' }}{{ contact.position ?: '—' }} - {% if contact.customer_id %} - {{ contact.customer_id }} - {% else %} - - {% endif %} - - - - - - {{ csrf_field()|raw }} - - - -
- Контактов пока нет -
-
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }}
{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/Modules/CRM/Views/dashboard.twig b/app/Modules/CRM/Views/dashboard.twig index 2de532d..ff1761e 100644 --- a/app/Modules/CRM/Views/dashboard.twig +++ b/app/Modules/CRM/Views/dashboard.twig @@ -5,7 +5,7 @@ {% block content %}
-

CRM

+

CRM

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

@@ -66,7 +66,7 @@
- +
diff --git a/app/Modules/CRM/Views/deals/calendar.twig b/app/Modules/CRM/Views/deals/calendar.twig index f7c3a16..ad63af2 100644 --- a/app/Modules/CRM/Views/deals/calendar.twig +++ b/app/Modules/CRM/Views/deals/calendar.twig @@ -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() }} +{% endblock %} {% if showLegend|default(true) and (legend is defined or events is defined) %}
diff --git a/app/Views/components/kanban/kanban.twig b/app/Views/components/kanban/kanban.twig index 1cf7294..3fb4249 100644 --- a/app/Views/components/kanban/kanban.twig +++ b/app/Views/components/kanban/kanban.twig @@ -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; + } + } +} diff --git a/app/Views/dashboard/index.twig b/app/Views/dashboard/index.twig index 93330c5..91713d6 100644 --- a/app/Views/dashboard/index.twig +++ b/app/Views/dashboard/index.twig @@ -16,10 +16,21 @@ + +
+
+
+ +
CRM
+

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

+ Открыть
diff --git a/app/Views/layouts/base.twig b/app/Views/layouts/base.twig index 82fcd14..104f0ed 100644 --- a/app/Views/layouts/base.twig +++ b/app/Views/layouts/base.twig @@ -37,23 +37,62 @@ - Клиенты + Клиенты + {# Модули #} - - - CRM + {# CRM модуль #} + {% if is_module_available('crm') %} + + CRM - + {% else %} + + CRM + + {% endif %} + + {# Booking модуль #} + {% if is_module_available('booking') %} + Booking - - Proof + {% else %} + + Booking - + {% endif %} + + {# Tasks модуль #} + {% if is_module_available('tasks') %} + Tasks + {% else %} + + Tasks + + {% endif %} + + {# Proof модуль #} + {% if is_module_available('proof') %} + + Proof + + {% else %} + + Proof + + {% endif %}
diff --git a/app/Views/superadmin/dashboard.twig b/app/Views/superadmin/dashboard.twig index e025cab..46d1732 100644 --- a/app/Views/superadmin/dashboard.twig +++ b/app/Views/superadmin/dashboard.twig @@ -30,9 +30,9 @@
📅
-

Всего тарифов

-
{{ stats.total_plans|number_format(0, '', ' ') }}
-
📋
+

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

+
{{ stats.total_modules|number_format(0, '', ' ') }}
+
📦
diff --git a/app/Views/superadmin/layout.twig b/app/Views/superadmin/layout.twig index 4cc0ef1..1447577 100644 --- a/app/Views/superadmin/layout.twig +++ b/app/Views/superadmin/layout.twig @@ -71,7 +71,8 @@