Добавлен универсальный поиск клиента и быстрое создание нового клиента
Реализованы следующие функции: - 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:
@@ -9,4 +9,8 @@ urlpatterns = [
|
||||
path('<int:pk>/', views.customer_detail, name='customer-detail'),
|
||||
path('<int:pk>/edit/', views.customer_update, name='customer-update'),
|
||||
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'),
|
||||
]
|
||||
@@ -3,7 +3,11 @@ from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.exceptions import ValidationError
|
||||
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 json
|
||||
from .models import Customer, Address
|
||||
from .forms import CustomerForm
|
||||
|
||||
@@ -103,3 +107,175 @@ def customer_delete(request, pk):
|
||||
'customer': customer
|
||||
}
|
||||
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)
|
||||
@@ -15,6 +15,31 @@
|
||||
opacity: 0.5;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -374,7 +399,84 @@
|
||||
|
||||
<script>
|
||||
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({
|
||||
theme: 'bootstrap-5',
|
||||
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 запрос
|
||||
fetch('{% url "orders:temporary-kit-create" %}', {
|
||||
fetch('{% url "products:api-temporary-kit-create" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -874,6 +1084,54 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
</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-dialog modal-lg">
|
||||
|
||||
Reference in New Issue
Block a user