refactor(orders): extract Customer Select2 to separate module

Extracted customer selection and creation functionality from order_form.html
to a reusable customer_select2.js module for better maintainability.

Changes:
- Created customer_select2.js (~450 lines) with IIFE pattern
  - AJAX customer search with Select2 integration
  - Smart parsing of email/phone/name from search input
  - Modal-based customer creation with validation
  - Toast notifications system
  - Auto-initialization via data-attributes
  - Global function exports for backward compatibility

- Updated order_form.html:
  - Added CSRF meta-tag for token access
  - Added data-attributes to customer select element
  - Included customer_select2.js script
  - Removed ~370 lines of inline JavaScript

Benefits:
- Improved code organization and readability
- Reusable across other pages requiring customer selection
- Better browser caching for static JS
- Consistent with existing select2-product-search.js pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-23 15:18:02 +03:00
parent 6669d47cdf
commit fb4f14f475
2 changed files with 468 additions and 392 deletions

View File

@@ -5,6 +5,7 @@
{% block title %}{{ title }}{% endblock %}
{% block extra_css %}
<meta name="csrf-token" content="{{ csrf_token }}">
<style>
/* Скрываем DELETE checkbox */
input[name$="-DELETE"] {
@@ -143,13 +144,28 @@
Клиент <span class="text-danger">*</span>
</label>
{% if preselected_customer %}
<select name="customer" class="form-select" id="id_customer">
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
<select name="customer"
class="form-select customer-select2-auto"
id="id_customer"
data-ajax-url="{% url 'customers:api-search-customers' %}"
data-create-url="{% url 'customers:api-create-customer' %}"
data-modal-id="createCustomerModal">
<option value="{{ preselected_customer.pk }}" selected
data-name="{{ preselected_customer.name }}"
data-phone="{{ preselected_customer.phone|default:'' }}"
data-email="{{ preselected_customer.email|default:'' }}">
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
</option>
</select>
{% else %}
{{ form.customer }}
<select name="customer"
class="form-select customer-select2-auto"
id="id_customer"
data-ajax-url="{% url 'customers:api-search-customers' %}"
data-create-url="{% url 'customers:api-create-customer' %}"
data-modal-id="createCustomerModal">
{{ form.customer.value|default:'' }}
</select>
{% endif %}
{% if form.customer.errors %}
<div class="text-danger">{{ form.customer.errors }}</div>
@@ -1224,193 +1240,12 @@ window.initOrderItemSelect2 = function(element) {
console.log('[Select2 Clear] Все поля очищены, форма осталась в DOM');
});
};
</script>
// Ждем пока jQuery загрузится
function initCustomerSelect2() {
if (typeof $ === 'undefined') {
setTimeout(initCustomerSelect2, 100);
return;
}
const $customerSelect = $('#id_customer');
const ajaxUrl = '{% url "customers:api-search-customers" %}';
// Сохраняем текущее значение перед очисткой
const currentValue = $customerSelect.val();
const currentText = $customerSelect.find(':selected').text();
// НЕ очищаем, если у нас есть текущий выбранный клиент
if (!currentValue) {
$customerSelect.empty();
}
// Инициализируем Select2 с AJAX
$customerSelect.select2({
theme: 'bootstrap-5',
width: '100%',
language: 'ru',
placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)',
minimumInputLength: 3,
allowClear: true,
ajax: {
url: ajaxUrl,
type: 'GET',
dataType: 'json',
delay: 500,
data: function(params) {
return {
q: params.term || ''
};
},
processResults: function(data) {
return {
results: data.results || []
};
}
},
templateResult: formatCustomerOption,
templateSelection: formatCustomerSelection,
escapeMarkup: function(markup) { return markup; }
});
// Восстанавливаем значение если оно было
if (currentValue && currentText) {
$customerSelect.val(currentValue);
}
// Select2 готов и есть предзаполненное значение
if (currentValue && window.DraftCreator) {
setTimeout(function() {
window.DraftCreator.triggerDraftCreation();
}, 100);
}
// Слушаем события
$customerSelect.on('select2:open', function(e) {
setTimeout(function() {
$('.select2-search__field:visible').first().focus();
}, 100);
});
// Обработчик для перехвата ПЕРЕД выбором
$customerSelect.on('select2:selecting', function(e) {
// Проверяем наличие e.params и e.params.data
if (!e.params || !e.params.data) {
return;
}
const data = e.params.data;
if (data.is_create_option || data.id === '__create_new__') {
// Предотвращаем выбор этой опции
e.preventDefault();
$customerSelect.select2('close');
$customerSelect.val(null).trigger('change.select2');
window.openCreateCustomerModal(data.search_text);
return false;
}
});
$customerSelect.on('select2:select', function(e) {
// Проверяем наличие e.params и e.params.data
if (!e.params || !e.params.data) {
return;
}
const data = e.params.data;
if (data.is_create_option || data.id === '__create_new__') {
this.value = '';
const changeEvent = new Event('change', { bubbles: true });
this.dispatchEvent(changeEvent);
window.openCreateCustomerModal(data.search_text);
} else {
const changeEvent = new Event('change', { bubbles: true });
this.dispatchEvent(changeEvent);
}
});
// Форматирование опции в списке
function formatCustomerOption(option) {
// ВАЖНО: Проверяем is_create_option или специальный ID ПЕРЕД проверкой !option.id
if (option.is_create_option || option.id === '__create_new__') {
return '<div class="customer-create-option"><i class="bi bi-plus-circle"></i> ' + option.text + '</div>';
}
if (!option.id) {
return option.text;
}
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.is_create_option || option.id === '__create_new__') {
return option.text;
}
if (!option.id) {
return option.text;
}
// Возвращаем name если есть (из AJAX), иначе text (из DOM опции)
return option.name || option.text;
}
}
// === СОЗДАНИЕ НОВОГО КЛИЕНТА ===
// Функция открытия модального окна создания клиента (определяем ДО инициализации Select2)
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();
};
// Вызываем инициализацию Select2 для customer
initCustomerSelect2();
<!-- Customer Select2 Widget -->
<script src="{% static 'orders/js/customer_select2.js' %}" defer></script>
<script>
// Инициализация Select2 для остальных полей (после jQuery загружен)
if (typeof $ !== 'undefined') {
$(document).ready(function() {
@@ -2005,210 +1840,6 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- Обработчик сохранения нового клиента (ДОЛЖЕН быть ПОСЛЕ модального окна в HTML) -->
<script>
document.getElementById('save-customer-btn').addEventListener('click', function() {
const saveBtn = this;
// Защита от двойного клика
if (saveBtn.disabled) {
return;
}
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;
}
// Блокируем кнопку и меняем текст
saveBtn.disabled = true;
const originalHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Создание...';
// Отправляем 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 modalElement = document.getElementById('createCustomerModal');
const modal = bootstrap.Modal.getInstance(modalElement);
// Слушаем событие полного закрытия модального окна
const handleHidden = () => {
// Удаляем обработчик после первого запуска
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
// Дополнительная очистка (на случай если Bootstrap не почистил)
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
// Возвращаем фокус на форму заказа (опционально)
const orderForm = document.getElementById('order-form');
if (orderForm) {
orderForm.focus();
}
};
modalElement.addEventListener('hidden.bs.modal', handleHidden);
modal.hide();
// Выбираем созданного клиента в Select2
const $customerSelect = $('select[name="customer"]');
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';
// Показываем успешное уведомление (автоисчезающее)
showNotification(`✓ Клиент "${data.name}" успешно создан!`, 'success');
// Триггерим создание черновика через draft-creator
// После выбора клиента должно произойти автоматическое создание черновика
// Добавляем небольшую задержку, чтобы Select2 полностью обновился
setTimeout(() => {
if (window.DraftCreator && typeof window.DraftCreator.triggerDraftCreation === 'function') {
window.DraftCreator.triggerDraftCreation();
}
}, 100);
} else {
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
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);
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">Ошибка при создании клиента: ' + error.message + '</div>';
errorDiv.style.display = 'block';
});
});
/**
* Показывает автоисчезающее уведомление в правом верхнем углу формы заказа
* @param {string} message - Текст сообщения
* @param {string} type - Тип: 'success', 'error', 'info' (по умолчанию 'info')
* @param {number} duration - Время показа в миллисекундах (по умолчанию 4000)
*/
function showNotification(message, type = 'info', duration = 4000) {
const alertClass = type === 'error' ? 'alert-danger' : type === 'success' ? 'alert-success' : 'alert-info';
const icon = type === 'error' ? '⚠️' : type === 'success' ? '✓' : '';
// Создаём контейнер для уведомлений, если его нет
let container = document.getElementById('notifications-container');
if (!container) {
container = document.createElement('div');
container.id = 'notifications-container';
container.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
z-index: 1050;
max-width: 400px;
`;
document.body.appendChild(container);
}
// Создаём уведомление
const notification = document.createElement('div');
notification.className = `alert ${alertClass}`;
notification.style.cssText = `
margin-bottom: 10px;
padding: 12px 15px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: slideIn 0.3s ease-in-out;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span>${icon}</span>
<span>${message}</span>
</div>
`;
// Добавляем в контейнер
container.appendChild(notification);
// Удаляем через указанное время
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-in-out';
setTimeout(() => {
notification.remove();
}, 300);
}, duration);
}
// CSS для анимаций уведомлений
if (!document.getElementById('notification-styles')) {
const style = document.createElement('style');
style.id = 'notification-styles';
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
</script>
<!-- Модальное окно для создания временного комплекта -->
<div class="modal fade" id="tempKitModal" tabindex="-1" aria-labelledby="tempKitModalLabel" aria-hidden="true">