add CRM module start
This commit is contained in:
parent
b14f293a45
commit
b810a17649
|
|
@ -86,8 +86,8 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
$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/Clients/Config/Routes.php';
|
||||||
|
require_once APPPATH . 'Modules/CRM/Config/Routes.php';
|
||||||
});
|
});
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin)
|
# СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,10 @@ class Twig extends \Daycry\Twig\Config\Twig
|
||||||
public array $paths = [
|
public array $paths = [
|
||||||
APPPATH . 'Views', // Добавляем стандартную папку Views в первую очередь
|
APPPATH . 'Views', // Добавляем стандартную папку Views в первую очередь
|
||||||
[APPPATH . 'Views/components', 'components'], // Компоненты таблиц
|
[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'], // Контакты
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ class Auth extends BaseController
|
||||||
|
|
||||||
// Генерируем токен для подтверждения email
|
// Генерируем токен для подтверждения email
|
||||||
$verificationToken = bin2hex(random_bytes(32));
|
$verificationToken = bin2hex(random_bytes(32));
|
||||||
|
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||||||
|
|
||||||
// 1. Создаем пользователя с токеном верификации
|
// 1. Создаем пользователя с токеном верификации
|
||||||
$userData = [
|
$userData = [
|
||||||
|
|
@ -133,6 +134,7 @@ class Auth extends BaseController
|
||||||
'email' => $this->request->getPost('email'),
|
'email' => $this->request->getPost('email'),
|
||||||
'password' => $this->request->getPost('password'), // Хешируется в модели
|
'password' => $this->request->getPost('password'), // Хешируется в модели
|
||||||
'verification_token' => $verificationToken,
|
'verification_token' => $verificationToken,
|
||||||
|
'token_expires_at' => $tokenExpiresAt,
|
||||||
'email_verified' => 0,
|
'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']) {
|
if ($user['email_verified']) {
|
||||||
return $this->renderTwig('auth/verify_error', [
|
return $this->renderTwig('auth/verify_error', [
|
||||||
'message' => 'Email уже подтверждён. Вы можете войти в систему.'
|
'message' => 'Email уже подтверждён. Вы можете войти в систему.'
|
||||||
|
|
@ -281,8 +290,10 @@ class Auth extends BaseController
|
||||||
|
|
||||||
// Генерируем новый токен
|
// Генерируем новый токен
|
||||||
$newToken = bin2hex(random_bytes(32));
|
$newToken = bin2hex(random_bytes(32));
|
||||||
|
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||||||
$userModel->update($user['id'], [
|
$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');
|
$remember = $this->request->getPost('remember');
|
||||||
|
$redirectUrl = count($userOrgs) === 1 ? '/' : '/organizations';
|
||||||
|
|
||||||
if ($remember) {
|
if ($remember) {
|
||||||
$this->createRememberToken($user['id']);
|
$redirectUrl = $this->createRememberTokenAndRedirect($user['id'], $redirectUrl);
|
||||||
// Устанавливаем сессию на 30 дней
|
|
||||||
$this->session->setExpiry(30 * 24 * 60 * 60); // 30 дней в секундах
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ
|
// АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ
|
||||||
|
|
@ -362,7 +373,9 @@ class Auth extends BaseController
|
||||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
||||||
$this->resetRateLimit('login');
|
$this->resetRateLimit('login');
|
||||||
|
|
||||||
return redirect()->to('/');
|
return $redirectUrl !== '/'
|
||||||
|
? redirect()->to($redirectUrl)
|
||||||
|
: redirect()->to('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ОЧИЩАЕМ active_org_id если несколько организаций
|
// ОЧИЩАЕМ active_org_id если несколько организаций
|
||||||
|
|
@ -378,7 +391,9 @@ class Auth extends BaseController
|
||||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
||||||
$this->resetRateLimit('login');
|
$this->resetRateLimit('login');
|
||||||
|
|
||||||
return redirect()->to('/organizations');
|
return $redirectUrl !== '/' && $redirectUrl !== '/organizations'
|
||||||
|
? redirect()->to($redirectUrl)
|
||||||
|
: redirect()->to('/organizations');
|
||||||
} else {
|
} else {
|
||||||
// === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ ===
|
// === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ ===
|
||||||
$limitExceeded = $this->recordFailedAttempt('login');
|
$limitExceeded = $this->recordFailedAttempt('login');
|
||||||
|
|
@ -420,8 +435,9 @@ class Auth extends BaseController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создание remember-токена для автологина
|
* Создание remember-токена для автологина
|
||||||
|
* Возвращает массив с selector и validator
|
||||||
*/
|
*/
|
||||||
protected function createRememberToken(int $userId): void
|
protected function createRememberTokenData(int $userId): array
|
||||||
{
|
{
|
||||||
$selector = bin2hex(random_bytes(16));
|
$selector = bin2hex(random_bytes(16));
|
||||||
$validator = bin2hex(random_bytes(32));
|
$validator = bin2hex(random_bytes(32));
|
||||||
|
|
@ -439,9 +455,24 @@ class Auth extends BaseController
|
||||||
'ip_address' => $this->request->getIPAddress(),
|
'ip_address' => $this->request->getIPAddress(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Устанавливаем cookie на 30 дней
|
return [
|
||||||
$cookie = \Config\Services::response()->setCookie('remember_selector', $selector, 30 * 24 * 60 * 60);
|
'selector' => $selector,
|
||||||
$cookie = \Config\Services::response()->setCookie('remember_token', $validator, 30 * 24 * 60 * 60);
|
'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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ abstract class BaseController extends Controller
|
||||||
|
|
||||||
// Загружаем хелпер доступа для Twig
|
// Загружаем хелпер доступа для Twig
|
||||||
helper('access');
|
helper('access');
|
||||||
|
helper('crm_deals');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -269,7 +270,7 @@ abstract class BaseController extends Controller
|
||||||
public function renderTwig($template, $data = [])
|
public function renderTwig($template, $data = [])
|
||||||
{
|
{
|
||||||
helper('csrf');
|
helper('csrf');
|
||||||
|
helper('crm_deals');
|
||||||
$twig = \Config\Services::twig();
|
$twig = \Config\Services::twig();
|
||||||
|
|
||||||
// oldInput из сессии добавляется в данные шаблона
|
// oldInput из сессии добавляется в данные шаблона
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,24 @@ class InvitationController extends BaseController
|
||||||
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
|
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
|
||||||
|
|
||||||
if (!$invitation) {
|
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', [
|
return $this->renderTwig('organizations/invitation_expired', [
|
||||||
'title' => 'Приглашение недействительно',
|
'title' => 'Приглашение недействительно',
|
||||||
]);
|
]);
|
||||||
|
|
@ -135,6 +153,24 @@ class InvitationController extends BaseController
|
||||||
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
|
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
|
||||||
|
|
||||||
if (!$invitation) {
|
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', [
|
return $this->renderTwig('organizations/invitation_expired', [
|
||||||
'title' => 'Приглашение недействительно',
|
'title' => 'Приглашение недействительно',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddTokenExpiresToUsers extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Проверяем, существует ли уже поле token_expires_at
|
||||||
|
$fields = $this->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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddInviteExpiresToOrganizationUsers extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Проверяем, существует ли уже поле invite_expires_at
|
||||||
|
$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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddStatusToOrganizations extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Проверяем, существует ли уже поле status
|
||||||
|
$fields = $this->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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateDealsTables extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Таблица этапов сделок
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateContactsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Таблица контактов
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует HEX цвет в RGBA
|
||||||
|
*/
|
||||||
|
if (!function_exists('hex2rgba')) {
|
||||||
|
function hex2rgba(string $color, float $opacity = 1): string
|
||||||
|
{
|
||||||
|
$color = ltrim($color, '#');
|
||||||
|
|
||||||
|
if (strlen($color) === 3) {
|
||||||
|
$color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hex = [hexdec($color[0] . $color[1]), hexdec($color[2] . $color[3]), hexdec($color[4] . $color[5])];
|
||||||
|
|
||||||
|
return 'rgba(' . implode(', ', $hex) . ', ' . $opacity . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматировать сумму в валюте
|
||||||
|
*/
|
||||||
|
if (!function_exists('format_currency')) {
|
||||||
|
function format_currency(float $amount, string $currency = 'RUB'): string
|
||||||
|
{
|
||||||
|
$locale = locale_get_default() ?: 'ru_RU';
|
||||||
|
$formatter = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
|
||||||
|
return $formatter->formatCurrency($amount, $currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текущую организацию
|
||||||
|
*/
|
||||||
|
function current_organization_id(): ?int
|
||||||
|
{
|
||||||
|
$session = session();
|
||||||
|
$orgId = $session->get('active_org_id');
|
||||||
|
return $orgId ? (int) $orgId : null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RateLimitIdentifier — генератор идентификаторов для rate limiting
|
||||||
|
*
|
||||||
|
* Использует комбинацию:
|
||||||
|
* - Cookie token (если есть) — персональный идентификатор браузера
|
||||||
|
* - IP адрес — базовая идентификация
|
||||||
|
* - User Agent hash — дополнительная уникальность
|
||||||
|
*/
|
||||||
|
class RateLimitIdentifier
|
||||||
|
{
|
||||||
|
private const COOKIE_NAME = 'rl_token';
|
||||||
|
private const COOKIE_TTL = 365 * 24 * 3600; // 1 год
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает уникальный идентификатор для rate limiting
|
||||||
|
*
|
||||||
|
* @param string $action Действие (login, register, reset)
|
||||||
|
* @return string Хеш-идентификатор
|
||||||
|
*/
|
||||||
|
public function getIdentifier(string $action): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
// Cookie token — если есть, добавляем
|
||||||
|
$cookieToken = $_COOKIE[self::COOKIE_NAME] ?? null;
|
||||||
|
if ($cookieToken && $this->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 <<<JS
|
||||||
|
(function() {
|
||||||
|
const cookieName = 'rl_token';
|
||||||
|
|
||||||
|
// Проверяем, есть ли кука
|
||||||
|
const hasCookie = document.cookie.split('; ').find(function(row) {
|
||||||
|
return row.indexOf(cookieName + '=') === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasCookie) {
|
||||||
|
// Генерируем UUID v4 на клиенте
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateUUID();
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const cookieString = cookieName + '=' + token +
|
||||||
|
'; expires=' + date.toUTCString() +
|
||||||
|
'; path=/' +
|
||||||
|
'; SameSite=Lax' +
|
||||||
|
(location.protocol === 'https:' ? '; Secure' : '') +
|
||||||
|
'; HttpOnly';
|
||||||
|
|
||||||
|
document.cookie = cookieString;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует уникальный токен
|
||||||
|
*
|
||||||
|
* @return string 32 символа (16 байт в hex)
|
||||||
|
*/
|
||||||
|
private function generateToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет валидность токена
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function isValidToken(string $token): bool
|
||||||
|
{
|
||||||
|
// Токен должен быть 32 hex символа (16 байт)
|
||||||
|
return preg_match('/^[a-f0-9]{32}$/', $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает IP адрес клиента
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getClientIp(): string
|
||||||
|
{
|
||||||
|
$ipKeys = [
|
||||||
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||||
|
'HTTP_X_REAL_IP', // Nginx proxy
|
||||||
|
'HTTP_X_FORWARDED_FOR', // Load balancer
|
||||||
|
'REMOTE_ADDR',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($ipKeys as $key) {
|
||||||
|
if (!empty($_SERVER[$key])) {
|
||||||
|
$ips = explode(',', $_SERVER[$key]);
|
||||||
|
return trim($ips[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает хеш User Agent
|
||||||
|
*
|
||||||
|
* Используем хеш вместо полного UA для:
|
||||||
|
* - Короче значение
|
||||||
|
* - Не храним чувствительные данные
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getUserAgentHash(): string
|
||||||
|
{
|
||||||
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
|
||||||
|
// Обрезаем слишком длинные UA
|
||||||
|
if (strlen($ua) > 500) {
|
||||||
|
$ua = substr($ua, 0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return md5($ua);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ class OrganizationUserModel extends Model
|
||||||
'invite_token',
|
'invite_token',
|
||||||
'invited_by',
|
'invited_by',
|
||||||
'invited_at',
|
'invited_at',
|
||||||
|
'invite_expires_at',
|
||||||
'joined_at',
|
'joined_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ class OrganizationUserModel extends Model
|
||||||
{
|
{
|
||||||
return $this->where('invite_token', $token)
|
return $this->where('invite_token', $token)
|
||||||
->where('status', self::STATUS_PENDING)
|
->where('status', self::STATUS_PENDING)
|
||||||
|
->where('invite_expires_at >', date('Y-m-d H:i:s'))
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// CRM Module Routes
|
||||||
|
|
||||||
|
$routes->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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Controllers;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Modules\CRM\Models\ContactModel;
|
||||||
|
use App\Modules\Clients\Models\ClientModel;
|
||||||
|
|
||||||
|
class ContactsController extends BaseController
|
||||||
|
{
|
||||||
|
protected ContactModel $contactModel;
|
||||||
|
protected ClientModel $clientModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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', 'Контакт удалён');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Controllers;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Modules\CRM\Models\DealModel;
|
||||||
|
use App\Modules\CRM\Models\DealStageModel;
|
||||||
|
use App\Modules\CRM\Models\ContactModel;
|
||||||
|
use App\Modules\Clients\Models\ClientModel;
|
||||||
|
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
protected DealModel $dealModel;
|
||||||
|
protected DealStageModel $stageModel;
|
||||||
|
protected ContactModel $contactModel;
|
||||||
|
protected ClientModel $clientModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Controllers;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Modules\CRM\Models\DealModel;
|
||||||
|
use App\Modules\CRM\Models\DealStageModel;
|
||||||
|
use App\Modules\CRM\Services\DealService;
|
||||||
|
use App\Modules\CRM\Services\DealStageService;
|
||||||
|
use App\Modules\CRM\Models\ContactModel;
|
||||||
|
use App\Modules\Clients\Models\ClientModel;
|
||||||
|
|
||||||
|
class DealsController extends BaseController
|
||||||
|
{
|
||||||
|
protected DealService $dealService;
|
||||||
|
protected DealStageService $stageService;
|
||||||
|
protected DealModel $dealModel;
|
||||||
|
protected DealStageModel $stageModel;
|
||||||
|
protected ContactModel $contactModel;
|
||||||
|
protected ClientModel $clientModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Entities;
|
||||||
|
|
||||||
|
use CodeIgniter\Entity\Entity;
|
||||||
|
|
||||||
|
class Contact extends Entity
|
||||||
|
{
|
||||||
|
protected $attributes = [
|
||||||
|
'id' => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
use App\Models\Traits\TenantScopedModel;
|
||||||
|
|
||||||
|
class ContactModel extends Model
|
||||||
|
{
|
||||||
|
use TenantScopedModel;
|
||||||
|
|
||||||
|
protected $table = 'contacts';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = \App\Modules\CRM\Entities\Contact::class;
|
||||||
|
protected $useSoftDeletes = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'organization_id',
|
||||||
|
'customer_id',
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'position',
|
||||||
|
'is_primary',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
protected $deletedField = 'deleted_at';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить контакты клиента
|
||||||
|
*/
|
||||||
|
public function getContactsForCustomer(int $customerId): array
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class DealModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'deals';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useSoftDeletes = true;
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
protected $deletedField = 'deleted_at';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'organization_id',
|
||||||
|
'contact_id',
|
||||||
|
'company_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'stage_id',
|
||||||
|
'assigned_user_id',
|
||||||
|
'expected_close_date',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сделки с JOIN-ами для таблицы
|
||||||
|
*/
|
||||||
|
public function getForTable(int $organizationId): array
|
||||||
|
{
|
||||||
|
return $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class DealStageModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'deal_stages';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'organization_id',
|
||||||
|
'name',
|
||||||
|
'color',
|
||||||
|
'order_index',
|
||||||
|
'type',
|
||||||
|
'probability',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все этапы организации
|
||||||
|
*/
|
||||||
|
public function getStagesByOrganization(int $organizationId): array
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Services;
|
||||||
|
|
||||||
|
use App\Modules\CRM\Models\DealModel;
|
||||||
|
use App\Modules\CRM\Models\DealStageModel;
|
||||||
|
|
||||||
|
class DealService
|
||||||
|
{
|
||||||
|
protected DealModel $dealModel;
|
||||||
|
protected DealStageModel $stageModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\CRM\Services;
|
||||||
|
|
||||||
|
use App\Modules\CRM\Models\DealModel;
|
||||||
|
use App\Modules\CRM\Models\DealStageModel;
|
||||||
|
|
||||||
|
class DealStageService
|
||||||
|
{
|
||||||
|
protected DealStageModel $stageModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<a href="{{ site_url('/crm/contacts') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fa-solid fa-arrow-left me-2"></i>К списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Сообщения об ошибках #}
|
||||||
|
{% if errors is defined and errors|length > 0 %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ actionUrl }}">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
|
||||||
|
{% if contact is defined %}
|
||||||
|
<input type="hidden" name="_method" value="PUT">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label fw-bold">Имя *</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value="{{ old.name|default(contact.name|default('')) }}"
|
||||||
|
required
|
||||||
|
placeholder="Иван Иванов">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label fw-bold">Email</label>
|
||||||
|
<input type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ old.email|default(contact.email|default('')) }}"
|
||||||
|
placeholder="email@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="phone" class="form-label fw-bold">Телефон</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value="{{ old.phone|default(contact.phone|default('')) }}"
|
||||||
|
placeholder="+7 (999) 123-45-67">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="position" class="form-label fw-bold">Должность</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="position"
|
||||||
|
name="position"
|
||||||
|
value="{{ old.position|default(contact.position|default('')) }}"
|
||||||
|
placeholder="Менеджер, Директор...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="customer_id" class="form-label fw-bold">Клиент</label>
|
||||||
|
<select class="form-select" id="customer_id" name="customer_id">
|
||||||
|
<option value="">Не привязан</option>
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}"
|
||||||
|
{{ (old.customer_id|default(contact.customer_id|default(''))) == client.id ? 'selected' : '' }}>
|
||||||
|
{{ client.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label fw-bold">Заметки</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Дополнительная информация...">{{ old.notes|default(contact.notes|default('')) }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="is_primary"
|
||||||
|
name="is_primary"
|
||||||
|
value="1"
|
||||||
|
{{ (old.is_primary|default(contact.is_primary|default(false))) ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="is_primary">
|
||||||
|
Основной контакт клиента
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 pt-3 border-top">
|
||||||
|
<a href="{{ site_url('/crm/contacts') }}" class="btn btn-outline-secondary">Отмена</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-check me-2"></i>
|
||||||
|
{{ contact is defined ? 'Сохранить' : 'Создать' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
<a href="{{ site_url('/crm/contacts/create') }}" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-plus me-2"></i>Добавить контакт
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Сообщения #}
|
||||||
|
{% if success is defined %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
<th>Должность</th>
|
||||||
|
<th>Клиент</th>
|
||||||
|
<th class="text-end" style="width: 120px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for contact in contacts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 32px; height: 32px; min-width: 32px;">
|
||||||
|
<span class="text-white small">{{ contact.name|slice(0, 2)|upper }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="fw-medium">{{ contact.name }}</span>
|
||||||
|
{% if contact.is_primary %}
|
||||||
|
<span class="badge bg-success" style="font-size: 0.7rem;">Основной</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ contact.email ?: '—' }}</td>
|
||||||
|
<td>{{ contact.phone ?: '—' }}</td>
|
||||||
|
<td>{{ contact.position ?: '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if contact.customer_id %}
|
||||||
|
<span class="text-muted">{{ contact.customer_id }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ site_url('/crm/contacts/' ~ contact.id ~ '/edit') }}"
|
||||||
|
class="btn btn-outline-primary btn-sm" title="Редактировать">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</a>
|
||||||
|
<form action="{{ site_url('/crm/contacts/' ~ contact.id) }}" method="POST" class="d-inline">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Удалить контакт?')" title="Удалить">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4 text-muted">
|
||||||
|
Контактов пока нет
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">CRM</h1>
|
||||||
|
<p class="text-muted mb-0">Управление продажами и клиентами</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Статистика #}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="display-6 fw-bold text-primary">{{ stats.open_count }}</div>
|
||||||
|
<div class="text-muted">Открытых сделок</div>
|
||||||
|
<div class="text-success small">{{ stats.open_total|number_format(0, ',', ' ') }} ₽</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="display-6 fw-bold text-success">{{ stats.won_count }}</div>
|
||||||
|
<div class="text-muted">Успешных сделок</div>
|
||||||
|
<div class="text-success small">{{ stats.won_total|number_format(0, ',', ' ') }} ₽</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="display-6 fw-bold text-secondary">{{ counts.clients }}</div>
|
||||||
|
<div class="text-muted">Клиентов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="display-6 fw-bold text-info">{{ counts.contacts }}</div>
|
||||||
|
<div class="text-muted">Контактов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Меню #}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="{{ site_url('/crm/deals') }}" class="card shadow-sm text-decoration-none h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-primary bg-opacity-10 rounded p-3">
|
||||||
|
<i class="fa-solid fa-file-contract text-primary fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-dark">Сделки</div>
|
||||||
|
<div class="text-muted small">Воронка продаж</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="{{ site_url('/crm/clients') }}" class="card shadow-sm text-decoration-none h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-success bg-opacity-10 rounded p-3">
|
||||||
|
<i class="fa-solid fa-building text-success fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-dark">Клиенты</div>
|
||||||
|
<div class="text-muted small">Компании</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="{{ site_url('/crm/contacts') }}" class="card shadow-sm text-decoration-none h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-info bg-opacity-10 rounded p-3">
|
||||||
|
<i class="fa-solid fa-users text-info fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-dark">Контакты</div>
|
||||||
|
<div class="text-muted small">Люди в компаниях</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="{{ site_url('/crm/deals/stages') }}" class="card shadow-sm text-decoration-none h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-warning bg-opacity-10 rounded p-3">
|
||||||
|
<i class="fa-solid fa-list-check text-warning fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-dark">Этапы</div>
|
||||||
|
<div class="text-muted small">Воронка продаж ({{ counts.stages }})</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<p class="text-muted mb-0">План закрытия сделок</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-plus me-2"></i>Новая сделка
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Переключатель видов #}
|
||||||
|
<ul class="nav nav-tabs mb-4">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals') }}">
|
||||||
|
<i class="fa-solid fa-list me-2"></i>Список
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals/kanban') }}">
|
||||||
|
<i class="fa-solid fa-columns me-2"></i>Канбан
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{{ site_url('/crm/deals/calendar') }}">
|
||||||
|
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Календарь #}
|
||||||
|
{{ 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() }}
|
||||||
|
<style>
|
||||||
|
.calendar-event {
|
||||||
|
display: block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-left: 3px solid #6b7280;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #374151;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-event:hover {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-events-more {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{#
|
||||||
|
calendar_event.twig - Событие календаря для сделки
|
||||||
|
#}
|
||||||
|
<a href="{{ site_url('/crm/deals/' ~ event.id) }}"
|
||||||
|
class="calendar-event"
|
||||||
|
style="border-left-color: {{ event.stage_color|default('#6B7280') }}"
|
||||||
|
title="{{ event.title }}">
|
||||||
|
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
|
||||||
|
</a>
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fa-solid fa-arrow-left me-2"></i>Назад
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Сообщения об ошибках #}
|
||||||
|
{% if errors is defined and errors|length > 0 %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for error in errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ actionUrl }}">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
|
||||||
|
{% if deal is defined %}
|
||||||
|
<input type="hidden" name="_method" value="PUT">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label fw-bold">Название сделки *</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value="{{ old.title|default(deal.title|default('')) }}"
|
||||||
|
required
|
||||||
|
placeholder="Например: Разработка сайта для ООО Ромашка">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="amount" class="form-label fw-bold">Сумма</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="amount"
|
||||||
|
name="amount"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value="{{ old.amount|default(deal.amount|default(0)) }}">
|
||||||
|
<select class="form-select" name="currency" style="max-width: 100px;">
|
||||||
|
<option value="RUB" {{ (old.currency|default(deal.currency|default('RUB'))) == 'RUB' ? 'selected' : '' }}>₽</option>
|
||||||
|
<option value="USD" {{ (old.currency|default(deal.currency|default(''))) == 'USD' ? 'selected' : '' }}>$</option>
|
||||||
|
<option value="EUR" {{ (old.currency|default(deal.currency|default(''))) == 'EUR' ? 'selected' : '' }}>€</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="stage_id" class="form-label fw-bold">Этап *</label>
|
||||||
|
<select class="form-select" id="stage_id" name="stage_id" required>
|
||||||
|
<option value="">Выберите этап</option>
|
||||||
|
{% for stage in stages %}
|
||||||
|
<option value="{{ stage.id }}"
|
||||||
|
{{ (old.stage_id|default(deal.stage_id|default(stageId|default('')))) == stage.id ? 'selected' : '' }}
|
||||||
|
data-color="{{ stage.color }}">
|
||||||
|
{{ stage.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label fw-bold">Описание</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Детали сделки, условия, комментарии...">{{ old.description|default(deal.description|default('')) }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="company_id" class="form-label fw-bold">Клиент *</label>
|
||||||
|
<select class="form-select" id="company_id" name="company_id" required>
|
||||||
|
<option value="">Выберите клиента</option>
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}"
|
||||||
|
{{ (old.company_id|default(deal.company_id|default(''))) == client.id ? 'selected' : '' }}>
|
||||||
|
{{ client.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_id" class="form-label fw-bold">Контакт</label>
|
||||||
|
<select class="form-select" id="contact_id" name="contact_id">
|
||||||
|
<option value="">Сначала выберите клиента</option>
|
||||||
|
{% for contact in contacts %}
|
||||||
|
<option value="{{ contact.id }}"
|
||||||
|
{{ (old.contact_id|default(deal.contact_id|default(''))) == contact.id ? 'selected' : '' }}>
|
||||||
|
{{ contact.name }} {{ contact.position ? ' (' ~ contact.position ~ ')' : '' }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div id="contacts-loading" class="text-muted small mt-1" style="display: none;">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin me-1"></i>Загрузка контактов...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="assigned_user_id" class="form-label fw-bold">Ответственный</label>
|
||||||
|
<select class="form-select" id="assigned_user_id" name="assigned_user_id">
|
||||||
|
<option value="">Не назначен</option>
|
||||||
|
{% for userId, userName in users %}
|
||||||
|
<option value="{{ userId }}"
|
||||||
|
{{ (old.assigned_user_id|default(deal.assigned_user_id|default(currentUserId|default('')))) == userId ? 'selected' : '' }}>
|
||||||
|
{{ userName }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="expected_close_date" class="form-label fw-bold">Ожидаемая дата закрытия</label>
|
||||||
|
<input type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="expected_close_date"
|
||||||
|
name="expected_close_date"
|
||||||
|
value="{{ old.expected_close_date|default(deal.expected_close_date|default('')) }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 pt-3 border-top">
|
||||||
|
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">Отмена</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-check me-2"></i>
|
||||||
|
{{ deal is defined ? 'Сохранить' : 'Создать сделку' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const companySelect = document.getElementById('company_id');
|
||||||
|
const contactSelect = document.getElementById('contact_id');
|
||||||
|
const contactsLoading = document.getElementById('contacts-loading');
|
||||||
|
|
||||||
|
// Функция загрузки контактов по клиенту
|
||||||
|
function loadContacts(clientId) {
|
||||||
|
if (!clientId) {
|
||||||
|
contactSelect.innerHTML = '<option value="">Сначала выберите клиента</option>';
|
||||||
|
contactSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contactSelect.disabled = true;
|
||||||
|
contactsLoading.style.display = 'block';
|
||||||
|
|
||||||
|
fetch(`/crm/deals/contacts-by-client?client_id=${clientId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
contactsLoading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success && data.contacts.length > 0) {
|
||||||
|
let options = '<option value="">Выберите контакт</option>';
|
||||||
|
data.contacts.forEach(contact => {
|
||||||
|
const label = contact.name + (contact.email ? ` (${contact.email})` : '');
|
||||||
|
options += `<option value="${contact.id}">${label}</option>`;
|
||||||
|
});
|
||||||
|
contactSelect.innerHTML = options;
|
||||||
|
contactSelect.disabled = false;
|
||||||
|
} else {
|
||||||
|
contactSelect.innerHTML = '<option value="">Нет контактов для этого клиента</option>';
|
||||||
|
contactSelect.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Ошибка загрузки контактов:', error);
|
||||||
|
contactsLoading.style.display = 'none';
|
||||||
|
contactSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||||
|
contactSelect.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем контакты если клиент уже выбран (редактирование)
|
||||||
|
const selectedClientId = companySelect.value;
|
||||||
|
if (selectedClientId) {
|
||||||
|
const selectedContactId = contactSelect.value;
|
||||||
|
if (selectedContactId) {
|
||||||
|
// При редактировании сохраняем выбранный контакт
|
||||||
|
contactSelect.dataset.selectedId = selectedContactId;
|
||||||
|
}
|
||||||
|
loadContacts(selectedClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик изменения клиента
|
||||||
|
companySelect.addEventListener('change', function() {
|
||||||
|
loadContacts(this.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Всего: {{ items|length }} |
|
||||||
|
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-plus me-2"></i>Новая сделка
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Переключатель видов #}
|
||||||
|
<ul class="nav nav-tabs mb-4">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{{ site_url('/crm/deals') }}">
|
||||||
|
<i class="fa-solid fa-list me-2"></i>Список
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals/kanban') }}">
|
||||||
|
<i class="fa-solid fa-columns me-2"></i>Канбан
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals/calendar') }}">
|
||||||
|
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{{ tableHtml|raw }}
|
||||||
|
{# CSRF токен для AJAX запросов #}
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script src="{{ site_url('/assets/js/modules/DataTable.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('.data-table').forEach(function(container) {
|
||||||
|
const id = container.id;
|
||||||
|
const url = container.dataset.url;
|
||||||
|
const perPage = parseInt(container.dataset.perPage) || 10;
|
||||||
|
|
||||||
|
if (window.dataTables && window.dataTables[id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = new DataTable(id, {
|
||||||
|
url: url,
|
||||||
|
perPage: perPage
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dataTables = window.dataTables || {};
|
||||||
|
window.dataTables[id] = table;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Открыто: {{ stats.open_count }} на {{ stats.open_total|number_format(0, ',', ' ') }} ₽ |
|
||||||
|
Успешно: {{ stats.won_count }} на {{ stats.won_total|number_format(0, ',', ' ') }} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ site_url('/crm/deals/new') }}" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-plus me-2"></i>Новая сделка
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Переключатель видов #}
|
||||||
|
<ul class="nav nav-tabs mb-4">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals') }}">
|
||||||
|
<i class="fa-solid fa-list me-2"></i>Список
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{{ site_url('/crm/deals/kanban') }}">
|
||||||
|
<i class="fa-solid fa-columns me-2"></i>Канбан
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ site_url('/crm/deals/calendar') }}">
|
||||||
|
<i class="fa-solid fa-calendar me-2"></i>Календарь
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Канбан доска #}
|
||||||
|
{{ 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() }}
|
||||||
|
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
{#
|
||||||
|
kanban_card.twig - Карточка сделки для Канбана
|
||||||
|
|
||||||
|
Используется как кастомный компонент карточки в kanban.twig
|
||||||
|
#}
|
||||||
|
<div class="card mb-2 kanban-card"
|
||||||
|
draggable="true"
|
||||||
|
data-item-id="{{ item.id }}"
|
||||||
|
style="cursor: grab;">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<a href="{{ site_url('/crm/deals/' ~ item.id) }}" class="text-decoration-none">
|
||||||
|
<strong class="text-dark">{{ item.title }}</strong>
|
||||||
|
</a>
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
₽{{ item.amount|number_format(0, ',', ' ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.contact_name or item.client_name %}
|
||||||
|
<small class="text-muted d-block mb-2">
|
||||||
|
<i class="fa-solid fa-user me-1"></i>
|
||||||
|
{{ item.contact_name|default(item.client_name) }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
{% if item.assigned_user_name %}
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa-solid fa-user-check me-1"></i>
|
||||||
|
{{ item.assigned_user_name }}
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<small></small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.expected_close_date %}
|
||||||
|
<small class="{{ item.expected_close_date < date('today') ? 'text-danger' : 'text-muted' }}">
|
||||||
|
<i class="fa-regular fa-calendar me-1"></i>
|
||||||
|
{{ item.expected_close_date|date('d.m') }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ deal.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ site_url('/crm/deals') }}" class="text-muted text-decoration-none">
|
||||||
|
<i class="fa-solid fa-arrow-left me-2"></i>К списку сделок
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# Основная информация #}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
{# Заголовок и статус #}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="badge mb-2"
|
||||||
|
style="background-color: {{ deal.stage_color }}20; color: {{ deal.stage_color }}; border: 1px solid {{ deal.stage_color }}40;">
|
||||||
|
{{ deal.stage_name|default('Без этапа') }}
|
||||||
|
</span>
|
||||||
|
<h1 class="h3 mb-0">{{ deal.title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="h4 mb-0">{{ deal.amount|number_format(0, ',', ' ') }} {{ deal.currency }}</div>
|
||||||
|
<small class="text-muted">Сумма сделки</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if deal.description %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h6 class="text-muted mb-2">Описание</h6>
|
||||||
|
<p class="mb-0" style="white-space: pre-wrap;">{{ deal.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row text-sm">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<span class="text-muted">Дата создания:</span>
|
||||||
|
<span class="ms-2">{{ deal.created_at|date('d.m.Y H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<span class="text-muted">Ожидаемое закрытие:</span>
|
||||||
|
<span class="ms-2 {{ deal.is_overdue ? 'text-danger fw-bold' : '' }}">
|
||||||
|
{{ deal.expected_close_date ? deal.expected_close_date|date('d.m.Y') : '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# История изменений #}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">История изменений</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if history is defined and history|length > 0 %}
|
||||||
|
<div class="timeline">
|
||||||
|
{% for item in history %}
|
||||||
|
<div class="timeline-item d-flex gap-3 pb-3 {{ not loop.last ? 'border-bottom' : '' }}">
|
||||||
|
<div class="timeline-icon bg-secondary rounded-circle d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 32px; height: 32px; min-width: 32px;">
|
||||||
|
<span class="text-white small">{{ item.user_name|default('С')|slice(0, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<span class="fw-medium">{{ item.user_name|default('Система') }}</span>
|
||||||
|
<small class="text-muted ms-2">{{ item.created_at|date('d.m.Y H:i') }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 mt-1">
|
||||||
|
<span class="{{ item.action_class }}">{{ item.action_label }}</span>
|
||||||
|
{% if item.change_description %}
|
||||||
|
— {{ item.change_description }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-4 mb-0">Нет записей в истории</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Боковая панель #}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
{# Клиент #}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0">Клиент</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if deal.contact_name %}
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 40px; height: 40px; min-width: 40px;">
|
||||||
|
<span class="text-white">{{ deal.contact_name|slice(0, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#" class="fw-medium text-decoration-none">{{ deal.contact_name }}</a>
|
||||||
|
{% if deal.contact_email %}
|
||||||
|
<small class="d-block text-muted">{{ deal.contact_email }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elseif deal.client_name %}
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-success rounded-circle d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 40px; height: 40px; min-width: 40px;">
|
||||||
|
<span class="text-white">{{ deal.client_name|slice(0, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#" class="fw-medium text-decoration-none">{{ deal.client_name }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Клиент не указан</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Ответственный #}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0">Ответственный</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if deal.assigned_user_name %}
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 40px; height: 40px; min-width: 40px;">
|
||||||
|
<span class="text-white">{{ deal.assigned_user_name|slice(0, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-medium">{{ deal.assigned_user_name }}</div>
|
||||||
|
{% if deal.assigned_user_email %}
|
||||||
|
<small class="text-muted">{{ deal.assigned_user_email }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Не назначен</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Вероятность #}
|
||||||
|
{% if deal.stage_probability is defined and deal.stage_probability > 0 %}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0">Вероятность закрытия</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress flex-grow-1" style="height: 8px;">
|
||||||
|
<div class="progress-bar bg-primary"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ deal.stage_probability }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fw-medium">{{ deal.stage_probability }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Действия #}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body d-flex gap-2">
|
||||||
|
<a href="{{ site_url('/crm/deals/' ~ deal.id ~ '/edit') }}" class="btn btn-outline-primary flex-grow-1">
|
||||||
|
<i class="fa-solid fa-pen me-1"></i>Редактировать
|
||||||
|
</a>
|
||||||
|
<form action="{{ site_url('/crm/deals/' ~ deal.id) }}" method="POST" class="flex-grow-1">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100"
|
||||||
|
onclick="return confirm('Удалить сделку?')">
|
||||||
|
<i class="fa-solid fa-trash me-1"></i>Удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
{% extends 'layouts/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ title }}</h1>
|
||||||
|
<p class="text-muted mb-0">Настройка воронки продаж</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ site_url('/crm/deals') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fa-solid fa-arrow-left me-2"></i>К сделкам
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Сообщения #}
|
||||||
|
{% if success is defined %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error is defined %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Форма добавления этапа #}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">Добавить этап</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ site_url('/crm/deals/stages') }}" method="POST" class="row align-items-end g-3">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="name" class="form-label fw-bold">Название</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required
|
||||||
|
placeholder="Название этапа">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="color" class="form-label fw-bold">Цвет</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="color"
|
||||||
|
name="color" value="#6B7280">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="type" class="form-label fw-bold">Тип</label>
|
||||||
|
<select class="form-select" id="type" name="type">
|
||||||
|
<option value="progress">В процессе</option>
|
||||||
|
<option value="won">Успех</option>
|
||||||
|
<option value="lost">Провал</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="probability" class="form-label fw-bold">Вероятность (%)</label>
|
||||||
|
<input type="number" class="form-control" id="probability" name="probability"
|
||||||
|
value="0" min="0" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="fa-solid fa-plus me-1"></i>Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Список этапов #}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">Этапы</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4" style="width: 60px;">Порядок</th>
|
||||||
|
<th>Этап</th>
|
||||||
|
<th style="width: 120px;">Тип</th>
|
||||||
|
<th style="width: 120px;">Вероятность</th>
|
||||||
|
<th class="text-end pe-4" style="width: 200px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for stage in stages %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4 text-muted">{{ stage.order_index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded"
|
||||||
|
style="width: 16px; height: 16px; background-color: {{ stage.color }};"></span>
|
||||||
|
<span class="fw-medium">{{ stage.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ stage.type_class }}">
|
||||||
|
{{ stage.type_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ stage.probability }}%</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
onclick="openEditModal({{ stage.id }}, '{{ stage.name }}', '{{ stage.color }}', '{{ stage.type }}', {{ stage.probability }})">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
{% if not stage.is_final %}
|
||||||
|
<form action="{{ site_url('/crm/deals/stages/' ~ stage.id) }}" method="POST" class="d-inline">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Удалить этап?')">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" disabled title="Нельзя удалить завершающий этап">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Модальное окно редактирования #}
|
||||||
|
<div class="modal fade" id="editModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="editForm" method="POST">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="_method" value="PUT">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Редактировать этап</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_name" class="form-label fw-bold">Название</label>
|
||||||
|
<input type="text" class="form-control" id="edit_name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_color" class="form-label fw-bold">Цвет</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="edit_color" name="color">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_type" class="form-label fw-bold">Тип</label>
|
||||||
|
<select class="form-select" id="edit_type" name="type">
|
||||||
|
<option value="progress">В процессе</option>
|
||||||
|
<option value="won">Успех</option>
|
||||||
|
<option value="lost">Провал</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit_probability" class="form-label fw-bold">Вероятность (%)</label>
|
||||||
|
<input type="number" class="form-control" id="edit_probability" name="probability"
|
||||||
|
min="0" max="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script>
|
||||||
|
function openEditModal(id, name, color, type, probability) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||||
|
const form = document.getElementById('editForm');
|
||||||
|
|
||||||
|
form.action = '{{ site_url('/crm/deals/stages/') }}' + id;
|
||||||
|
document.getElementById('edit_name').value = name;
|
||||||
|
document.getElementById('edit_color').value = color;
|
||||||
|
document.getElementById('edit_type').value = type;
|
||||||
|
document.getElementById('edit_probability').value = probability;
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -87,6 +87,7 @@ class InvitationService
|
||||||
|
|
||||||
// Генерируем токен приглашения
|
// Генерируем токен приглашения
|
||||||
$inviteToken = $this->generateToken();
|
$inviteToken = $this->generateToken();
|
||||||
|
$inviteExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||||
|
|
||||||
// Создаем запись приглашения
|
// Создаем запись приглашения
|
||||||
$invitationData = [
|
$invitationData = [
|
||||||
|
|
@ -95,6 +96,7 @@ class InvitationService
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
'status' => OrganizationUserModel::STATUS_PENDING,
|
'status' => OrganizationUserModel::STATUS_PENDING,
|
||||||
'invite_token' => $inviteToken,
|
'invite_token' => $inviteToken,
|
||||||
|
'invite_expires_at' => $inviteExpiresAt,
|
||||||
'invited_by' => $invitedBy,
|
'invited_by' => $invitedBy,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -238,8 +240,10 @@ class InvitationService
|
||||||
|
|
||||||
// Генерируем новый токен
|
// Генерируем новый токен
|
||||||
$newToken = $this->generateToken();
|
$newToken = $this->generateToken();
|
||||||
|
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||||
$this->orgUserModel->update($invitationId, [
|
$this->orgUserModel->update($invitationId, [
|
||||||
'invite_token' => $newToken,
|
'invite_token' => $newToken,
|
||||||
|
'invite_expires_at' => $newExpiresAt,
|
||||||
'invited_at' => date('Y-m-d H:i:s'),
|
'invited_at' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -290,6 +294,7 @@ class InvitationService
|
||||||
protected function createShadowUser(string $email): int
|
protected function createShadowUser(string $email): int
|
||||||
{
|
{
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||||||
|
|
||||||
return $this->userModel->insert([
|
return $this->userModel->insert([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
|
|
@ -297,6 +302,7 @@ class InvitationService
|
||||||
'password' => null, // Без пароля до регистрации
|
'password' => null, // Без пароля до регистрации
|
||||||
'email_verified' => 0,
|
'email_verified' => 0,
|
||||||
'verification_token' => $token,
|
'verification_token' => $token,
|
||||||
|
'token_expires_at' => $tokenExpiresAt,
|
||||||
'created_at' => date('Y-m-d H:i:s'),
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -367,7 +373,7 @@ class InvitationService
|
||||||
</p>
|
</p>
|
||||||
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
|
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
|
||||||
<p class="invite-link">{$inviteLink}</p>
|
<p class="invite-link">{$inviteLink}</p>
|
||||||
<p>Ссылка действительна 48 часов.</p>
|
<p>Ссылка действительна 7 дней.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© Бизнес.Точка</p>
|
<p>© Бизнес.Точка</p>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
{#
|
||||||
|
calendar.twig - Универсальный компонент календаря
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- events: Массив событий
|
||||||
|
Пример:
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Событие 1',
|
||||||
|
date: '2026-01-15',
|
||||||
|
color: '#3B82F6',
|
||||||
|
url: '/path/to/event'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
- currentMonth: Текущий месяц в формате YYYY-MM
|
||||||
|
- prevMonth: URL или параметр для предыдущего месяца
|
||||||
|
- nextMonth: URL или параметр для следующего месяца
|
||||||
|
- eventComponent: Имя Twig компонента для рендеринга событий (опционально)
|
||||||
|
- onEventClick: JavaScript функция при клике на событие (опционально)
|
||||||
|
- showLegend: Показывать легенду (опционально, по умолчанию true)
|
||||||
|
- legend: Массив для легенды (опционально)
|
||||||
|
Пример:
|
||||||
|
legend: [
|
||||||
|
{ name: 'Этап 1', color: '#3B82F6' }
|
||||||
|
]
|
||||||
|
#}
|
||||||
|
{# Навигация по месяцам #}
|
||||||
|
{% if showNavigation|default(true) %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
|
<a href="{{ prevMonth }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fa-solid fa-chevron-left me-1"></i>Предыдущий
|
||||||
|
</a>
|
||||||
|
<h5 class="mb-0">{{ monthName }}</h5>
|
||||||
|
<a href="{{ nextMonth }}" class="btn btn-outline-secondary">
|
||||||
|
Следующий<i class="fa-solid fa-chevron-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Календарь #}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="calendar">
|
||||||
|
{# Дни недели #}
|
||||||
|
<div class="calendar-header bg-light">
|
||||||
|
{% for day in ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] %}
|
||||||
|
<div class="calendar-header-cell text-center py-2 text-muted small fw-normal">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Сетка календаря #}
|
||||||
|
<div class="calendar-grid">
|
||||||
|
{# Пустые ячейки до первого дня #}
|
||||||
|
{% for i in 0..(firstDayOfWeek - 1) %}
|
||||||
|
<div class="calendar-cell bg-light"></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Дни месяца #}
|
||||||
|
{% for day in 1..daysInMonth %}
|
||||||
|
{% set dateStr = currentMonth ~ '-' ~ (day < 10 ? '0' ~ day : day) %}
|
||||||
|
{% set dayEvents = eventsByDate[dateStr]|default([]) %}
|
||||||
|
{% set isToday = dateStr == today %}
|
||||||
|
|
||||||
|
<div class="calendar-cell {{ isToday ? 'calendar-cell-today' : '' }}">
|
||||||
|
<div class="calendar-day-number {{ isToday ? 'text-primary fw-bold' : '' }}">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-events">
|
||||||
|
{% for event in dayEvents|slice(0, 3) %}
|
||||||
|
{% if eventComponent is defined %}
|
||||||
|
{{ include(eventComponent, {event: event}) }}
|
||||||
|
{% else %}
|
||||||
|
{{ include('@components/calendar/default_event.twig', {event: event, onEventClick: onEventClick|default('')}) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if dayEvents|length > 3 %}
|
||||||
|
<div class="calendar-events-more text-muted small">
|
||||||
|
+{{ dayEvents|length - 3 }} ещё
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Пустые ячейки после последнего дня #}
|
||||||
|
{% set remainingCells = 7 - ((firstDayOfWeek + daysInMonth) % 7) %}
|
||||||
|
{% if remainingCells < 7 %}
|
||||||
|
{% for i in 1..remainingCells %}
|
||||||
|
<div class="calendar-cell bg-light"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Легенда #}
|
||||||
|
{% if showLegend|default(true) and (legend is defined or events is defined) %}
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Легенда</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% if legend is defined %}
|
||||||
|
{% for item in legend %}
|
||||||
|
<span class="badge"
|
||||||
|
style="background-color: {{ item.color }}20; color: {{ item.color }}; border: 1px solid {{ item.color }}40;">
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{# Автоматическая легенда из типов событий #}
|
||||||
|
{% set uniqueColors = {} %}
|
||||||
|
{% for event in events %}
|
||||||
|
{% if event.color is defined and event.color not in uniqueColors %}
|
||||||
|
<span class="badge"
|
||||||
|
style="background-color: {{ event.color }}20; color: {{ event.color }}; border: 1px solid {{ event.color }}40;">
|
||||||
|
{{ event.title }}
|
||||||
|
</span>
|
||||||
|
{% set uniqueColors = uniqueColors|merge([event.color]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{#
|
||||||
|
default_event.twig - Событие по умолчанию для Календаря
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- event: Объект события
|
||||||
|
Ожидаемые поля:
|
||||||
|
- id: Идентификатор
|
||||||
|
- title: Заголовок
|
||||||
|
- date: Дата события (для сравнения с today)
|
||||||
|
- color: Цвет для бордера
|
||||||
|
- url: Ссылка (опционально)
|
||||||
|
- onEventClick: JavaScript функция при клике (опционально)
|
||||||
|
#}
|
||||||
|
{% if event.url %}
|
||||||
|
<a href="{{ event.url }}"
|
||||||
|
class="calendar-event"
|
||||||
|
style="border-left-color: {{ event.color|default('#6B7280') }}"
|
||||||
|
{% if onEventClick %}onclick="{{ onEventClick }}({{ event.id }}); return false;"{% endif %}
|
||||||
|
title="{{ event.title }}">
|
||||||
|
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="calendar-event"
|
||||||
|
style="border-left-color: {{ event.color|default('#6B7280') }}"
|
||||||
|
{% if onEventClick %}onclick="{{ onEventClick }}({{ event.id }});"{% endif %}
|
||||||
|
title="{{ event.title }}">
|
||||||
|
{{ event.title|length > 15 ? event.title|slice(0, 15) ~ '...' : event.title }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
{#
|
||||||
|
default_card.twig - Карточка по умолчанию для Канбан-компонента
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- item: Объект элемента
|
||||||
|
- column: Объект колонки (для доступа к color и т.д.)
|
||||||
|
|
||||||
|
Ожидаемые поля в item:
|
||||||
|
- id: Идентификатор
|
||||||
|
- title: Заголовок
|
||||||
|
- url: Ссылка на просмотр (опционально)
|
||||||
|
- amount: Сумма для отображения (опционально)
|
||||||
|
- date: Дата для отображения (опционально)
|
||||||
|
- assignee: Ответственный (опционально)
|
||||||
|
- status: Статус для цветовой маркировки (опционально)
|
||||||
|
#}
|
||||||
|
<div class="card mb-2 kanban-card"
|
||||||
|
draggable="true"
|
||||||
|
data-item-id="{{ item.id }}"
|
||||||
|
style="cursor: grab;">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
{# Заголовок и сумма #}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
{% if item.url %}
|
||||||
|
<a href="{{ item.url }}" class="text-decoration-none">
|
||||||
|
<strong class="text-dark">{{ item.title }}</strong>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<strong class="text-dark">{{ item.title }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.amount is defined and item.amount %}
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
₽{{ item.amount|number_format(0, ',', ' ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Дополнительная информация #}
|
||||||
|
{% if item.description is defined and item.description %}
|
||||||
|
<small class="text-muted d-block mb-2">
|
||||||
|
{{ item.description|length > 50 ? item.description|slice(0, 50) ~ '...' : item.description }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Нижняя панель #}
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
{% if item.assignee is defined and item.assignee %}
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fa-solid fa-user-check me-1"></i>
|
||||||
|
{{ item.assignee }}
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<small></small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.date is defined and item.date %}
|
||||||
|
<small class="{{ item.isOverdue is defined and item.isOverdue ? 'text-danger' : 'text-muted' }}">
|
||||||
|
<i class="fa-regular fa-calendar me-1"></i>
|
||||||
|
{{ item.date|date('d.m') }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Теги/метки #}
|
||||||
|
{% if item.tags is defined and item.tags|length > 0 %}
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-1">
|
||||||
|
{% for tag in item.tags %}
|
||||||
|
<span class="badge" style="background-color: {{ tag.color }}20; color: {{ tag.color }}; font-size: 0.65rem;">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
{#
|
||||||
|
kanban.twig - Универсальный компонент Канбан-доски
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- columns: Массив колонок с данными
|
||||||
|
Пример:
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Колонка 1',
|
||||||
|
color: '#3B82F6',
|
||||||
|
items: [...],
|
||||||
|
total: 1000,
|
||||||
|
itemLabel: 'сделка' (опционально, для грамматики)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
- cardComponent: Имя Twig компонента для рендеринга карточек (опционально)
|
||||||
|
- moveUrl: URL для API перемещения элементов (опционально)
|
||||||
|
- onMove: JavaScript функция callback при перемещении (опционально)
|
||||||
|
- emptyMessage: Сообщение при отсутствии элементов (опционально)
|
||||||
|
- addUrl: URL для добавления нового элемента (опционально)
|
||||||
|
- addLabel: Текст кнопки добавления (опционально)
|
||||||
|
#}
|
||||||
|
<div class="kanban-board overflow-auto pb-4">
|
||||||
|
<div class="d-flex gap-3" style="min-width: max-content;">
|
||||||
|
{% for column in columns %}
|
||||||
|
<div class="kanban-column" style="min-width: {{ column.width|default('320px') }}; min-height: 60vh;">
|
||||||
|
{# Заголовок колонки #}
|
||||||
|
<div class="card mb-2"
|
||||||
|
style="border-left: 4px solid {{ column.color }}; border-top: none; border-right: none; border-bottom: none;">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">{{ column.name }}</h6>
|
||||||
|
<span class="badge bg-secondary">{{ column.items|length }}</span>
|
||||||
|
</div>
|
||||||
|
{% if column.total is defined %}
|
||||||
|
<small class="text-muted">
|
||||||
|
₽{{ column.total|number_format(0, ',', ' ') }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Карточки #}
|
||||||
|
<div class="kanban-cards-container"
|
||||||
|
data-column-id="{{ column.id }}"
|
||||||
|
data-move-url="{{ moveUrl|default('') }}"
|
||||||
|
style="min-height: 50vh;"
|
||||||
|
{% if onMove is defined %}data-on-move="{{ onMove }}"{% endif %}>
|
||||||
|
{% if column.items is defined and column.items|length > 0 %}
|
||||||
|
{% for item in column.items %}
|
||||||
|
{% if cardComponent is defined %}
|
||||||
|
{{ include(cardComponent, {item: item, column: column}) }}
|
||||||
|
{% else %}
|
||||||
|
{{ include('@components/kanban/default_card.twig', {item: item, column: column}) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Кнопка добавления #}
|
||||||
|
{% if addUrl is defined or column.addUrl is defined %}
|
||||||
|
<a href="{{ column.addUrl|default(addUrl) }}?column_id={{ column.id }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm w-100 mt-2">
|
||||||
|
<i class="fa-solid fa-plus me-1"></i>
|
||||||
|
{{ addLabel|default('Добавить') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initKanban();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initKanban() {
|
||||||
|
const cards = document.querySelectorAll('.kanban-card[draggable="true"]');
|
||||||
|
const containers = document.querySelectorAll('.kanban-cards-container');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.addEventListener('dragstart', handleDragStart);
|
||||||
|
card.addEventListener('dragend', handleDragEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
containers.forEach(container => {
|
||||||
|
container.addEventListener('dragover', handleDragOver);
|
||||||
|
container.addEventListener('drop', handleDrop);
|
||||||
|
container.addEventListener('dragenter', handleDragEnter);
|
||||||
|
container.addEventListener('dragleave', handleDragLeave);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
this.classList.add('dragging');
|
||||||
|
e.dataTransfer.setData('text/plain', this.dataset.itemId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.kanban-cards-container').forEach(col => {
|
||||||
|
col.classList.remove('bg-light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.add('bg-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
this.classList.remove('bg-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.remove('bg-light');
|
||||||
|
|
||||||
|
const itemId = e.dataTransfer.getData('text/plain');
|
||||||
|
const newColumnId = this.dataset.columnId;
|
||||||
|
const moveUrl = this.dataset.moveUrl;
|
||||||
|
const onMove = this.dataset.onMove;
|
||||||
|
|
||||||
|
if (itemId && newColumnId) {
|
||||||
|
if (moveUrl) {
|
||||||
|
// AJAX перемещение
|
||||||
|
fetch(moveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: 'item_id=' + itemId + '&column_id=' + newColumnId
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
if (onMove) {
|
||||||
|
window[onMove](itemId, newColumnId, data);
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Ошибка при перемещении');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при перемещении');
|
||||||
|
});
|
||||||
|
} else if (onMove) {
|
||||||
|
// Только callback без AJAX
|
||||||
|
window[onMove](itemId, newColumnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -23,6 +23,13 @@
|
||||||
Возможно, оно истекло или было отозвано отправителем.
|
Возможно, оно истекло или было отозвано отправителем.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if expired and expired_at %}
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<i class="fa-solid fa-clock me-2"></i>
|
||||||
|
Приглашение истекло {{ expired_at|date("d.m.Y в H:i") }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<a href="/" class="btn btn-primary">
|
<a href="/" class="btn btn-primary">
|
||||||
<i class="fa-solid fa-home me-2"></i>На главную
|
<i class="fa-solid fa-home me-2"></i>На главную
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue