Добавлен универсальный поиск клиента и быстрое создание нового клиента

Реализованы следующие функции:
- AJAX API endpoint для поиска клиента по имени, телефону или email одновременно
- AJAX API endpoint для создания нового клиента прямо при создании заказа
- Интерактивная форма поиска в поле "Клиент" с использованием Select2
- При отсутствии результатов поиска предлагается создать нового клиента с автоматическим заполнением формы введенными данными
- Модальное окно для создания клиента во всплывающем окне (не на отдельной странице)
- Автоматический выбор созданного клиента после сохранения

Изменения:
1. customers/views.py - добавлены endpoints api_search_customers и api_create_customer
2. customers/urls.py - добавлены URL маршруты для новых endpoints
3. orders/templates/orders/order_form.html - обновлена инициализация Select2 для поиска, добавлено модальное окно и стили

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 22:23:11 +03:00
parent c8923970ea
commit 74ece6dd66
3 changed files with 443 additions and 5 deletions

View File

@@ -9,4 +9,8 @@ urlpatterns = [
path('<int:pk>/', views.customer_detail, name='customer-detail'), path('<int:pk>/', views.customer_detail, name='customer-detail'),
path('<int:pk>/edit/', views.customer_update, name='customer-update'), path('<int:pk>/edit/', views.customer_update, name='customer-update'),
path('<int:pk>/delete/', views.customer_delete, name='customer-delete'), path('<int:pk>/delete/', views.customer_delete, name='customer-delete'),
# AJAX API endpoints
path('api/search/', views.api_search_customers, name='api-search-customers'),
path('api/create/', views.api_create_customer, name='api-create-customer'),
] ]

View File

@@ -3,7 +3,11 @@ from django.contrib import messages
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
import phonenumbers import phonenumbers
import json
from .models import Customer, Address from .models import Customer, Address
from .forms import CustomerForm from .forms import CustomerForm
@@ -92,14 +96,186 @@ def customer_update(request, pk):
def customer_delete(request, pk): def customer_delete(request, pk):
"""Удаление клиента""" """Удаление клиента"""
customer = get_object_or_404(Customer, pk=pk) customer = get_object_or_404(Customer, pk=pk)
if request.method == 'POST': if request.method == 'POST':
customer_name = customer.full_name customer_name = customer.full_name
customer.delete() customer.delete()
messages.success(request, f'Клиент {customer_name} успешно удален.') messages.success(request, f'Клиент {customer_name} успешно удален.')
return redirect('customers:customer-list') return redirect('customers:customer-list')
context = { context = {
'customer': customer 'customer': customer
} }
return render(request, 'customers/customer_confirm_delete.html', context) return render(request, 'customers/customer_confirm_delete.html', context)
# === AJAX API ENDPOINTS ===
@require_http_methods(["GET"])
@login_required
def api_search_customers(request):
"""
AJAX endpoint для поиска клиента по имени, телефону или email.
Параметры GET:
- q: поисковая строка
Возвращает JSON с результатами поиска:
{
"results": [
{"id": 1, "text": "Иван Петров (+375291234567)", "name": "Иван Петров", "phone": "+375291234567", "email": "ivan@example.com"},
...
],
"pagination": {"more": false}
}
Если ничего не найдено и заданы параметры поиска, возвращает:
{
"results": [
{"id": null, "text": "Создать клиента: 'Поиск'", "is_create_option": true, "search_text": "Поиск"}
],
"pagination": {"more": false}
}
"""
query = request.GET.get('q', '').strip()
if not query or len(query) < 1:
return JsonResponse({
'results': [],
'pagination': {'more': False}
})
# Пытаемся нормализовать номер телефона для поиска
phone_normalized = normalize_query_phone(query)
# Ищем по имени, email или телефону
customers = Customer.objects.filter(
Q(name__icontains=query) |
Q(email__icontains=query) |
Q(phone__icontains=phone_normalized)
).order_by('name')[:20] # Ограничиваем 20 результатами
results = []
# Добавляем найденные клиентов
for customer in customers:
phone_display = str(customer.phone) if customer.phone else ''
text = customer.name
if phone_display:
text += f' ({phone_display})'
results.append({
'id': customer.pk,
'text': text,
'name': customer.name,
'phone': phone_display,
'email': customer.email,
})
# Если ничего не найдено, предлагаем создать нового клиента
if not results:
results.append({
'id': None,
'text': f'Создать клиента: "{query}"',
'is_create_option': True,
'search_text': query,
})
return JsonResponse({
'results': results,
'pagination': {'more': False}
})
@require_http_methods(["POST"])
@login_required
def api_create_customer(request):
"""
AJAX endpoint для создания нового клиента.
Принимает POST JSON:
{
"name": "Иван Петров",
"phone": "+375291234567",
"email": "ivan@example.com"
}
Возвращает JSON:
{
"success": true,
"id": 123,
"name": "Иван Петров",
"phone": "+375291234567",
"email": "ivan@example.com"
}
При ошибке:
{
"success": false,
"error": "Клиент с таким номером телефона уже существует"
}
"""
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
phone = data.get('phone', '').strip()
email = data.get('email', '').strip()
# Валидация: имя обязательно
if not name:
return JsonResponse({
'success': False,
'error': 'Имя клиента обязательно'
}, status=400)
# Нормализуем телефон если он указан
if phone:
phone = normalize_query_phone(phone)
# Проверяем, не существует ли уже клиент с таким телефоном
if phone and Customer.objects.filter(phone=phone).exists():
return JsonResponse({
'success': False,
'error': 'Клиент с таким номером телефона уже существует'
}, status=400)
# Проверяем, не существует ли уже клиент с таким email
if email and Customer.objects.filter(email=email).exists():
return JsonResponse({
'success': False,
'error': 'Клиент с таким email уже существует'
}, status=400)
# Создаем нового клиента
customer = Customer.objects.create(
name=name,
phone=phone if phone else None,
email=email if email else None
)
phone_display = str(customer.phone) if customer.phone else ''
return JsonResponse({
'success': True,
'id': customer.pk,
'name': customer.name,
'phone': phone_display,
'email': customer.email,
}, status=201)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except ValidationError as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка сервера: {str(e)}'
}, status=500)

View File

@@ -15,6 +15,31 @@
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
/* Стили для поиска клиента */
.customer-option {
padding: 5px 0;
}
.customer-create-option {
color: #28a745;
font-weight: 500;
padding: 5px 0;
}
.customer-create-option i {
margin-right: 5px;
}
/* Select2 dropdown styling */
.select2-results__option.customer-option-item {
padding: 8px 8px;
border-bottom: 1px solid #f1f1f1;
}
.select2-results__option.customer-option-item:last-child {
border-bottom: none;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -374,7 +399,84 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Инициализация Select2 для обычных полей (не товары) // Инициализация Select2 для поля customer с поиском
$('#{{ form.customer.id_for_label }}').select2({
theme: 'bootstrap-5',
width: '100%',
language: 'ru',
placeholder: 'Начните вводить имя, телефон или email',
minimumInputLength: 1,
ajax: {
url: '{% url "customers:api-search-customers" %}',
dataType: 'json',
delay: 250,
data: function(params) {
return {
q: params.term,
page: params.page || 1
};
},
processResults: function(data) {
return {
results: data.results,
pagination: {
more: data.pagination.more
}
};
},
cache: true
},
templateResult: formatCustomerOption,
templateSelection: formatCustomerSelection,
escapeMarkup: function(markup) { return markup; }
});
// Форматирование опции в списке
function formatCustomerOption(option) {
if (!option.id) {
return option.text;
}
if (option.is_create_option) {
return '<div class="customer-create-option"><i class="bi bi-plus-circle"></i> ' + option.text + '</div>';
}
let html = '<div class="customer-option">';
html += '<strong>' + option.name + '</strong>';
if (option.phone) {
html += '<br><small class="text-muted">Телефон: ' + option.phone + '</small>';
}
if (option.email) {
html += '<br><small class="text-muted">Email: ' + option.email + '</small>';
}
html += '</div>';
return html;
}
// Форматирование выбранного значения
function formatCustomerSelection(option) {
if (!option.id) {
return option.text;
}
if (option.is_create_option) {
return option.text;
}
return option.name;
}
// Обработка выбора в Select2
$('#{{ form.customer.id_for_label }}').on('select2:select', function(e) {
const data = e.params.data;
if (data.is_create_option) {
// Очищаем select2
$(this).val(null).trigger('change');
// Открываем модальное окно для создания клиента
openCreateCustomerModal(data.search_text);
}
});
// Инициализация Select2 для остальных полей
$('.select2:not(.select2-order-item)').select2({ $('.select2:not(.select2-order-item)').select2({
theme: 'bootstrap-5', theme: 'bootstrap-5',
width: '100%', width: '100%',
@@ -633,6 +735,114 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// === СОЗДАНИЕ НОВОГО КЛИЕНТА ===
// Функция открытия модального окна создания клиента
window.openCreateCustomerModal = function(searchText = '') {
// Заполняем форму предложенными данными
if (searchText) {
// Пытаемся распознать введенные данные
const phoneRegex = /[\d\s\-\+\(\)]+/;
const emailRegex = /[^\s@]+@[^\s@]+\.[^\s@]+/;
// Ищем email и телефон в строке поиска
const emailMatch = searchText.match(emailRegex);
const phoneMatch = searchText.match(phoneRegex);
// Если это похоже на email, заполняем email
if (emailMatch) {
document.getElementById('customer-email').value = emailMatch[0];
}
// Если это похоже на телефон (много цифр), заполняем телефон
else if (phoneMatch && phoneMatch[0].replace(/\D/g, '').length >= 9) {
document.getElementById('customer-phone').value = phoneMatch[0];
}
// Иначе считаем это имя
else {
document.getElementById('customer-name').value = searchText;
}
}
// Очищаем поля которые не заполнены
const customerNameInput = document.getElementById('customer-name');
const customerPhoneInput = document.getElementById('customer-phone');
const customerEmailInput = document.getElementById('customer-email');
const createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
// Очищаем сообщения об ошибках
document.getElementById('customer-form-errors').innerHTML = '';
document.getElementById('customer-form-errors').style.display = 'none';
createCustomerModal.show();
};
// Обработчик сохранения нового клиента
document.getElementById('save-customer-btn').addEventListener('click', function() {
const name = document.getElementById('customer-name').value.trim();
const phone = document.getElementById('customer-phone').value.trim();
const email = document.getElementById('customer-email').value.trim();
// Базовая валидация
const errors = [];
if (!name) {
errors.push('Имя клиента обязательно');
}
if (errors.length > 0) {
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<ul class="mb-0">' + errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
errorDiv.style.display = 'block';
return;
}
// Отправляем AJAX запрос
fetch('{% url "customers:api-create-customer" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
name: name,
phone: phone || null,
email: email || null
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
modal.hide();
// Выбираем созданного клиента в Select2
const $customerSelect = $('#{{ form.customer.id_for_label }}');
const newOption = new Option(data.name, data.id, true, true);
$customerSelect.append(newOption).trigger('change');
// Очищаем форму
document.getElementById('customer-name').value = '';
document.getElementById('customer-phone').value = '';
document.getElementById('customer-email').value = '';
document.getElementById('customer-form-errors').innerHTML = '';
document.getElementById('customer-form-errors').style.display = 'none';
// Показываем успешное сообщение
alert(`Клиент "${data.name}" успешно создан!`);
} else {
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">' + data.error + '</div>';
errorDiv.style.display = 'block';
}
})
.catch(error => {
console.error('Error:', error);
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">Ошибка при создании клиента: ' + error.message + '</div>';
errorDiv.style.display = 'block';
});
});
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ === // === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
// Модальное окно для создания временного комплекта // Модальное окно для создания временного комплекта
@@ -757,7 +967,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Отправляем AJAX запрос // Отправляем AJAX запрос
fetch('{% url "orders:temporary-kit-create" %}', { fetch('{% url "products:api-temporary-kit-create" %}', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -874,6 +1084,54 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
<!-- Модальное окно для создания нового клиента -->
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createCustomerModalLabel">
<i class="bi bi-person-plus"></i> Создать нового клиента
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="customer-form-errors" style="display: none;" class="alert alert-danger"></div>
<div class="mb-3">
<label for="customer-name" class="form-label">
Имя клиента <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="customer-name"
placeholder="Например: Иван Петров">
</div>
<div class="mb-3">
<label for="customer-phone" class="form-label">Телефон</label>
<input type="tel" class="form-control" id="customer-phone"
placeholder="+375291234567 или 8(029)1234567">
<small class="form-text text-muted">
Введите в любом формате, будет автоматически преобразован
</small>
</div>
<div class="mb-3">
<label for="customer-email" class="form-label">Email</label>
<input type="email" class="form-control" id="customer-email"
placeholder="ivan@example.com">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Отмена
</button>
<button type="button" class="btn btn-success" id="save-customer-btn">
<i class="bi bi-check-circle"></i> Создать клиента
</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно для создания временного комплекта --> <!-- Модальное окно для создания временного комплекта -->
<div class="modal fade" id="tempKitModal" tabindex="-1" aria-labelledby="tempKitModalLabel" aria-hidden="true"> <div class="modal fade" id="tempKitModal" tabindex="-1" aria-labelledby="tempKitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">