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:
445
myproject/orders/static/orders/js/customer_select2.js
Normal file
445
myproject/orders/static/orders/js/customer_select2.js
Normal 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);
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
<style>
|
<style>
|
||||||
/* Скрываем DELETE checkbox */
|
/* Скрываем DELETE checkbox */
|
||||||
input[name$="-DELETE"] {
|
input[name$="-DELETE"] {
|
||||||
@@ -143,13 +144,28 @@
|
|||||||
Клиент <span class="text-danger">*</span>
|
Клиент <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
{% if preselected_customer %}
|
{% if preselected_customer %}
|
||||||
<select name="customer" class="form-select" id="id_customer">
|
<select name="customer"
|
||||||
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
|
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 %}
|
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if form.customer.errors %}
|
{% if form.customer.errors %}
|
||||||
<div class="text-danger">{{ form.customer.errors }}</div>
|
<div class="text-danger">{{ form.customer.errors }}</div>
|
||||||
@@ -1224,193 +1240,12 @@ window.initOrderItemSelect2 = function(element) {
|
|||||||
console.log('[Select2 Clear] Все поля очищены, форма осталась в DOM');
|
console.log('[Select2 Clear] Все поля очищены, форма осталась в DOM');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
// Ждем пока jQuery загрузится
|
<!-- Customer Select2 Widget -->
|
||||||
function initCustomerSelect2() {
|
<script src="{% static 'orders/js/customer_select2.js' %}" defer></script>
|
||||||
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();
|
|
||||||
|
|
||||||
|
<script>
|
||||||
// Инициализация Select2 для остальных полей (после jQuery загружен)
|
// Инициализация Select2 для остальных полей (после jQuery загружен)
|
||||||
if (typeof $ !== 'undefined') {
|
if (typeof $ !== 'undefined') {
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
@@ -2005,210 +1840,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="modal fade" id="tempKitModal" tabindex="-1" aria-labelledby="tempKitModalLabel" aria-hidden="true">
|
||||||
|
|||||||
Reference in New Issue
Block a user