From b810a1764991a7e3e5fcf3921d92883254486f76 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 15 Jan 2026 07:09:36 +0300 Subject: [PATCH] add CRM module start --- app/Config/Routes.php | 2 +- app/Config/Twig.php | 5 +- app/Controllers/Auth.php | 51 +- app/Controllers/BaseController.php | 3 +- app/Controllers/InvitationController.php | 36 ++ ...26-01-15-000003_AddTokenExpiresToUsers.php | 29 + ...04_AddInviteExpiresToOrganizationUsers.php | 29 + ...-01-15-000005_AddStatusToOrganizations.php | 29 + .../2026-01-15-000006_CreateDealsTables.php | 206 +++++++ .../2026-01-15-000007_CreateContactsTable.php | 87 +++ app/Helpers/crm_deals_helper.php | 41 ++ app/Libraries/RateLimitIdentifier.php | 197 +++++++ app/Models/OrganizationUserModel.php | 2 + app/Modules/CRM/Config/Routes.php | 42 ++ .../CRM/Controllers/ContactsController.php | 154 +++++ .../CRM/Controllers/DashboardController.php | 55 ++ .../CRM/Controllers/DealsController.php | 525 ++++++++++++++++++ app/Modules/CRM/Entities/Contact.php | 52 ++ app/Modules/CRM/Models/ContactModel.php | 66 +++ app/Modules/CRM/Models/DealModel.php | 229 ++++++++ app/Modules/CRM/Models/DealStageModel.php | 119 ++++ app/Modules/CRM/Services/DealService.php | 141 +++++ app/Modules/CRM/Services/DealStageService.php | 99 ++++ app/Modules/CRM/Views/contacts/form.twig | 127 +++++ app/Modules/CRM/Views/contacts/index.twig | 89 +++ app/Modules/CRM/Views/dashboard.twig | 114 ++++ app/Modules/CRM/Views/deals/calendar.twig | 77 +++ .../CRM/Views/deals/calendar_event.twig | 9 + app/Modules/CRM/Views/deals/form.twig | 217 ++++++++ app/Modules/CRM/Views/deals/index.twig | 76 +++ app/Modules/CRM/Views/deals/kanban.twig | 51 ++ app/Modules/CRM/Views/deals/kanban_card.twig | 45 ++ app/Modules/CRM/Views/deals/show.twig | 204 +++++++ app/Modules/CRM/Views/deals/stages.twig | 195 +++++++ app/Services/InvitationService.php | 8 +- app/Views/components/calendar/calendar.twig | 133 +++++ .../components/calendar/default_event.twig | 29 + app/Views/components/kanban/default_card.twig | 76 +++ app/Views/components/kanban/kanban.twig | 165 ++++++ .../organizations/invitation_expired.twig | 7 + 40 files changed, 3807 insertions(+), 14 deletions(-) create mode 100644 app/Database/Migrations/2026-01-15-000003_AddTokenExpiresToUsers.php create mode 100644 app/Database/Migrations/2026-01-15-000004_AddInviteExpiresToOrganizationUsers.php create mode 100644 app/Database/Migrations/2026-01-15-000005_AddStatusToOrganizations.php create mode 100644 app/Database/Migrations/2026-01-15-000006_CreateDealsTables.php create mode 100644 app/Database/Migrations/2026-01-15-000007_CreateContactsTable.php create mode 100644 app/Helpers/crm_deals_helper.php create mode 100644 app/Libraries/RateLimitIdentifier.php create mode 100644 app/Modules/CRM/Config/Routes.php create mode 100644 app/Modules/CRM/Controllers/ContactsController.php create mode 100644 app/Modules/CRM/Controllers/DashboardController.php create mode 100644 app/Modules/CRM/Controllers/DealsController.php create mode 100644 app/Modules/CRM/Entities/Contact.php create mode 100644 app/Modules/CRM/Models/ContactModel.php create mode 100644 app/Modules/CRM/Models/DealModel.php create mode 100644 app/Modules/CRM/Models/DealStageModel.php create mode 100644 app/Modules/CRM/Services/DealService.php create mode 100644 app/Modules/CRM/Services/DealStageService.php create mode 100644 app/Modules/CRM/Views/contacts/form.twig create mode 100644 app/Modules/CRM/Views/contacts/index.twig create mode 100644 app/Modules/CRM/Views/dashboard.twig create mode 100644 app/Modules/CRM/Views/deals/calendar.twig create mode 100644 app/Modules/CRM/Views/deals/calendar_event.twig create mode 100644 app/Modules/CRM/Views/deals/form.twig create mode 100644 app/Modules/CRM/Views/deals/index.twig create mode 100644 app/Modules/CRM/Views/deals/kanban.twig create mode 100644 app/Modules/CRM/Views/deals/kanban_card.twig create mode 100644 app/Modules/CRM/Views/deals/show.twig create mode 100644 app/Modules/CRM/Views/deals/stages.twig create mode 100644 app/Views/components/calendar/calendar.twig create mode 100644 app/Views/components/calendar/default_event.twig create mode 100644 app/Views/components/kanban/default_card.twig create mode 100644 app/Views/components/kanban/kanban.twig diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f456e76..f8489e4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -86,8 +86,8 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) { # ============================================================================= $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) # ============================================================================= diff --git a/app/Config/Twig.php b/app/Config/Twig.php index 8f6a0de..c973015 100644 --- a/app/Config/Twig.php +++ b/app/Config/Twig.php @@ -43,7 +43,10 @@ class Twig extends \Daycry\Twig\Config\Twig public array $paths = [ APPPATH . 'Views', // Добавляем стандартную папку Views в первую очередь [APPPATH . 'Views/components', 'components'], // Компоненты таблиц - [APPPATH . 'Modules/Clients/Views', 'Clients']// Модуль Клиенты + [APPPATH . 'Modules/Clients/Views', 'Clients'], // Модуль Клиенты + [APPPATH . 'Modules/CRM/Views', 'CRM'], // Модуль CRM (основная папка) + // [APPPATH . 'Modules/CRM/Views/deals', 'CRM/Deals'], // Сделки + // [APPPATH . 'Modules/CRM/Views/contacts', 'CRM/Contacts'], // Контакты ]; /** diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 9f44328..34f86d0 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -126,6 +126,7 @@ class Auth extends BaseController // Генерируем токен для подтверждения email $verificationToken = bin2hex(random_bytes(32)); + $tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours')); // 1. Создаем пользователя с токеном верификации $userData = [ @@ -133,6 +134,7 @@ class Auth extends BaseController 'email' => $this->request->getPost('email'), 'password' => $this->request->getPost('password'), // Хешируется в модели 'verification_token' => $verificationToken, + 'token_expires_at' => $tokenExpiresAt, 'email_verified' => 0, ]; @@ -215,6 +217,13 @@ class Auth extends BaseController ]); } + // Проверяем срок действия токена + if (!empty($user['token_expires_at']) && strtotime($user['token_expires_at']) < time()) { + return $this->renderTwig('auth/verify_error', [ + 'message' => 'Ссылка для подтверждения истекла. Пожалуйста, запросите письмо повторно.' + ]); + } + if ($user['email_verified']) { return $this->renderTwig('auth/verify_error', [ 'message' => 'Email уже подтверждён. Вы можете войти в систему.' @@ -281,8 +290,10 @@ class Auth extends BaseController // Генерируем новый токен $newToken = bin2hex(random_bytes(32)); + $newExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours')); $userModel->update($user['id'], [ - 'verification_token' => $newToken + 'verification_token' => $newToken, + 'token_expires_at' => $newExpiresAt ]); // Отправляем письмо повторно @@ -347,10 +358,10 @@ class Auth extends BaseController // === ЗАПОМНИТЬ МЕНЯ === $remember = $this->request->getPost('remember'); + $redirectUrl = count($userOrgs) === 1 ? '/' : '/organizations'; + if ($remember) { - $this->createRememberToken($user['id']); - // Устанавливаем сессию на 30 дней - $this->session->setExpiry(30 * 24 * 60 * 60); // 30 дней в секундах + $redirectUrl = $this->createRememberTokenAndRedirect($user['id'], $redirectUrl); } // АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ @@ -362,7 +373,9 @@ class Auth extends BaseController // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === $this->resetRateLimit('login'); - return redirect()->to('/'); + return $redirectUrl !== '/' + ? redirect()->to($redirectUrl) + : redirect()->to('/'); } // ОЧИЩАЕМ active_org_id если несколько организаций @@ -378,7 +391,9 @@ class Auth extends BaseController // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === $this->resetRateLimit('login'); - return redirect()->to('/organizations'); + return $redirectUrl !== '/' && $redirectUrl !== '/organizations' + ? redirect()->to($redirectUrl) + : redirect()->to('/organizations'); } else { // === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ === $limitExceeded = $this->recordFailedAttempt('login'); @@ -420,8 +435,9 @@ class Auth extends BaseController /** * Создание remember-токена для автологина + * Возвращает массив с selector и validator */ - protected function createRememberToken(int $userId): void + protected function createRememberTokenData(int $userId): array { $selector = bin2hex(random_bytes(16)); $validator = bin2hex(random_bytes(32)); @@ -439,9 +455,24 @@ class Auth extends BaseController 'ip_address' => $this->request->getIPAddress(), ]); - // Устанавливаем cookie на 30 дней - $cookie = \Config\Services::response()->setCookie('remember_selector', $selector, 30 * 24 * 60 * 60); - $cookie = \Config\Services::response()->setCookie('remember_token', $validator, 30 * 24 * 60 * 60); + return [ + 'selector' => $selector, + 'validator' => $validator, + ]; + } + + /** + * Создание токена и возврат редиректа с установленными куками + */ + protected function createRememberTokenAndRedirect(int $userId, string $redirectUrl) + { + $tokenData = $this->createRememberTokenData($userId); + + $redirect = redirect()->to($redirectUrl); + $redirect->setCookie('remember_selector', $tokenData['selector'], 30 * 24 * 60 * 60); + $redirect->setCookie('remember_token', $tokenData['validator'], 30 * 24 * 60 * 60); + + return $redirectUrl; } /** diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index d16b2e2..220219e 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -49,6 +49,7 @@ abstract class BaseController extends Controller // Загружаем хелпер доступа для Twig helper('access'); + helper('crm_deals'); } /** @@ -269,7 +270,7 @@ abstract class BaseController extends Controller public function renderTwig($template, $data = []) { helper('csrf'); - + helper('crm_deals'); $twig = \Config\Services::twig(); // oldInput из сессии добавляется в данные шаблона diff --git a/app/Controllers/InvitationController.php b/app/Controllers/InvitationController.php index f3f2ac6..8762550 100644 --- a/app/Controllers/InvitationController.php +++ b/app/Controllers/InvitationController.php @@ -25,6 +25,24 @@ class InvitationController extends BaseController $invitation = $this->invitationService->orgUserModel->findByInviteToken($token); if (!$invitation) { + // Проверяем, есть ли приглашение с таким токеном (может быть истекшим) + $db = \Config\Database::connect(); + $expiredInvitation = $db->table('organization_users') + ->where('invite_token', $token) + ->get() + ->getRowArray(); + + if ($expiredInvitation && !empty($expiredInvitation['invite_expires_at'])) { + $expiredAt = strtotime($expiredInvitation['invite_expires_at']); + $isExpired = $expiredAt < time(); + + return $this->renderTwig('organizations/invitation_expired', [ + 'title' => $isExpired ? 'Приглашение истекло' : 'Приглашение недействительно', + 'expired' => $isExpired, + 'expired_at' => $expiredInvitation['invite_expires_at'] ?? null, + ]); + } + return $this->renderTwig('organizations/invitation_expired', [ 'title' => 'Приглашение недействительно', ]); @@ -135,6 +153,24 @@ class InvitationController extends BaseController $invitation = $this->invitationService->orgUserModel->findByInviteToken($token); if (!$invitation) { + // Проверяем, есть ли приглашение с таким токеном (может быть истекшим) + $db = \Config\Database::connect(); + $expiredInvitation = $db->table('organization_users') + ->where('invite_token', $token) + ->get() + ->getRowArray(); + + if ($expiredInvitation && !empty($expiredInvitation['invite_expires_at'])) { + $expiredAt = strtotime($expiredInvitation['invite_expires_at']); + $isExpired = $expiredAt < time(); + + return $this->renderTwig('organizations/invitation_expired', [ + 'title' => $isExpired ? 'Приглашение истекло' : 'Приглашение недействительно', + 'expired' => $isExpired, + 'expired_at' => $expiredInvitation['invite_expires_at'] ?? null, + ]); + } + return $this->renderTwig('organizations/invitation_expired', [ 'title' => 'Приглашение недействительно', ]); diff --git a/app/Database/Migrations/2026-01-15-000003_AddTokenExpiresToUsers.php b/app/Database/Migrations/2026-01-15-000003_AddTokenExpiresToUsers.php new file mode 100644 index 0000000..68ebcf2 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000003_AddTokenExpiresToUsers.php @@ -0,0 +1,29 @@ +db->getFieldData('users'); + $existingFields = array_column($fields, 'name'); + + if (!in_array('token_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE users ADD COLUMN token_expires_at DATETIME NULL AFTER verification_token"); + } + } + + public function down() + { + $fields = $this->db->getFieldData('users'); + $existingFields = array_column($fields, 'name'); + + if (in_array('token_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE users DROP COLUMN token_expires_at"); + } + } +} diff --git a/app/Database/Migrations/2026-01-15-000004_AddInviteExpiresToOrganizationUsers.php b/app/Database/Migrations/2026-01-15-000004_AddInviteExpiresToOrganizationUsers.php new file mode 100644 index 0000000..ee80f81 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000004_AddInviteExpiresToOrganizationUsers.php @@ -0,0 +1,29 @@ +db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + + if (!in_array('invite_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_expires_at DATETIME NULL AFTER invited_at"); + } + } + + public function down() + { + $fields = $this->db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + + if (in_array('invite_expires_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_expires_at"); + } + } +} diff --git a/app/Database/Migrations/2026-01-15-000005_AddStatusToOrganizations.php b/app/Database/Migrations/2026-01-15-000005_AddStatusToOrganizations.php new file mode 100644 index 0000000..6a591f1 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000005_AddStatusToOrganizations.php @@ -0,0 +1,29 @@ +db->getFieldData('organizations'); + $existingFields = array_column($fields, 'name'); + + if (!in_array('status', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organizations ADD COLUMN status ENUM('active', 'blocked') NOT NULL DEFAULT 'active' AFTER settings"); + } + } + + public function down() + { + $fields = $this->db->getFieldData('organizations'); + $existingFields = array_column($fields, 'name'); + + if (in_array('status', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organizations DROP COLUMN status"); + } + } +} diff --git a/app/Database/Migrations/2026-01-15-000006_CreateDealsTables.php b/app/Database/Migrations/2026-01-15-000006_CreateDealsTables.php new file mode 100644 index 0000000..7936d81 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000006_CreateDealsTables.php @@ -0,0 +1,206 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'color' => [ + 'type' => 'VARCHAR', + 'constraint' => 7, + 'default' => '#6B7280', + ], + 'order_index' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['progress', 'won', 'lost'], + 'default' => 'progress', + ], + 'probability' => [ + 'type' => 'INT', + 'constraint' => 3, + 'unsigned' => true, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('deal_stages'); + + // Таблица сделок + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'contact_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'company_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '15,2', + 'default' => 0.00, + ], + 'currency' => [ + 'type' => 'CHAR', + 'constraint' => 3, + 'default' => 'RUB', + ], + 'stage_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'assigned_user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'expected_close_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addKey('stage_id'); + $this->forge->addKey('assigned_user_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('stage_id', 'deal_stages', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('assigned_user_id', 'users', 'id', 'SET NULL', 'SET NULL'); + $this->forge->createTable('deals'); + + // Таблица истории сделок + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'deal_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'action' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'field_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'old_value' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'new_value' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('deal_id'); + $this->forge->addKey('user_id'); + $this->forge->addForeignKey('deal_id', 'deals', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('deal_history'); + } + + public function down() + { + $this->forge->dropTable('deal_history'); + $this->forge->dropTable('deals'); + $this->forge->dropTable('deal_stages'); + } +} diff --git a/app/Database/Migrations/2026-01-15-000007_CreateContactsTable.php b/app/Database/Migrations/2026-01-15-000007_CreateContactsTable.php new file mode 100644 index 0000000..7b65e02 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000007_CreateContactsTable.php @@ -0,0 +1,87 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'customer_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'comment' => 'Ссылка на клиента (компанию)', + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'comment' => 'Имя контакта', + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + 'position' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'comment' => 'Должность', + ], + 'is_primary' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'unsigned' => true, + 'default' => 0, + 'comment' => 'Основной контакт', + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('organization_id'); + $this->forge->addKey('customer_id'); + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('customer_id', 'organizations_clients', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('contacts'); + } + + public function down() + { + $this->forge->dropTable('contacts'); + } +} diff --git a/app/Helpers/crm_deals_helper.php b/app/Helpers/crm_deals_helper.php new file mode 100644 index 0000000..81bd56f --- /dev/null +++ b/app/Helpers/crm_deals_helper.php @@ -0,0 +1,41 @@ +formatCurrency($amount, $currency); + } +} + +/** + * Получить текущую организацию + */ +function current_organization_id(): ?int +{ + $session = session(); + $orgId = $session->get('active_org_id'); + return $orgId ? (int) $orgId : null; +} diff --git a/app/Libraries/RateLimitIdentifier.php b/app/Libraries/RateLimitIdentifier.php new file mode 100644 index 0000000..ef3ad5e --- /dev/null +++ b/app/Libraries/RateLimitIdentifier.php @@ -0,0 +1,197 @@ +isValidToken($cookieToken)) { + $parts['c'] = $cookieToken; + } + + // IP адрес — всегда + $parts['i'] = $this->getClientIp(); + + // User Agent hash — всегда (для дополнительной уникальности) + $parts['ua'] = $this->getUserAgentHash(); + + // Если куки нет — добавляем признак "без куки" для отладки + if (empty($cookieToken)) { + $parts['nc'] = '1'; + } + + // Комбинация всех частей → хеш + return md5('rl:' . $action . ':' . implode('|', $parts)); + } + + /** + * Генерирует и устанавливает токен, если его нет + * + * @return string|null Токен или null если уже есть + */ + public function ensureToken(): ?string + { + if (empty($_COOKIE[self::COOKIE_NAME])) { + $token = $this->generateToken(); + + // Устанавливаем куку на год + setcookie( + self::COOKIE_NAME, + $token, + [ + 'expires' => time() + self::COOKIE_TTL, + 'path' => '/', + 'secure' => true, + 'samesite' => 'Lax', + 'httponly' => true, + ] + ); + + return $token; + } + + return null; + } + + /** + * Проверяет, установлен ли токен + * + * @return bool + */ + public function hasToken(): bool + { + return !empty($_COOKIE[self::COOKIE_NAME]); + } + + /** + * Получает JS код для установки токена при первом визите + * + * @return string JavaScript код + */ + public function getJsScript(): string + { + return << 500) { + $ua = substr($ua, 0, 500); + } + + return md5($ua); + } +} diff --git a/app/Models/OrganizationUserModel.php b/app/Models/OrganizationUserModel.php index e26d3d7..b0ba0b3 100644 --- a/app/Models/OrganizationUserModel.php +++ b/app/Models/OrganizationUserModel.php @@ -21,6 +21,7 @@ class OrganizationUserModel extends Model 'invite_token', 'invited_by', 'invited_at', + 'invite_expires_at', 'joined_at', ]; @@ -44,6 +45,7 @@ class OrganizationUserModel extends Model { return $this->where('invite_token', $token) ->where('status', self::STATUS_PENDING) + ->where('invite_expires_at >', date('Y-m-d H:i:s')) ->first(); } diff --git a/app/Modules/CRM/Config/Routes.php b/app/Modules/CRM/Config/Routes.php new file mode 100644 index 0000000..b057daf --- /dev/null +++ b/app/Modules/CRM/Config/Routes.php @@ -0,0 +1,42 @@ +group('crm', ['filter' => 'org', 'namespace' => 'App\Modules\CRM\Controllers'], static function ($routes) { + + // Dashboard + $routes->get('/', 'DashboardController::index'); + + // Contacts + $routes->get('contacts', 'ContactsController::index'); + $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'); + + // Deals + $routes->group('deals', static function ($routes) { + $routes->get('/', 'DealsController::index'); + $routes->get('table', 'DealsController::table'); + $routes->get('kanban', 'DealsController::kanban'); + $routes->get('calendar', 'DealsController::calendar'); + $routes->get('new', 'DealsController::create'); + $routes->get('create', 'DealsController::create'); + $routes->post('/', 'DealsController::store'); + $routes->get('(:num)', 'DealsController::show/$1'); + $routes->get('(:num)/edit', 'DealsController::edit/$1'); + $routes->post('(:num)', 'DealsController::update/$1'); + $routes->get('(:num)/delete', 'DealsController::destroy/$1'); + + // API endpoints + $routes->post('move-stage', 'DealsController::moveStage'); + $routes->get('contacts-by-client', 'DealsController::getContactsByClient'); + + // Stages + $routes->get('stages', 'DealsController::stages'); + $routes->post('stages', 'DealsController::storeStage'); + $routes->post('stages/(:num)', 'DealsController::updateStage/$1'); + $routes->get('stages/(:num)/delete', 'DealsController::destroyStage/$1'); + }); +}); diff --git a/app/Modules/CRM/Controllers/ContactsController.php b/app/Modules/CRM/Controllers/ContactsController.php new file mode 100644 index 0000000..4aa90d3 --- /dev/null +++ b/app/Modules/CRM/Controllers/ContactsController.php @@ -0,0 +1,154 @@ +contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + + /** + * Список контактов + */ + public function index() + { + $organizationId = $this->requireActiveOrg(); + + $contacts = $this->contactModel + ->where('organization_id', $organizationId) + ->orderBy('created_at', 'DESC') + ->findAll(); + + return $this->renderTwig('@CRM/contacts/index', [ + 'title' => 'Контакты', + 'contacts' => $contacts, + ]); + } + + /** + * Форма создания контакта + */ + public function create() + { + $organizationId = $this->requireActiveOrg(); + + $clients = $this->clientModel + ->where('organization_id', $organizationId) + ->findAll(); + + return $this->renderTwig('@CRM/contacts/form', [ + 'title' => 'Новый контакт', + 'actionUrl' => '/crm/contacts', + 'clients' => $clients, + ]); + } + + /** + * Сохранить новый контакт + */ + public function store() + { + $organizationId = $this->requireActiveOrg(); + + $data = [ + 'organization_id' => $organizationId, + 'customer_id' => $this->request->getPost('customer_id') ?: null, + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?: null, + 'phone' => $this->request->getPost('phone') ?: null, + 'position' => $this->request->getPost('position') ?: null, + 'is_primary' => $this->request->getPost('is_primary') ? 1 : 0, + 'notes' => $this->request->getPost('notes') ?: null, + ]; + + $this->contactModel->save($data); + $contactId = $this->contactModel->getInsertID(); + + if ($contactId) { + return redirect()->to('/crm/contacts')->with('success', 'Контакт успешно создан'); + } + + return redirect()->back()->with('error', 'Ошибка при создании контакта')->withInput(); + } + + /** + * Форма редактирования контакта + */ + public function edit(int $id) + { + $organizationId = $this->requireActiveOrg(); + + $contact = $this->contactModel->find($id); + + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + + $clients = $this->clientModel + ->where('organization_id', $organizationId) + ->findAll(); + + return $this->renderTwig('@CRM/contacts/form', [ + 'title' => 'Редактирование контакта', + 'actionUrl' => "/crm/contacts/{$id}", + 'contact' => $contact, + 'clients' => $clients, + ]); + } + + /** + * Обновить контакт + */ + public function update(int $id) + { + $organizationId = $this->requireActiveOrg(); + + $contact = $this->contactModel->find($id); + + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + + $data = [ + 'customer_id' => $this->request->getPost('customer_id') ?: null, + 'name' => $this->request->getPost('name'), + 'email' => $this->request->getPost('email') ?: null, + 'phone' => $this->request->getPost('phone') ?: null, + 'position' => $this->request->getPost('position') ?: null, + 'is_primary' => $this->request->getPost('is_primary') ? 1 : 0, + 'notes' => $this->request->getPost('notes') ?: null, + ]; + + $this->contactModel->update($id, $data); + + return redirect()->to('/crm/contacts')->with('success', 'Контакт обновлён'); + } + + /** + * Удалить контакт + */ + public function destroy(int $id) + { + $organizationId = $this->requireActiveOrg(); + + $contact = $this->contactModel->find($id); + + if (!$contact || $contact->organization_id !== $organizationId) { + return redirect()->to('/crm/contacts')->with('error', 'Контакт не найден'); + } + + $this->contactModel->delete($id); + + return redirect()->to('/crm/contacts')->with('success', 'Контакт удалён'); + } +} diff --git a/app/Modules/CRM/Controllers/DashboardController.php b/app/Modules/CRM/Controllers/DashboardController.php new file mode 100644 index 0000000..7af1acd --- /dev/null +++ b/app/Modules/CRM/Controllers/DashboardController.php @@ -0,0 +1,55 @@ +dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + $this->contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + + /** + * Главная страница CRM - дашборд + */ + public function index() + { + $organizationId = $this->requireActiveOrg(); + + // Статистика по сделкам + $stats = $this->dealModel->getDealStats($organizationId); + + // Количество контактов + $contactsCount = $this->contactModel->where('organization_id', $organizationId)->countAllResults(); + + // Количество клиентов + $clientsCount = $this->clientModel->where('organization_id', $organizationId)->countAllResults(); + + // Количество этапов + $stagesCount = $this->stageModel->where('organization_id', $organizationId)->countAllResults(); + + return $this->renderTwig('@CRM/dashboard', [ + 'title' => 'CRM - Панель управления', + 'stats' => $stats, + 'counts' => [ + 'contacts' => $contactsCount, + 'clients' => $clientsCount, + 'stages' => $stagesCount, + ], + ]); + } +} diff --git a/app/Modules/CRM/Controllers/DealsController.php b/app/Modules/CRM/Controllers/DealsController.php new file mode 100644 index 0000000..7787ef1 --- /dev/null +++ b/app/Modules/CRM/Controllers/DealsController.php @@ -0,0 +1,525 @@ +dealService = new DealService(); + $this->stageService = new DealStageService(); + $this->dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + $this->contactModel = new ContactModel(); + $this->clientModel = new ClientModel(); + } + + /** + * Главная страница - список сделок (использует DataTable) + */ + public function index() + { + $organizationId = $this->requireActiveOrg(); + + return $this->renderTwig('@CRM/deals/index', [ + 'title' => 'Сделки', + 'tableHtml' => $this->renderTable($this->getTableConfig()), + 'stats' => $this->dealService->getStats($organizationId), + ]); + } + + /** + * AJAX endpoint для таблицы сделок + */ + public function table(?array $config = null, ?string $pageUrl = null) + { + return parent::table($this->getTableConfig(), '/crm/deals'); + } + + /** + * Конфигурация таблицы сделок для DataTable + */ + protected function getTableConfig(): array + { + $organizationId = $this->getActiveOrgId(); + + return [ + 'id' => 'deals-table', + 'url' => '/crm/deals/table', + 'model' => $this->dealModel, + 'columns' => [ + 'title' => [ + 'label' => 'Сделка', + 'width' => '30%', + ], + 'stage_name' => [ + 'label' => 'Этап', + 'width' => '15%', + ], + 'amount' => [ + 'label' => 'Сумма', + 'width' => '15%', + 'align' => 'text-end', + ], + 'client_name' => [ + 'label' => 'Клиент', + 'width' => '20%', + ], + 'expected_close_date' => [ + 'label' => 'Срок', + 'width' => '10%', + ], + ], + 'searchable' => ['title', 'stage_name', 'client_name', 'amount'], + 'sortable' => ['title', 'amount', 'expected_close_date', 'created_at', 'stage_name'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'actions' => ['label' => '', 'width' => '10%'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/crm/deals/{id}', + 'icon' => 'fa-solid fa-eye', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Просмотр', + ], + [ + 'label' => '', + 'url' => '/crm/deals/{id}/edit', + 'icon' => 'fa-solid fa-pen', + 'class' => 'btn-outline-primary btn-sm', + 'title' => 'Редактировать', + 'type' => 'edit', + ], + ], + 'emptyMessage' => 'Сделок пока нет', + 'emptyIcon' => 'fa-solid fa-file-contract', + 'emptyActionUrl' => '/crm/deals/new', + 'emptyActionLabel' => 'Создать сделку', + 'emptyActionIcon' => 'fa-solid fa-plus', + 'can_edit' => true, + 'can_delete' => true, + // Field map for joined fields + 'fieldMap' => [ + 'stage_name' => 'ds.name', + 'client_name' => 'oc.name', + 'amount' => 'deals.amount', + ], + // Custom scope for JOINs and filtering + 'scope' => function($builder) use ($organizationId) { + $builder->from('deals') + ->select('deals.id, deals.title, deals.amount, deals.currency, deals.expected_close_date, deals.created_at, deals.deleted_at, ds.name as stage_name, ds.color as stage_color, c.name as contact_name, oc.name as client_name, au.name as assigned_user_name, cb.name as created_by_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('users cb', 'deals.created_by = cb.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null); + }, + ]; + } + + /** + * Канбан-доска сделок + */ + public function kanban() + { + $organizationId = $this->requireActiveOrg(); + $stages = $this->stageService->getOrganizationStages($organizationId); + $kanbanData = $this->dealService->getDealsForKanban($organizationId); + + // Формируем колонки для компонента + $kanbanColumns = []; + foreach ($stages as $stage) { + $stageDeals = $kanbanData[$stage['id']]['deals'] ?? []; + $kanbanColumns[] = [ + 'id' => $stage['id'], + 'name' => $stage['name'], + 'color' => $stage['color'], + 'items' => $stageDeals, + 'total' => $kanbanData[$stage['id']]['total_amount'] ?? 0, + ]; + } + + return $this->renderTwig('@CRM/deals/kanban', [ + 'title' => 'Сделки — Канбан', + 'kanbanColumns' => $kanbanColumns, + 'stats' => $this->dealService->getStats($organizationId), + ]); + } + + /** + * Календарь сделок + */ + public function calendar() + { + $organizationId = $this->requireActiveOrg(); + $month = $this->request->getGet('month') ?? date('Y-m'); + + $currentTimestamp = strtotime($month . '-01'); + $daysInMonth = date('t', $currentTimestamp); + $firstDayOfWeek = date('N', $currentTimestamp) - 1; + + $deals = $this->dealService->getDealsForCalendar($organizationId, $month); + $eventsByDate = []; + + foreach ($deals as $deal) { + if ($deal['expected_close_date']) { + $dateKey = date('Y-m-d', strtotime($deal['expected_close_date'])); + $eventsByDate[$dateKey][] = [ + 'id' => $deal['id'], + 'title' => $deal['title'], + 'date' => $deal['expected_close_date'], + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + 'url' => '/crm/deals/' . $deal['id'], + ]; + } + } + + // Формируем легенду из этапов + $stages = $this->stageService->getOrganizationStages($organizationId); + $calendarLegend = array_map(function ($stage) { + return [ + 'name' => $stage['name'], + 'color' => $stage['color'], + ]; + }, $stages); + + return $this->renderTwig('@CRM/deals/calendar', [ + 'title' => 'Сделки — Календарь', + 'calendarEvents' => array_map(function ($deal) { + return [ + 'id' => $deal['id'], + 'title' => $deal['title'], + 'date' => $deal['expected_close_date'], + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + ]; + }, $deals), + 'eventsByDate' => $eventsByDate, + 'calendarLegend' => $calendarLegend, + 'currentMonth' => $month, + 'monthName' => date('F Y', $currentTimestamp), + 'daysInMonth' => $daysInMonth, + 'firstDayOfWeek' => $firstDayOfWeek, + 'prevMonth' => date('Y-m', strtotime('-1 month', $currentTimestamp)), + 'nextMonth' => date('Y-m', strtotime('+1 month', $currentTimestamp)), + 'today' => date('Y-m-d'), + ]); + } + + /** + * Страница создания сделки + */ + public function create() + { + $organizationId = $this->requireActiveOrg(); + $stageId = $this->request->getGet('stage_id'); + + // Получаем пользователей организации для поля "Ответственный" + $orgUserModel = new \App\Models\OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + + return $this->renderTwig('@CRM/deals/form', [ + 'title' => 'Новая сделка', + 'actionUrl' => '/crm/deals', + 'stages' => $this->stageService->getOrganizationStages($organizationId), + 'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(), + 'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(), + 'users' => $users, + 'stageId' => $stageId, + 'currentUserId' => $this->getCurrentUserId(), + ]); + } + + /** + * Сохранить новую сделку + */ + public function store() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $data = [ + 'organization_id' => $organizationId, + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'amount' => $this->request->getPost('amount') ?? 0, + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'stage_id' => $this->request->getPost('stage_id'), + 'contact_id' => $this->request->getPost('contact_id') ?: null, + 'company_id' => $this->request->getPost('company_id') ?: null, + 'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null, + 'expected_close_date' => $this->request->getPost('expected_close_date') ?: null, + ]; + + $dealId = $this->dealService->createDeal($data, $userId); + + if ($dealId) { + return redirect()->to('/crm/deals')->with('success', 'Сделка успешно создана'); + } + + return redirect()->back()->with('error', 'Ошибка при создании сделки')->withInput(); + } + + /** + * Просмотр сделки + */ + public function show(int $id) + { + $organizationId = $this->requireActiveOrg(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + + return $this->renderTwig('@CRM/deals/show', [ + 'title' => $deal['title'], + 'deal' => (object) $deal, + ]); + } + + /** + * Страница редактирования сделки + */ + public function edit(int $id) + { + $organizationId = $this->requireActiveOrg(); + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + + // Получаем пользователей организации для поля "Ответственный" + $orgUserModel = new \App\Models\OrganizationUserModel(); + $orgUsers = $orgUserModel->getOrganizationUsers($organizationId); + $users = []; + foreach ($orgUsers as $user) { + $users[$user['user_id']] = $user['user_name'] ?: $user['user_email']; + } + + return $this->renderTwig('@CRM/deals/form', [ + 'title' => 'Редактирование сделки', + 'actionUrl' => "/crm/deals/{$id}", + 'deal' => (object) $deal, + 'stages' => $this->stageService->getOrganizationStages($organizationId), + 'clients' => $this->clientModel->where('organization_id', $organizationId)->findAll(), + 'contacts' => $this->contactModel->where('organization_id', $organizationId)->findAll(), + 'users' => $users, + 'currentUserId' => $this->getCurrentUserId(), + ]); + } + + /** + * Обновить сделку + */ + public function update(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + + $data = [ + 'title' => $this->request->getPost('title'), + 'description' => $this->request->getPost('description'), + 'amount' => $this->request->getPost('amount') ?? 0, + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'stage_id' => $this->request->getPost('stage_id'), + 'contact_id' => $this->request->getPost('contact_id') ?: null, + 'company_id' => $this->request->getPost('company_id') ?: null, + 'assigned_user_id' => $this->request->getPost('assigned_user_id') ?: null, + 'expected_close_date' => $this->request->getPost('expected_close_date') ?: null, + ]; + + $result = $this->dealService->updateDeal($id, $data, $userId); + + if ($result) { + return redirect()->to("/crm/deals/{$id}")->with('success', 'Сделка обновлена'); + } + + return redirect()->back()->with('error', 'Ошибка при обновлении сделки')->withInput(); + } + + /** + * Удалить сделку + */ + public function destroy(int $id) + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $deal = $this->dealService->getDealWithJoins($id, $organizationId); + if (!$deal) { + return redirect()->to('/crm/deals')->with('error', 'Сделка не найдена'); + } + + $this->dealService->deleteDeal($id, $userId); + + return redirect()->to('/crm/deals')->with('success', 'Сделка удалена'); + } + + /** + * API: перемещение сделки между этапами (drag-and-drop) + */ + public function moveStage() + { + $organizationId = $this->requireActiveOrg(); + $userId = $this->getCurrentUserId(); + + $dealId = $this->request->getPost('deal_id'); + $newStageId = $this->request->getPost('stage_id'); + + $deal = $this->dealService->getDealWithJoins($dealId, $organizationId); + if (!$deal) { + return $this->response->setJSON(['success' => false, 'message' => 'Сделка не найдена']); + } + + $result = $this->dealService->changeStage($dealId, $newStageId, $userId); + + return $this->response->setJSON(['success' => $result]); + } + + /** + * Управление этапами + */ + public function stages() + { + $organizationId = $this->requireActiveOrg(); + $stages = $this->stageService->getOrganizationStages($organizationId); + + return $this->renderTwig('@CRM/deals/stages', [ + 'title' => 'Этапы сделок', + 'stages' => $stages, + ]); + } + + /** + * Создать этап + */ + public function storeStage() + { + $organizationId = $this->requireActiveOrg(); + + $data = [ + 'organization_id' => $organizationId, + 'name' => $this->request->getPost('name'), + 'color' => $this->request->getPost('color') ?? '#6B7280', + 'type' => $this->request->getPost('type') ?? 'progress', + 'probability' => $this->request->getPost('probability') ?? 0, + ]; + + $stageId = $this->stageService->createStage($data); + + if ($stageId) { + return redirect()->to('/crm/deals/stages')->with('success', 'Этап создан'); + } + + return redirect()->back()->with('error', 'Ошибка при создании этапа')->withInput(); + } + + /** + * Обновить этап + */ + public function updateStage(int $id) + { + $organizationId = $this->requireActiveOrg(); + $stage = $this->stageService->getStage($id); + + if (!$stage || $stage['organization_id'] !== $organizationId) { + return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден'); + } + + $data = [ + 'name' => $this->request->getPost('name'), + 'color' => $this->request->getPost('color'), + 'type' => $this->request->getPost('type'), + 'probability' => $this->request->getPost('probability'), + ]; + + $this->stageService->updateStage($id, $data); + + return redirect()->to('/crm/deals/stages')->with('success', 'Этап обновлён'); + } + + /** + * Удалить этап + */ + public function destroyStage(int $id) + { + $organizationId = $this->requireActiveOrg(); + $stage = $this->stageService->getStage($id); + + if (!$stage || $stage['organization_id'] !== $organizationId) { + return redirect()->to('/crm/deals/stages')->with('error', 'Этап не найден'); + } + + if (!$this->stageService->canDeleteStage($id)) { + return redirect()->to('/crm/deals/stages')->with('error', 'Нельзя удалить этап, на котором есть сделки'); + } + + $this->stageService->deleteStage($id); + + return redirect()->to('/crm/deals/stages')->with('success', 'Этап удалён'); + } + + /** + * API: получить контакты клиента + */ + public function getContactsByClient() + { + $organizationId = $this->requireActiveOrg(); + $clientId = $this->request->getGet('client_id'); + + if (!$clientId) { + return $this->response->setJSON(['success' => true, 'contacts' => []]); + } + + // Проверяем что клиент принадлежит организации + $client = $this->clientModel->where('organization_id', $organizationId)->find($clientId); + if (!$client) { + return $this->response->setJSON(['success' => false, 'message' => 'Клиент не найден']); + } + + $contacts = $this->contactModel + ->where('organization_id', $organizationId) + ->where('customer_id', $clientId) + ->findAll(); + + return $this->response->setJSON([ + 'success' => true, + 'contacts' => array_map(function($contact) { + return [ + 'id' => $contact->id, + 'name' => $contact->name, + 'email' => $contact->email, + 'phone' => $contact->phone, + ]; + }, $contacts) + ]); + } +} diff --git a/app/Modules/CRM/Entities/Contact.php b/app/Modules/CRM/Entities/Contact.php new file mode 100644 index 0000000..095ae32 --- /dev/null +++ b/app/Modules/CRM/Entities/Contact.php @@ -0,0 +1,52 @@ + null, + 'organization_id' => null, + 'customer_id' => null, + 'name' => null, + 'email' => null, + 'phone' => null, + 'position' => null, + 'is_primary' => false, + 'notes' => null, + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + ]; + + protected $casts = [ + 'id' => 'integer', + 'organization_id' => 'integer', + 'customer_id' => 'integer', + 'name' => 'string', + 'email' => 'string', + 'phone' => 'string', + 'position' => 'string', + 'is_primary' => 'boolean', + 'notes' => 'string', + ]; + + /** + * Получить связанного клиента + */ + public function getCustomer() + { + return model(\App\Modules\Clients\Models\ClientModel::class)->find($this->customer_id); + } + + /** + * Получить имя клиента + */ + public function getCustomerName(): ?string + { + $customer = $this->getCustomer(); + return $customer ? $customer->name : null; + } +} diff --git a/app/Modules/CRM/Models/ContactModel.php b/app/Modules/CRM/Models/ContactModel.php new file mode 100644 index 0000000..cdcaccb --- /dev/null +++ b/app/Modules/CRM/Models/ContactModel.php @@ -0,0 +1,66 @@ +where('customer_id', $customerId)->findAll(); + } + + /** + * Получить основной контакт клиента + */ + public function getPrimaryContact(int $customerId): ?object + { + return $this->where('customer_id', $customerId) + ->where('is_primary', true) + ->first(); + } + + /** + * Получить список контактов для выпадающего списка + */ + public function getContactsList(int $organizationId): array + { + $contacts = $this->where('organization_id', $organizationId) + ->orderBy('name', 'ASC') + ->findAll(); + + $list = []; + foreach ($contacts as $contact) { + $list[$contact->id] = $contact->name . ($contact->email ? " ({$contact->email})" : ''); + } + + return $list; + } +} diff --git a/app/Modules/CRM/Models/DealModel.php b/app/Modules/CRM/Models/DealModel.php new file mode 100644 index 0000000..3e725b4 --- /dev/null +++ b/app/Modules/CRM/Models/DealModel.php @@ -0,0 +1,229 @@ +select(' + deals.id, + deals.title, + deals.amount, + deals.currency, + deals.expected_close_date, + deals.created_at, + deals.deleted_at, + ds.name as stage_name, + ds.color as stage_color, + ds.type as stage_type, + ds.probability as stage_probability, + c.name as contact_name, + c.email as contact_email, + oc.name as client_name, + au.name as assigned_user_name, + au.email as assigned_user_email, + cb.name as created_by_name + ') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('users cb', 'deals.created_by = cb.id', 'left') + ->where('deals.organization_id', $organizationId) + ->orderBy('deals.created_at', 'DESC') + ->findAll(); + } + + /** + * Получить сделки организации с фильтрами + */ + public function getDealsByOrganization( + int $organizationId, + ?int $stageId = null, + ?int $assignedUserId = null, + ?string $search = null, + ?string $dateFrom = null, + ?string $dateTo = null + ): array { + $builder = $this->where('organization_id', $organizationId); + + if ($stageId) { + $builder->where('stage_id', $stageId); + } + + if ($assignedUserId) { + $builder->where('assigned_user_id', $assignedUserId); + } + + if ($search) { + $builder->groupStart() + ->like('title', $search) + ->orLike('description', $search) + ->groupEnd(); + } + + if ($dateFrom) { + $builder->where('expected_close_date >=', $dateFrom); + } + + if ($dateTo) { + $builder->where('expected_close_date <=', $dateTo); + } + + return $builder->orderBy('created_at', 'DESC')->findAll(); + } + + /** + * Получить сделки, сгруппированные по этапам (для Канбана) + */ + public function getDealsGroupedByStage(int $organizationId): array + { + $deals = $this->select('deals.*, ds.name as stage_name, ds.color as stage_color, ds.type as stage_type, au.name as assigned_user_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null) + ->orderBy('ds.order_index', 'ASC') + ->orderBy('deals.created_at', 'DESC') + ->findAll(); + + $grouped = []; + foreach ($deals as $deal) { + $stageId = $deal['stage_id'] ?? 0; + if (!isset($grouped[$stageId])) { + $grouped[$stageId] = [ + 'stage_name' => $deal['stage_name'] ?? 'Без этапа', + 'stage_color' => $deal['stage_color'] ?? '#6B7280', + 'stage_type' => $deal['stage_type'] ?? 'progress', + 'deals' => [], + 'total_amount' => 0, + ]; + } + $grouped[$stageId]['deals'][] = $deal; + $grouped[$stageId]['total_amount'] += (float) $deal['amount']; + } + + return $grouped; + } + + /** + * Получить сделки для календаря (по дате закрытия) + */ + public function getDealsForCalendar(int $organizationId, string $month): array + { + return $this->select('deals.*, ds.color as stage_color, ds.name as stage_name') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->where('deals.organization_id', $organizationId) + ->where('deals.deleted_at', null) + ->where('deals.expected_close_date >=', date('Y-m-01', strtotime($month))) + ->where('deals.expected_close_date <=', date('Y-m-t', strtotime($month))) + ->orderBy('expected_close_date', 'ASC') + ->findAll(); + } + + /** + * Получить статистику по сделкам + */ + public function getDealStats(int $organizationId): array + { + $openDeals = $this->select('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->whereIn('type', ['progress']); + }) + ->get() + ->getRow(); + + $wonDeals = $this->select('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->where('type', 'won'); + }) + ->get() + ->getRow(); + + $lostDeals = $this->select('COUNT(*) as count') + ->where('organization_id', $organizationId) + ->where('deleted_at', null) + ->whereIn('stage_id', function($builder) use ($organizationId) { + return $builder->select('id') + ->from('deal_stages') + ->where('organization_id', $organizationId) + ->where('type', 'lost'); + }) + ->get() + ->getRow(); + + return [ + 'open_count' => $openDeals->count, + 'open_total' => $openDeals->total, + 'won_count' => $wonDeals->count, + 'won_total' => $wonDeals->total, + 'lost_count' => $lostDeals->count, + ]; + } + + /** + * Получить сделку по ID с JOIN-ами + */ + public function getWithJoins(int $dealId, int $organizationId): ?array + { + return $this->select(' + deals.*, + ds.name as stage_name, + ds.color as stage_color, + ds.type as stage_type, + ds.probability as stage_probability, + c.name as contact_name, + c.email as contact_email, + oc.name as client_name, + au.name as assigned_user_name, + au.email as assigned_user_email + ') + ->join('deal_stages ds', 'deals.stage_id = ds.id', 'left') + ->join('contacts c', 'deals.contact_id = c.id', 'left') + ->join('organizations_clients oc', 'deals.company_id = oc.id', 'left') + ->join('users au', 'deals.assigned_user_id = au.id', 'left') + ->where('deals.id', $dealId) + ->where('deals.organization_id', $organizationId) + ->first(); + } +} diff --git a/app/Modules/CRM/Models/DealStageModel.php b/app/Modules/CRM/Models/DealStageModel.php new file mode 100644 index 0000000..692e088 --- /dev/null +++ b/app/Modules/CRM/Models/DealStageModel.php @@ -0,0 +1,119 @@ +where('organization_id', $organizationId) + ->orderBy('order_index', 'ASC') + ->findAll(); + } + + /** + * Получить следующий порядковый номер для этапа + */ + public function getNextOrderIndex(int $organizationId): int + { + $max = $this->selectMax('order_index') + ->where('organization_id', $organizationId) + ->first(); + + return ($max['order_index'] ?? 0) + 1; + } + + /** + * Создать этапы по умолчанию для новой организации + */ + public function createDefaultStages(int $organizationId): array + { + $defaultStages = [ + [ + 'organization_id' => $organizationId, + 'name' => 'Новый лид', + 'color' => '#6B7280', + 'order_index' => 1, + 'type' => 'progress', + 'probability' => 10, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Квалификация', + 'color' => '#3B82F6', + 'order_index' => 2, + 'type' => 'progress', + 'probability' => 25, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Предложение', + 'color' => '#F59E0B', + 'order_index' => 3, + 'type' => 'progress', + 'probability' => 50, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Переговоры', + 'color' => '#8B5CF6', + 'order_index' => 4, + 'type' => 'progress', + 'probability' => 75, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Успех', + 'color' => '#10B981', + 'order_index' => 5, + 'type' => 'won', + 'probability' => 100, + ], + [ + 'organization_id' => $organizationId, + 'name' => 'Провал', + 'color' => '#EF4444', + 'order_index' => 6, + 'type' => 'lost', + 'probability' => 0, + ], + ]; + + return $this->insertBatch($defaultStages); + } + + /** + * Получить список этапов для выпадающего списка + */ + public function getStagesList(int $organizationId): array + { + $stages = $this->getStagesByOrganization($organizationId); + $list = []; + + foreach ($stages as $stage) { + $list[$stage['id']] = $stage['name']; + } + + return $list; + } +} diff --git a/app/Modules/CRM/Services/DealService.php b/app/Modules/CRM/Services/DealService.php new file mode 100644 index 0000000..5c000b1 --- /dev/null +++ b/app/Modules/CRM/Services/DealService.php @@ -0,0 +1,141 @@ +dealModel = new DealModel(); + $this->stageModel = new DealStageModel(); + } + + /** + * Создать новую сделку + */ + public function createDeal(array $data, int $userId): int + { + $data['created_by'] = $userId; + + return $this->dealModel->insert($data); + } + + /** + * Обновить сделку + */ + public function updateDeal(int $dealId, array $data, int $userId): bool + { + $oldDeal = $this->dealModel->find($dealId); + if (!$oldDeal) { + return false; + } + + return $this->dealModel->update($dealId, $data); + } + + /** + * Изменить этап сделки + */ + public function changeStage(int $dealId, int $newStageId, int $userId): bool + { + $deal = $this->dealModel->find($dealId); + if (!$deal) { + return false; + } + + $newStage = $this->stageModel->find($newStageId); + if (!$newStage) { + return false; + } + + return $this->dealModel->update($dealId, ['stage_id' => $newStageId]); + } + + /** + * Мягко удалить сделку + */ + public function deleteDeal(int $dealId, int $userId): bool + { + $deal = $this->dealModel->find($dealId); + if (!$deal) { + return false; + } + + return $this->dealModel->delete($dealId); + } + + /** + * Восстановить удалённую сделку + */ + public function restoreDeal(int $dealId, int $userId): bool + { + return $this->dealModel->delete($dealId, false); + } + + /** + * Получить сделку по ID + */ + public function getDeal(int $dealId): ?array + { + return $this->dealModel->find($dealId); + } + + /** + * Получить сделки с фильтрами + */ + public function getDeals( + int $organizationId, + ?int $stageId = null, + ?int $assignedUserId = null, + ?string $search = null, + ?string $dateFrom = null, + ?string $dateTo = null + ): array { + return $this->dealModel->getDealsByOrganization( + $organizationId, + $stageId, + $assignedUserId, + $search, + $dateFrom, + $dateTo + ); + } + + /** + * Получить сделки для Канбана + */ + public function getDealsForKanban(int $organizationId): array + { + return $this->dealModel->getDealsGroupedByStage($organizationId); + } + + /** + * Получить сделки для календаря + */ + public function getDealsForCalendar(int $organizationId, string $month): array + { + return $this->dealModel->getDealsForCalendar($organizationId, $month); + } + + /** + * Получить статистику по сделкам + */ + public function getStats(int $organizationId): array + { + return $this->dealModel->getDealStats($organizationId); + } + + /** + * Получить сделку по ID с JOIN-ами + */ + public function getDealWithJoins(int $dealId, int $organizationId): ?array + { + return $this->dealModel->getWithJoins($dealId, $organizationId); + } +} diff --git a/app/Modules/CRM/Services/DealStageService.php b/app/Modules/CRM/Services/DealStageService.php new file mode 100644 index 0000000..bee55f3 --- /dev/null +++ b/app/Modules/CRM/Services/DealStageService.php @@ -0,0 +1,99 @@ +stageModel = new DealStageModel(); + } + + /** + * Создать новый этап + */ + public function createStage(array $data): int + { + $data['order_index'] = $this->stageModel->getNextOrderIndex($data['organization_id']); + + return $this->stageModel->insert($data); + } + + /** + * Обновить этап + */ + public function updateStage(int $stageId, array $data): bool + { + return $this->stageModel->update($stageId, $data); + } + + /** + * Удалить этап (мягкое удаление) + */ + public function deleteStage(int $stageId): bool + { + return $this->stageModel->delete($stageId); + } + + /** + * Получить этап по ID + */ + public function getStage(int $stageId): ?array + { + return $this->stageModel->find($stageId); + } + + /** + * Получить все этапы организации + */ + public function getOrganizationStages(int $organizationId): array + { + return $this->stageModel->getStagesByOrganization($organizationId); + } + + /** + * Изменить порядок этапов + */ + public function reorderStages(int $organizationId, array $stageOrders): bool + { + foreach ($stageOrders as $order => $stageId) { + $this->stageModel->update($stageId, ['order_index' => $order]); + } + + return true; + } + + /** + * Проврить, можно ли удалить этап (есть ли сделки на этом этапе) + */ + public function canDeleteStage(int $stageId): bool + { + $dealModel = new DealModel(); + $count = $dealModel->where('stage_id', $stageId) + ->where('deleted_at', null) + ->countAllResults(); + + return $count === 0; + } + + /** + * Инициализировать этапы по умолчанию для новой организации + */ + public function initializeDefaultStages(int $organizationId): array + { + return $this->stageModel->createDefaultStages($organizationId); + } + + /** + * Получить список этапов для выпадающего списка + */ + public function getStagesList(int $organizationId): array + { + return $this->stageModel->getStagesList($organizationId); + } +} diff --git a/app/Modules/CRM/Views/contacts/form.twig b/app/Modules/CRM/Views/contacts/form.twig new file mode 100644 index 0000000..87edfbe --- /dev/null +++ b/app/Modules/CRM/Views/contacts/form.twig @@ -0,0 +1,127 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+

{{ title }}

+ + К списку + +
+ + {# Сообщения об ошибках #} + {% if errors is defined and errors|length > 0 %} + + {% endif %} + +
+
+
+ {{ csrf_field()|raw }} + + {% if contact is defined %} + + {% endif %} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ +
+ Отмена + +
+
+
+
+
+
+{% endblock %} diff --git a/app/Modules/CRM/Views/contacts/index.twig b/app/Modules/CRM/Views/contacts/index.twig new file mode 100644 index 0000000..939cfbb --- /dev/null +++ b/app/Modules/CRM/Views/contacts/index.twig @@ -0,0 +1,89 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} + + +{# Сообщения #} +{% if success is defined %} + +{% 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 }} + + +
+
+ Контактов пока нет +
+
+
+
+{% endblock %} diff --git a/app/Modules/CRM/Views/dashboard.twig b/app/Modules/CRM/Views/dashboard.twig new file mode 100644 index 0000000..2de532d --- /dev/null +++ b/app/Modules/CRM/Views/dashboard.twig @@ -0,0 +1,114 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

CRM

+

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

+
+
+ +{# Статистика #} +
+
+
+
+
{{ stats.open_count }}
+
Открытых сделок
+
{{ stats.open_total|number_format(0, ',', ' ') }} ₽
+
+
+
+
+
+
+
{{ stats.won_count }}
+
Успешных сделок
+
{{ stats.won_total|number_format(0, ',', ' ') }} ₽
+
+
+
+
+
+
+
{{ counts.clients }}
+
Клиентов
+
+
+
+
+
+
+
{{ counts.contacts }}
+
Контактов
+
+
+
+
+ +{# Меню #} + +{% endblock %} diff --git a/app/Modules/CRM/Views/deals/calendar.twig b/app/Modules/CRM/Views/deals/calendar.twig new file mode 100644 index 0000000..f7c3a16 --- /dev/null +++ b/app/Modules/CRM/Views/deals/calendar.twig @@ -0,0 +1,77 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

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

+
+ + Новая сделка + +
+ +{# Переключатель видов #} + + +{# Календарь #} +{{ include('@components/calendar/calendar.twig', { + eventsByDate: eventsByDate, + currentMonth: currentMonth, + monthName: monthName, + daysInMonth: daysInMonth, + firstDayOfWeek: firstDayOfWeek, + today: today, + prevMonth: site_url('/crm/deals/calendar?month=' ~ prevMonth), + nextMonth: site_url('/crm/deals/calendar?month=' ~ nextMonth), + showNavigation: true, + showLegend: true, + legend: calendarLegend, + eventComponent: '@Deals/calendar_event.twig' +}) }} +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} diff --git a/app/Modules/CRM/Views/deals/calendar_event.twig b/app/Modules/CRM/Views/deals/calendar_event.twig new file mode 100644 index 0000000..64e32b8 --- /dev/null +++ b/app/Modules/CRM/Views/deals/calendar_event.twig @@ -0,0 +1,9 @@ +{# + calendar_event.twig - Событие календаря для сделки +#} + + {{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }} + diff --git a/app/Modules/CRM/Views/deals/form.twig b/app/Modules/CRM/Views/deals/form.twig new file mode 100644 index 0000000..a6f3d8b --- /dev/null +++ b/app/Modules/CRM/Views/deals/form.twig @@ -0,0 +1,217 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+

{{ title }}

+ + Назад + +
+ + {# Сообщения об ошибках #} + {% if errors is defined and errors|length > 0 %} + + {% endif %} + +
+
+
+ {{ csrf_field()|raw }} + + {% if deal is defined %} + + {% endif %} + +
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ Отмена + +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Modules/CRM/Views/deals/index.twig b/app/Modules/CRM/Views/deals/index.twig new file mode 100644 index 0000000..0a8e0cc --- /dev/null +++ b/app/Modules/CRM/Views/deals/index.twig @@ -0,0 +1,76 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

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

+
+ + Новая сделка + +
+ +{# Переключатель видов #} + + +
+
+ {{ tableHtml|raw }} + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} +
+
+{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + +{% endblock %} diff --git a/app/Modules/CRM/Views/deals/kanban.twig b/app/Modules/CRM/Views/deals/kanban.twig new file mode 100644 index 0000000..aea5f07 --- /dev/null +++ b/app/Modules/CRM/Views/deals/kanban.twig @@ -0,0 +1,51 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

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

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

{{ deal.title }}

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

{{ deal.description }}

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

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

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

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

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

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

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

Не назначен

+ {% endif %} +
+
+ + {# Вероятность #} + {% if deal.stage_probability is defined and deal.stage_probability > 0 %} +
+
+
Вероятность закрытия
+
+
+
+
+
+
+ {{ deal.stage_probability }}% +
+
+
+ {% endif %} + + {# Действия #} +
+
+ + Редактировать + +
+ {{ csrf_field()|raw }} + + +
+
+
+
+
+{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} diff --git a/app/Modules/CRM/Views/deals/stages.twig b/app/Modules/CRM/Views/deals/stages.twig new file mode 100644 index 0000000..f6c7bd1 --- /dev/null +++ b/app/Modules/CRM/Views/deals/stages.twig @@ -0,0 +1,195 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+

Настройка воронки продаж

+
+ + К сделкам + +
+ +{# Сообщения #} +{% if success is defined %} + +{% endif %} + +{% if error is defined %} + +{% endif %} + +{# Форма добавления этапа #} +
+
+
Добавить этап
+
+
+
+ {{ csrf_field()|raw }} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{# Список этапов #} +
+
+
Этапы
+
+
+
+ + + + + + + + + + + + {% for stage in stages %} + + + + + + + + {% endfor %} + +
ПорядокЭтапТипВероятностьДействия
{{ stage.order_index + 1 }} +
+ + {{ stage.name }} +
+
+ + {{ stage.type_label }} + + {{ stage.probability }}% + + {% if not stage.is_final %} +
+ {{ csrf_field()|raw }} + + +
+ {% else %} + + {% endif %} +
+
+
+
+ +{# Модальное окно редактирования #} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + +{% endblock %} diff --git a/app/Services/InvitationService.php b/app/Services/InvitationService.php index ccfaa50..7294006 100644 --- a/app/Services/InvitationService.php +++ b/app/Services/InvitationService.php @@ -87,6 +87,7 @@ class InvitationService // Генерируем токен приглашения $inviteToken = $this->generateToken(); + $inviteExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); // Создаем запись приглашения $invitationData = [ @@ -95,6 +96,7 @@ class InvitationService 'role' => $role, 'status' => OrganizationUserModel::STATUS_PENDING, 'invite_token' => $inviteToken, + 'invite_expires_at' => $inviteExpiresAt, 'invited_by' => $invitedBy, ]; @@ -238,8 +240,10 @@ class InvitationService // Генерируем новый токен $newToken = $this->generateToken(); + $newExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); $this->orgUserModel->update($invitationId, [ 'invite_token' => $newToken, + 'invite_expires_at' => $newExpiresAt, 'invited_at' => date('Y-m-d H:i:s'), ]); @@ -290,6 +294,7 @@ class InvitationService protected function createShadowUser(string $email): int { $token = bin2hex(random_bytes(32)); + $tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours')); return $this->userModel->insert([ 'email' => $email, @@ -297,6 +302,7 @@ class InvitationService 'password' => null, // Без пароля до регистрации 'email_verified' => 0, 'verification_token' => $token, + 'token_expires_at' => $tokenExpiresAt, 'created_at' => date('Y-m-d H:i:s'), ]); } @@ -367,7 +373,7 @@ class InvitationService

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

-

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

+

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