diff --git a/myproject/products/forms.py b/myproject/products/forms.py index fb5b88a..8087fd0 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -858,7 +858,7 @@ ConfigurableProductAttributeFormSetCreate = inlineformset_factory( formset=BaseConfigurableProductAttributeFormSet, # Убрали 'option' - значения будут добавляться через JavaScript в карточку fields=['name', 'position', 'visible'], - extra=1, + extra=0, # Пользователь добавляет параметры через кнопку "Добавить параметр" can_delete=True, min_num=0, validate_min=False, diff --git a/myproject/products/templates/products/configurableproduct_form.html b/myproject/products/templates/products/configurableproduct_form.html index 2060016..9083444 100644 --- a/myproject/products/templates/products/configurableproduct_form.html +++ b/myproject/products/templates/products/configurableproduct_form.html @@ -106,13 +106,35 @@ input[name*="DELETE"] { {{ attribute_formset.management_form }} - + {% if attribute_formset.non_form_errors %} @@ -127,10 +149,26 @@ input[name*="DELETE"] { {{ form.id }}
- -
+ +
- {{ form.name }} +
+ + +
{% if form.name.errors %}
{{ form.name.errors.0 }}
{% endif %} @@ -169,9 +207,14 @@ input[name*="DELETE"] {
- +
- +
+ + +
@@ -231,14 +274,14 @@ function addValueField(container, valueText = '', kitId = '') { const index = container.querySelectorAll('.value-field-group').length; const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - // Получаем список доступных комплектов из скрытого элемента + // Получаем список доступных комплектов const kitOptionsHtml = getKitOptionsHtml(kitId); const html = `
+
+
+ +
+ +
+
+ +
+ + + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + + // Инициализируем обработчики + const modal = document.getElementById('createAttributeModal'); + + document.getElementById('addNewAttributeValueBtn').addEventListener('click', function() { + const container = document.getElementById('newAttributeValues'); + addNewAttributeValueField(container); + }); + + document.getElementById('saveNewAttributeBtn').addEventListener('click', async function() { + await saveNewAttribute(); + }); +} + +// Добавить поле значения в модальном окне +function addNewAttributeValueField(container) { + const html = ` +
+ + +
+ `; + container.insertAdjacentHTML('beforeend', html); + + const lastRemoveBtn = container.querySelector('.new-value-field:last-child .remove-new-value-btn'); + lastRemoveBtn.addEventListener('click', function() { + this.closest('.new-value-field').remove(); + }); +} + +// Сохранить новый атрибут через API +async function saveNewAttribute() { + const modal = document.getElementById('createAttributeModal'); + const name = document.getElementById('newAttributeName').value.trim(); + + if (!name) { + showToast('Введите название атрибута', 'warning'); + return; + } + + const saveBtn = document.getElementById('saveNewAttributeBtn'); + saveBtn.disabled = true; + saveBtn.innerHTML = ' Сохранение...'; + + try { + // Создаем атрибут + const csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value; + + const response = await fetch(window.API_URLS.createAttribute, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ name: name }) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Ошибка создания атрибута'); + } + + const newAttr = { + id: data.id, + name: data.name, + slug: data.slug, + values: [] + }; + + // Добавляем значения если они есть + const valueInputs = document.querySelectorAll('#newAttributeValues .new-value-field input'); + for (const input of valueInputs) { + const value = input.value.trim(); + if (value) { + const valResponse = await fetch(window.API_URLS.addValue(data.id), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ value: value }) + }); + const valData = await valResponse.json(); + if (valData.success) { + newAttr.values.push({ + id: valData.id, + value: valData.value, + slug: valData.slug + }); + } + } + } + + // Добавляем в локальный справочник + window.PRODUCT_ATTRIBUTES.push(newAttr); + + // Обновляем все select'ы атрибутов + updateAttributeSelects(); + + // Закрываем модальное окно + const bsModal = bootstrap.Modal.getInstance(modal); + bsModal.hide(); + + showToast(`Атрибут "${name}" успешно создан!`, 'success'); + + // Если есть целевая карточка, обновляем её + const cardIndex = modal.dataset.targetCardIndex; + if (cardIndex !== undefined) { + const card = document.querySelector(`[data-formset-index="${cardIndex}"]`); + if (card) { + // Устанавливаем новый атрибут в select + const select = card.querySelector('.param-name-select'); + const hiddenInput = card.querySelector('.param-name-input'); + if (select) { + select.value = newAttr.name; + } + if (hiddenInput) { + hiddenInput.value = newAttr.name; + } + + // Загружаем значения если есть + if (newAttr.values.length > 0) { + const container = card.querySelector('.value-fields-wrapper'); + newAttr.values.forEach(val => { + addValueField(container, val.value, ''); + }); + } + } + } + + } catch (error) { + showToast(error.message, 'danger'); + } finally { + saveBtn.disabled = false; + saveBtn.innerHTML = ' Создать'; + } +} + +// Обновить все select'ы атрибутов после добавления нового +function updateAttributeSelects() { + document.querySelectorAll('.param-name-select').forEach(select => { + const currentValue = select.value; + select.innerHTML = getAttributeOptionsHtml(currentValue); + }); +} + // Добавление нового параметра document.getElementById('addParameterBtn')?.addEventListener('click', function() { const container = document.getElementById('attributeFormsetContainer'); @@ -312,12 +688,19 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
-
+
- +
+ + +
@@ -348,7 +731,12 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
- +
+ + +
@@ -364,16 +752,17 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function() // Инициализируем новую карточку const newCard = container.querySelector(`[data-formset-index="${formIdx}"]`); - initAddValueBtn(newCard); + initCardHandlers(newCard); - // Инициализируем удаление параметра - initParamDeleteToggle(newCard); + // Фокус на select + newCard.querySelector('.param-name-select')?.focus(); }); // Функция для скрытия удаленного параметра function initParamDeleteToggle(card) { const deleteCheckbox = card.querySelector('input[type="checkbox"][name$="-DELETE"]'); - if (deleteCheckbox) { + if (deleteCheckbox && !deleteCheckbox.dataset.initialized) { + deleteCheckbox.dataset.initialized = 'true'; deleteCheckbox.addEventListener('change', function() { if (this.checked) { card.style.opacity = '0.5'; @@ -388,12 +777,7 @@ function initParamDeleteToggle(card) { // Функция для сериализации значений параметров и их комплектов перед отправкой формы function serializeAttributeValues() { - /** - * Перед отправкой формы нужно сериализовать все значения параметров - * и их связанные комплекты из инлайн input'ов в скрытые JSON поля - */ document.querySelectorAll('.attribute-card').forEach((card, idx) => { - // Получаем все инпуты с значениями и их комплектами внутри этой карточки const valueGroups = card.querySelectorAll('.value-field-group'); const values = []; const kits = []; @@ -406,7 +790,7 @@ function serializeAttributeValues() { const value = valueInput.value.trim(); const kitId = kitSelect ? kitSelect.value : ''; - if (value && kitId) { // Требуем чтобы оба поля были заполнены + if (value && kitId) { values.push(value); kits.push(parseInt(kitId)); } @@ -414,7 +798,6 @@ function serializeAttributeValues() { }); // Создаем или обновляем скрытые поля JSON - // поле values: ["50", "60", "70"] const valuesFieldName = `attributes-${idx}-values`; let valuesField = document.querySelector(`input[name="${valuesFieldName}"]`); if (!valuesField) { @@ -425,7 +808,6 @@ function serializeAttributeValues() { } valuesField.value = JSON.stringify(values); - // поле kits: [1, 2, 3] (id ProductKit) const kitsFieldName = `attributes-${idx}-kits`; let kitsField = document.querySelector(`input[name="${kitsFieldName}"]`); if (!kitsField) { @@ -438,18 +820,52 @@ function serializeAttributeValues() { }); } +// Toast уведомления +function showToast(message, type = 'info') { + // Создаем контейнер если его нет + let container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + container.style.zIndex = '1100'; + document.body.appendChild(container); + } + + const bgClass = { + 'success': 'bg-success', + 'danger': 'bg-danger', + 'warning': 'bg-warning', + 'info': 'bg-info' + }[type] || 'bg-info'; + + const textClass = type === 'warning' ? 'text-dark' : 'text-white'; + + const toastHtml = ` + + `; + + container.insertAdjacentHTML('beforeend', toastHtml); + const toastEl = container.lastElementChild; + const toast = new bootstrap.Toast(toastEl, { delay: 4000 }); + toast.show(); + + toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove()); +} + // Инициализация при загрузке страницы document.addEventListener('DOMContentLoaded', function() { initializeParameterCards(); - document.querySelectorAll('.attribute-card').forEach(card => { - initParamDeleteToggle(card); - }); // Добавляем сериализацию значений перед отправкой формы const form = document.querySelector('form'); if (form) { form.addEventListener('submit', function(e) { - // Перед отправкой формы сериализуем все значения параметров serializeAttributeValues(); }); } diff --git a/myproject/products/urls.py b/myproject/products/urls.py index 100dce0..423af84 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -94,6 +94,7 @@ urlpatterns = [ path('attributes//delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'), # API для атрибутов + path('api/attributes/', views.get_attributes_list_api, name='api-attributes-list'), path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'), path('api/attributes//values/add/', views.add_attribute_value_api, name='attribute-add-value'), path('api/attributes//values//delete/', views.delete_attribute_value_api, name='attribute-delete-value'), diff --git a/myproject/products/views/__init__.py b/myproject/products/views/__init__.py index ed6fa9f..e493fc0 100644 --- a/myproject/products/views/__init__.py +++ b/myproject/products/views/__init__.py @@ -102,6 +102,7 @@ from .attribute_views import ( create_attribute_api, add_attribute_value_api, delete_attribute_value_api, + get_attributes_list_api, ) # API представления @@ -193,6 +194,7 @@ __all__ = [ 'create_attribute_api', 'add_attribute_value_api', 'delete_attribute_value_api', + 'get_attributes_list_api', # API 'search_products_and_variants', diff --git a/myproject/products/views/attribute_views.py b/myproject/products/views/attribute_views.py index 0b382c6..9991ca6 100644 --- a/myproject/products/views/attribute_views.py +++ b/myproject/products/views/attribute_views.py @@ -245,3 +245,30 @@ def delete_attribute_value_api(request, pk, value_id): return JsonResponse({'success': False, 'error': 'Значение не найдено'}) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}) + + +@login_required +def get_attributes_list_api(request): + """ + API для получения списка всех атрибутов с их значениями. + Используется для autocomplete в форме создания вариативного товара. + """ + attributes = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name') + + data = [] + for attr in attributes: + data.append({ + 'id': attr.pk, + 'name': attr.name, + 'slug': attr.slug, + 'values': [ + { + 'id': val.pk, + 'value': val.value, + 'slug': val.slug + } + for val in attr.values.all().order_by('position', 'value') + ] + }) + + return JsonResponse({'success': True, 'attributes': data}) diff --git a/myproject/products/views/configurableproduct_views.py b/myproject/products/views/configurableproduct_views.py index 81abb03..0bedeb6 100644 --- a/myproject/products/views/configurableproduct_views.py +++ b/myproject/products/views/configurableproduct_views.py @@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required from django.db import transaction from user_roles.mixins import ManagerOwnerRequiredMixin -from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute +from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute, ProductAttribute from ..forms import ( ConfigurableProductForm, ConfigurableProductOptionFormSetCreate, @@ -144,6 +144,9 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi is_temporary=False ).order_by('name') + # Справочник атрибутов для autocomplete + context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name') + return context def form_valid(self, form): @@ -420,6 +423,9 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi is_temporary=False ).order_by('name') + # Справочник атрибутов для autocomplete + context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name') + return context def form_valid(self, form):