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 %}
+
+
+ {% for error in errors %}
+ - {{ error }}
+ {% endfor %}
+
+
+
+ {% 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 %}
+
+
+
{{ title }}
+
+
+ Добавить контакт
+
+
+
+{# Сообщения #}
+{% if success is defined %}
+
+ {{ success }}
+
+
+{% endif %}
+
+
+
+
+
+
+
+ | Имя |
+ Email |
+ Телефон |
+ Должность |
+ Клиент |
+ Действия |
+
+
+
+ {% for contact in contacts %}
+
+
+
+
+ {{ 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 %}
+ |
+
+
+
+
+
+ |
+
+ {% else %}
+
+ |
+ Контактов пока нет
+ |
+
+ {% endfor %}
+
+
+
+
+
+{% 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 %}
+
+
+ {% for error in errors %}
+ - {{ error }}
+ {% endfor %}
+
+
+
+ {% 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) }}
+
+
+
+ {% 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 %}
+
+ {# Действия #}
+
+
+
+{% 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 %}
+
+ {{ success }}
+
+
+{% endif %}
+
+{% if error is defined %}
+
+ {{ error }}
+
+
+{% endif %}
+
+{# Форма добавления этапа #}
+
+
+{# Список этапов #}
+
+
+
+
+
+
+
+ | Порядок |
+ Этап |
+ Тип |
+ Вероятность |
+ Действия |
+
+
+
+ {% for stage in stages %}
+
+ | {{ stage.order_index + 1 }} |
+
+
+
+ {{ stage.name }}
+
+ |
+
+
+ {{ stage.type_label }}
+
+ |
+ {{ stage.probability }}% |
+
+
+ {% if not stage.is_final %}
+
+ {% else %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+{# Модальное окно редактирования #}
+
+
+
+
+
+
+
+{% 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
Если кнопка не работает, скопируйте ссылку и откройте в браузере:
{$inviteLink}
- Ссылка действительна 48 часов.
+ Ссылка действительна 7 дней.