add CRM module start

This commit is contained in:
root
2026-01-15 07:09:36 +03:00
parent b14f293a45
commit b810a17649
40 changed files with 3807 additions and 14 deletions
+127
View File
@@ -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 %}
+89
View File
@@ -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 %}
+114
View File
@@ -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 %}
+77
View File
@@ -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>
+217
View File
@@ -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 %}
+76
View File
@@ -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 %}
+51
View File
@@ -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>
+204
View File
@@ -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 %}
+195
View File
@@ -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 %}