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

@@ -0,0 +1,445 @@
/**
* Customer Select2 Widget
* Переиспользуемый модуль для выбора и создания клиентов
*
* Использование:
* 1. Автоматическая инициализация: добавьте класс 'customer-select2-auto' и data-атрибуты
* 2. Ручная инициализация: вызовите window.initCustomerSelect2(element, options)
*/
(function(window) {
'use strict';
// ========== СЕКЦИЯ 1: Утилиты ==========
/**
* Получить CSRF токен из meta-тега или cookie
*/
function getCSRFToken() {
// Сначала пробуем meta тег
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) return meta.content;
// Fallback на cookie
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [key, value] = cookie.trim().split('=');
if (key === 'csrftoken') return value;
}
return '';
}
// ========== СЕКЦИЯ 2: Форматирование результатов ==========
function formatCustomerOption(option) {
// Опция "Создать нового клиента"
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;
}
return option.name || option.text;
}
// ========== СЕКЦИЯ 3: Парсинг данных ==========
/**
* Парсит текст поиска и определяет тип данных (email, телефон или имя)
*/
function parseSearchText(searchText) {
if (!searchText) return { name: '', phone: '', email: '' };
const phoneRegex = /[\d\s\-\+\(\)]+/;
const emailRegex = /[^\s@]+@[^\s@]+\.[^\s@]+/;
const emailMatch = searchText.match(emailRegex);
const phoneMatch = searchText.match(phoneRegex);
if (emailMatch) {
return { name: '', phone: '', email: emailMatch[0] };
} else if (phoneMatch && phoneMatch[0].replace(/\D/g, '').length >= 9) {
return { name: '', phone: phoneMatch[0], email: '' };
} else {
return { name: searchText, phone: '', email: '' };
}
}
// ========== СЕКЦИЯ 4: Уведомления ==========
/**
* Показывает автоисчезающее уведомление
*/
function showNotification(message, type, duration) {
type = type || 'info';
duration = 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(function() {
notification.style.animation = 'slideOut 0.3s ease-in-out';
setTimeout(function() { 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);
}
}
// ========== СЕКЦИЯ 5: Модальное окно создания клиента ==========
/**
* Открывает модальное окно для создания нового клиента
*/
function openCreateCustomerModal(searchText) {
searchText = searchText || '';
// Парсим данные из поискового текста
const parsed = parseSearchText(searchText);
// Заполняем поля формы
const nameInput = document.getElementById('customer-name');
const phoneInput = document.getElementById('customer-phone');
const emailInput = document.getElementById('customer-email');
if (nameInput) nameInput.value = parsed.name;
if (phoneInput) phoneInput.value = parsed.phone;
if (emailInput) emailInput.value = parsed.email;
// Очищаем ошибки
const errorsDiv = document.getElementById('customer-form-errors');
if (errorsDiv) {
errorsDiv.innerHTML = '';
errorsDiv.style.display = 'none';
}
// Открываем модал
const modalElement = document.getElementById('createCustomerModal');
if (modalElement && typeof bootstrap !== 'undefined') {
const modal = new bootstrap.Modal(modalElement);
modal.show();
}
}
/**
* Инициализирует обработчик кнопки создания клиента
*/
function initCreateCustomerHandler(options) {
const saveBtn = document.getElementById('save-customer-btn');
if (!saveBtn) return;
// Удаляем старые обработчики
const newBtn = saveBtn.cloneNode(true);
saveBtn.parentNode.replaceChild(newBtn, saveBtn);
newBtn.addEventListener('click', function() {
if (newBtn.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(function(e) { return '<li>' + e + '</li>'; }).join('') + '</ul>';
errorDiv.style.display = 'block';
return;
}
// Блокируем кнопку
newBtn.disabled = true;
const originalHTML = newBtn.innerHTML;
newBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Создание...';
// AJAX запрос
fetch(options.createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({
name: name,
phone: phone || null,
email: email || null
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
// Закрываем модал
const modalElement = document.getElementById('createCustomerModal');
const modal = bootstrap.Modal.getInstance(modalElement);
const handleHidden = function() {
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
// Очистка backdrop
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) backdrop.remove();
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
};
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');
// Триггерим DraftCreator
setTimeout(function() {
if (window.DraftCreator && window.DraftCreator.triggerDraftCreation) {
window.DraftCreator.triggerDraftCreation();
}
}, 100);
} else {
// Ошибка от сервера
newBtn.disabled = false;
newBtn.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(function(error) {
console.error('Error:', error);
newBtn.disabled = false;
newBtn.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';
});
});
}
// ========== СЕКЦИЯ 6: Инициализация Select2 ==========
/**
* Инициализирует Customer Select2
*
* @param {Element|string} element - DOM элемент или селектор
* @param {Object} options - Настройки
* @param {string} options.ajaxUrl - URL для AJAX поиска
* @param {string} options.createUrl - URL для создания клиента
* @param {string} options.modalId - ID модального окна (по умолчанию 'createCustomerModal')
* @param {number} options.minimumInputLength - Минимальная длина для поиска (по умолчанию 3)
* @param {number} options.delay - Задержка AJAX запроса (по умолчанию 500)
*/
function initCustomerSelect2(element, options) {
// Ждём загрузки jQuery
if (typeof $ === 'undefined') {
setTimeout(function() { initCustomerSelect2(element, options); }, 100);
return;
}
options = options || {};
const $element = typeof element === 'string' ? $(element) : $(element);
if (!$element.length) return;
const ajaxUrl = options.ajaxUrl || $element.data('ajax-url');
const createUrl = options.createUrl || $element.data('create-url');
const modalId = options.modalId || $element.data('modal-id') || 'createCustomerModal';
const minimumInputLength = options.minimumInputLength || 3;
const delay = options.delay || 500;
if (!ajaxUrl) {
console.error('[CustomerSelect2] AJAX URL not provided');
return;
}
// Сохраняем текущее значение
const currentValue = $element.val();
const currentText = $element.find(':selected').text();
if (!currentValue) {
$element.empty();
}
// Инициализация Select2
$element.select2({
theme: 'bootstrap-5',
width: '100%',
language: 'ru',
placeholder: 'Начните вводить имя, телефон или email (минимум ' + minimumInputLength + ' символа)',
minimumInputLength: minimumInputLength,
allowClear: true,
ajax: {
url: ajaxUrl,
type: 'GET',
dataType: 'json',
delay: delay,
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) {
$element.val(currentValue);
}
// Интеграция с DraftCreator при загрузке
if (currentValue && window.DraftCreator) {
setTimeout(function() {
window.DraftCreator.triggerDraftCreation();
}, 100);
}
// Обработчик открытия Select2
$element.on('select2:open', function() {
setTimeout(function() {
$('.select2-search__field:visible').first().focus();
}, 100);
});
// Обработчик ПЕРЕД выбором (для создания нового)
$element.on('select2:selecting', function(e) {
if (!e.params || !e.params.data) return;
const data = e.params.data;
if (data.is_create_option || data.id === '__create_new__') {
e.preventDefault();
$element.select2('close');
$element.val(null).trigger('change.select2');
openCreateCustomerModal(data.search_text);
return false;
}
});
// Обработчик выбора
$element.on('select2:select', function(e) {
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);
openCreateCustomerModal(data.search_text);
} else {
const changeEvent = new Event('change', { bubbles: true });
this.dispatchEvent(changeEvent);
}
});
// Инициализируем обработчик создания клиента
if (createUrl) {
initCreateCustomerHandler({ createUrl: createUrl, modalId: modalId });
}
return $element;
}
// ========== СЕКЦИЯ 7: Автоматическая инициализация ==========
document.addEventListener('DOMContentLoaded', function() {
// Авто-инициализация для элементов с классом 'customer-select2-auto'
const elements = document.querySelectorAll('.customer-select2-auto');
elements.forEach(function(element) {
const ajaxUrl = element.dataset.ajaxUrl;
const createUrl = element.dataset.createUrl;
const modalId = element.dataset.modalId;
if (ajaxUrl) {
initCustomerSelect2(element, {
ajaxUrl: ajaxUrl,
createUrl: createUrl,
modalId: modalId
});
}
});
});
// ========== СЕКЦИЯ 8: Экспорт ==========
window.initCustomerSelect2 = initCustomerSelect2;
window.openCreateCustomerModal = openCreateCustomerModal;
window.showNotification = showNotification;
})(window);