Замена простого select на autocomplete с поиском для привязки атрибутов к товарам/комплектам

- Переиспользован модуль select2-product-search.js из orders
- Заменен простой select на Select2 с AJAX поиском через API search_products_and_variants
- Добавлена поддержка привязки как ProductKit, так и Product к значениям атрибутов
- Обновлен метод _save_attributes_from_cards для обработки item_ids и item_types
- Удалены дублирующиеся подключения jQuery и Select2 (используются из base.html)
- Улучшен UX: живой поиск, отображение типа товара (🌹/💐), цены и наличия
This commit is contained in:
2025-12-30 02:59:45 +03:00
parent a3f2185714
commit a95bd56b2b
2 changed files with 144 additions and 85 deletions

View File

@@ -29,6 +29,30 @@ input[name*="DELETE"] {
align-items: center; align-items: center;
min-height: 38px; min-height: 38px;
} }
/* Стили для autocomplete товаров/комплектов */
.product-kit-select-wrapper {
flex: 1;
min-width: 200px;
}
/* Отображение типа товара в Select2 */
.select2-results__option .item-type-badge {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
margin-left: 0.3rem;
border-radius: 3px;
}
.item-type-kit {
background-color: #0d6efd;
color: white;
}
.item-type-product {
background-color: #198754;
color: white;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -108,12 +132,6 @@ input[name*="DELETE"] {
<!-- Данные для JavaScript --> <!-- Данные для JavaScript -->
<script> <script>
window.AVAILABLE_KITS = [
{% for kit in available_kits %}
{ id: {{ kit.id }}, name: "{{ kit.name|escapejs }}" }{% if not forloop.last %},{% endif %}
{% endfor %}
];
// Справочник атрибутов с их значениями // Справочник атрибутов с их значениями
window.PRODUCT_ATTRIBUTES = [ window.PRODUCT_ATTRIBUTES = [
{% for attr in product_attributes %} {% for attr in product_attributes %}
@@ -133,7 +151,8 @@ input[name*="DELETE"] {
// URL для API // URL для API
window.API_URLS = { window.API_URLS = {
createAttribute: "{% url 'products:api-attribute-create' %}", createAttribute: "{% url 'products:api-attribute-create' %}",
addValue: function(attrId) { return `/products/api/attributes/${attrId}/values/add/`; } addValue: function(attrId) { return `/products/api/attributes/${attrId}/values/add/`; },
searchProductsAndVariants: "{% url 'products:api-search-products-variants' %}"
}; };
</script> </script>
@@ -266,16 +285,17 @@ input[name*="DELETE"] {
</form> </form>
</div> </div>
<!-- Переиспользуемый модуль для Select2 поиска товаров -->
<script src="{% static 'products/js/select2-product-search.js' %}"></script>
<script> <script>
// === Управление параметрами товара (карточный интерфейс) === // === Управление параметрами товара (карточный интерфейс) ===
// Функция для добавления нового поля значения параметра с выбором ProductKit // Функция для добавления нового поля значения параметра с выбором Product/Kit через Select2
function addValueField(container, valueText = '', kitId = '') { function addValueField(container, valueText = '', itemId = '', itemType = '') {
const index = container.querySelectorAll('.value-field-group').length; const index = container.querySelectorAll('.value-field-group').length;
const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const selectId = `product-kit-select-${fieldId}`;
// Получаем список доступных комплектов
const kitOptionsHtml = getKitOptionsHtml(kitId);
const html = ` const html = `
<div class="value-field-group d-flex gap-2 mb-2 align-items-start"> <div class="value-field-group d-flex gap-2 mb-2 align-items-start">
@@ -283,13 +303,13 @@ function addValueField(container, valueText = '', kitId = '') {
placeholder="Введите значение" placeholder="Введите значение"
value="${escapeHtml(valueText)}" value="${escapeHtml(valueText)}"
data-field-id="${fieldId}" data-field-id="${fieldId}"
style="min-width: 100px;"> style="flex: 0 0 150px;">
<select class="form-select form-select-sm parameter-kit-select" <select class="form-select form-select-sm product-kit-select"
id="${selectId}"
data-field-id="${fieldId}" data-field-id="${fieldId}"
title="Выберите комплект для этого значения" data-ajax-url="${window.API_URLS.searchProductsAndVariants}"
style="min-width: 150px;"> style="flex: 1; min-width: 200px;">
<option value="">-- Выберите комплект --</option> <option value=""></option>
${kitOptionsHtml}
</select> </select>
<button type="button" class="btn btn-sm btn-outline-danger remove-value-btn" title="Удалить значение"> <button type="button" class="btn btn-sm btn-outline-danger remove-value-btn" title="Удалить значение">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@@ -299,12 +319,22 @@ function addValueField(container, valueText = '', kitId = '') {
container.insertAdjacentHTML('beforeend', html); container.insertAdjacentHTML('beforeend', html);
// Установка выбранного комплекта если был передан // Инициализируем Select2 для нового поля
if (kitId) { const newSelect = document.getElementById(selectId);
const lastSelect = container.querySelector('.value-field-group:last-child .parameter-kit-select'); if (newSelect) {
if (lastSelect) { // Если есть предзагруженные данные, добавляем option
lastSelect.value = kitId; if (itemId && itemType) {
const optionValue = `${itemType}_${itemId}`;
const option = document.createElement('option');
option.value = optionValue;
option.selected = true;
option.setAttribute('data-type', itemType);
// Текст будет установлен через Select2
newSelect.appendChild(option);
} }
// Инициализируем Select2 с AJAX поиском
window.initProductSelect2(newSelect, 'all', window.API_URLS.searchProductsAndVariants);
} }
// Обработчик удаления значения // Обработчик удаления значения
@@ -312,6 +342,11 @@ function addValueField(container, valueText = '', kitId = '') {
if (lastRemoveBtn) { if (lastRemoveBtn) {
lastRemoveBtn.addEventListener('click', function(e) { lastRemoveBtn.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
// Уничтожаем Select2 перед удалением элемента
const select = this.closest('.value-field-group').querySelector('.product-kit-select');
if (select && $(select).data('select2')) {
$(select).select2('destroy');
}
this.closest('.value-field-group').remove(); this.closest('.value-field-group').remove();
}); });
} }
@@ -324,15 +359,6 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
// Получить HTML с опциями комплектов
function getKitOptionsHtml(selectedKitId = '') {
const kitsData = window.AVAILABLE_KITS || [];
return kitsData.map(kit => {
const selected = kit.id == selectedKitId ? 'selected' : '';
return `<option value="${kit.id}" ${selected}>${escapeHtml(kit.name)}</option>`;
}).join('');
}
// Найти атрибут в справочнике по имени // Найти атрибут в справочнике по имени
function findAttributeByName(name) { function findAttributeByName(name) {
const attributes = window.PRODUCT_ATTRIBUTES || []; const attributes = window.PRODUCT_ATTRIBUTES || [];
@@ -775,24 +801,32 @@ function initParamDeleteToggle(card) {
} }
} }
// Функция для сериализации значений параметров и их комплектов перед отправкой формы // Функция для сериализации значений параметров и их товаров/комплектов перед отправкой формы
function serializeAttributeValues() { function serializeAttributeValues() {
document.querySelectorAll('.attribute-card').forEach((card, idx) => { document.querySelectorAll('.attribute-card').forEach((card, idx) => {
const valueGroups = card.querySelectorAll('.value-field-group'); const valueGroups = card.querySelectorAll('.value-field-group');
const values = []; const values = [];
const kits = []; const itemIds = [];
const itemTypes = [];
valueGroups.forEach(group => { valueGroups.forEach(group => {
const valueInput = group.querySelector('.parameter-value-input'); const valueInput = group.querySelector('.parameter-value-input');
const kitSelect = group.querySelector('.parameter-kit-select'); const itemSelect = group.querySelector('.product-kit-select');
if (valueInput) { if (valueInput && itemSelect) {
const value = valueInput.value.trim(); const value = valueInput.value.trim();
const kitId = kitSelect ? kitSelect.value : ''; const selectedValue = itemSelect.value; // Формат: "product_123" или "kit_456"
if (value && selectedValue) {
const parts = selectedValue.split('_');
if (parts.length === 2) {
const type = parts[0]; // 'product' или 'kit'
const id = parts[1];
if (value && kitId) {
values.push(value); values.push(value);
kits.push(parseInt(kitId)); itemIds.push(parseInt(id));
itemTypes.push(type);
}
} }
} }
}); });
@@ -808,15 +842,25 @@ function serializeAttributeValues() {
} }
valuesField.value = JSON.stringify(values); valuesField.value = JSON.stringify(values);
const kitsFieldName = `attributes-${idx}-kits`; const itemIdsFieldName = `attributes-${idx}-item_ids`;
let kitsField = document.querySelector(`input[name="${kitsFieldName}"]`); let itemIdsField = document.querySelector(`input[name="${itemIdsFieldName}"]`);
if (!kitsField) { if (!itemIdsField) {
kitsField = document.createElement('input'); itemIdsField = document.createElement('input');
kitsField.type = 'hidden'; itemIdsField.type = 'hidden';
kitsField.name = kitsFieldName; itemIdsField.name = itemIdsFieldName;
card.appendChild(kitsField); card.appendChild(itemIdsField);
} }
kitsField.value = JSON.stringify(kits); itemIdsField.value = JSON.stringify(itemIds);
const itemTypesFieldName = `attributes-${idx}-item_types`;
let itemTypesField = document.querySelector(`input[name="${itemTypesFieldName}"]`);
if (!itemTypesField) {
itemTypesField = document.createElement('input');
itemTypesField.type = 'hidden';
itemTypesField.name = itemTypesFieldName;
card.appendChild(itemTypesField);
}
itemTypesField.value = JSON.stringify(itemTypes);
}); });
} }

View File

@@ -96,11 +96,6 @@ class ConfigurableProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixi
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Добавляем доступные комплекты для выбора (активные, не временные)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
return context return context
@@ -138,12 +133,6 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
prefix='attributes' prefix='attributes'
) )
# Доступные комплекты для JavaScript (для выбора при добавлении значений атрибутов)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
# Справочник атрибутов для autocomplete # Справочник атрибутов для autocomplete
context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name') context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
@@ -247,10 +236,12 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
- attributes-X-visible: видимость - attributes-X-visible: видимость
- attributes-X-DELETE: помечен ли для удаления - attributes-X-DELETE: помечен ли для удаления
- attributes-X-values: JSON массив значений параметра - attributes-X-values: JSON массив значений параметра
- attributes-X-kits: JSON массив ID комплектов для каждого значения - attributes-X-item_ids: JSON массив ID товаров/комплектов
- attributes-X-item_types: JSON массив типов ('product' или 'kit')
""" """
import json import json
from products.models.kits import ProductKit from products.models.kits import ProductKit
from products.models.products import Product
# Сначала удаляем все старые атрибуты # Сначала удаляем все старые атрибуты
ConfigurableProductAttribute.objects.filter(parent=self.object).delete() ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
@@ -282,9 +273,10 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on' visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on'
# Получаем значения и их привязанные комплекты # Получаем значения, ID и типы (kit/product)
values_json = self.request.POST.get(f'attributes-{idx}-values', '[]') values_json = self.request.POST.get(f'attributes-{idx}-values', '[]')
kits_json = self.request.POST.get(f'attributes-{idx}-kits', '[]') item_ids_json = self.request.POST.get(f'attributes-{idx}-item_ids', '[]')
item_types_json = self.request.POST.get(f'attributes-{idx}-item_types', '[]')
try: try:
values = json.loads(values_json) values = json.loads(values_json)
@@ -292,15 +284,21 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
values = [] values = []
try: try:
kit_ids = json.loads(kits_json) item_ids = json.loads(item_ids_json)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
kit_ids = [] item_ids = []
try:
item_types = json.loads(item_types_json)
except (json.JSONDecodeError, TypeError):
item_types = []
# Создаём ConfigurableProductAttribute для каждого значения # Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values): for value_idx, value in enumerate(values):
if value and value.strip(): if value and value.strip():
# Получаем соответствующий ID комплекта # Получаем соответствующие ID и тип
kit_id = kit_ids[value_idx] if value_idx < len(kit_ids) else None item_id = item_ids[value_idx] if value_idx < len(item_ids) else None
item_type = item_types[value_idx] if value_idx < len(item_types) else None
# Приготавливаем параметры создания # Приготавливаем параметры создания
create_kwargs = { create_kwargs = {
@@ -311,13 +309,17 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
'visible': visible 'visible': visible
} }
# Добавляем комплект если указан # Добавляем комплект или товар если указан
if kit_id: if item_id and item_type:
try: try:
kit = ProductKit.objects.get(id=kit_id) if item_type == 'kit':
kit = ProductKit.objects.get(id=item_id)
create_kwargs['kit'] = kit create_kwargs['kit'] = kit
except ProductKit.DoesNotExist: elif item_type == 'product':
# Комплект не найден - создаём без привязки product = Product.objects.get(id=item_id)
create_kwargs['product'] = product
except (ProductKit.DoesNotExist, Product.DoesNotExist):
# Комплект/товар не найден - создаём без привязки
pass pass
ConfigurableProductAttribute.objects.create(**create_kwargs) ConfigurableProductAttribute.objects.create(**create_kwargs)
@@ -527,10 +529,12 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
- attributes-X-visible: видимость - attributes-X-visible: видимость
- attributes-X-DELETE: помечен ли для удаления - attributes-X-DELETE: помечен ли для удаления
- attributes-X-values: JSON массив значений параметра - attributes-X-values: JSON массив значений параметра
- attributes-X-kits: JSON массив ID комплектов для каждого значения - attributes-X-item_ids: JSON массив ID товаров/комплектов
- attributes-X-item_types: JSON массив типов ('product' или 'kit')
""" """
import json import json
from products.models.kits import ProductKit from products.models.kits import ProductKit
from products.models.products import Product
# Сначала удаляем все старые атрибуты # Сначала удаляем все старые атрибуты
ConfigurableProductAttribute.objects.filter(parent=self.object).delete() ConfigurableProductAttribute.objects.filter(parent=self.object).delete()
@@ -562,9 +566,10 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on' visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on'
# Получаем значения и их привязанные комплекты # Получаем значения, ID и типы (kit/product)
values_json = self.request.POST.get(f'attributes-{idx}-values', '[]') values_json = self.request.POST.get(f'attributes-{idx}-values', '[]')
kits_json = self.request.POST.get(f'attributes-{idx}-kits', '[]') item_ids_json = self.request.POST.get(f'attributes-{idx}-item_ids', '[]')
item_types_json = self.request.POST.get(f'attributes-{idx}-item_types', '[]')
try: try:
values = json.loads(values_json) values = json.loads(values_json)
@@ -572,15 +577,21 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
values = [] values = []
try: try:
kit_ids = json.loads(kits_json) item_ids = json.loads(item_ids_json)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
kit_ids = [] item_ids = []
try:
item_types = json.loads(item_types_json)
except (json.JSONDecodeError, TypeError):
item_types = []
# Создаём ConfigurableProductAttribute для каждого значения # Создаём ConfigurableProductAttribute для каждого значения
for value_idx, value in enumerate(values): for value_idx, value in enumerate(values):
if value and value.strip(): if value and value.strip():
# Получаем соответствующий ID комплекта # Получаем соответствующие ID и тип
kit_id = kit_ids[value_idx] if value_idx < len(kit_ids) else None item_id = item_ids[value_idx] if value_idx < len(item_ids) else None
item_type = item_types[value_idx] if value_idx < len(item_types) else None
# Приготавливаем параметры создания # Приготавливаем параметры создания
create_kwargs = { create_kwargs = {
@@ -591,13 +602,17 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
'visible': visible 'visible': visible
} }
# Добавляем комплект если указан # Добавляем комплект или товар если указан
if kit_id: if item_id and item_type:
try: try:
kit = ProductKit.objects.get(id=kit_id) if item_type == 'kit':
kit = ProductKit.objects.get(id=item_id)
create_kwargs['kit'] = kit create_kwargs['kit'] = kit
except ProductKit.DoesNotExist: elif item_type == 'product':
# Комплект не найден - создаём без привязки product = Product.objects.get(id=item_id)
create_kwargs['product'] = product
except (ProductKit.DoesNotExist, Product.DoesNotExist):
# Комплект/товар не найден - создаём без привязки
pass pass
ConfigurableProductAttribute.objects.create(**create_kwargs) ConfigurableProductAttribute.objects.create(**create_kwargs)