diff --git a/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py b/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py new file mode 100644 index 0000000..d49ec89 --- /dev/null +++ b/myproject/products/migrations/0002_product_deleted_at_product_deleted_by_and_more.py @@ -0,0 +1,133 @@ +# Generated by Django 5.2.7 on 2025-10-23 12:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), + ), + migrations.AddField( + model_name='product', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), + ), + migrations.AddField( + model_name='product', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), + ), + migrations.AddField( + model_name='product', + name='slug', + field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'), + ), + migrations.AddField( + model_name='productcategory', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'), + ), + migrations.AddField( + model_name='productcategory', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), + ), + migrations.AddField( + model_name='productcategory', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to=settings.AUTH_USER_MODEL, verbose_name='Удалена пользователем'), + ), + migrations.AddField( + model_name='productcategory', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False, verbose_name='Удалена'), + ), + migrations.AddField( + model_name='productcategory', + name='updated_at', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'), + ), + migrations.AddField( + model_name='productkit', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), + ), + migrations.AddField( + model_name='productkit', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), + ), + migrations.AddField( + model_name='productkit', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), + ), + migrations.AddField( + model_name='producttag', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'), + ), + migrations.AddField( + model_name='producttag', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'), + ), + migrations.AddField( + model_name='producttag', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'), + ), + migrations.AddField( + model_name='producttag', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'), + ), + migrations.AddField( + model_name='producttag', + name='updated_at', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'), + ), + migrations.AddIndex( + model_name='productcategory', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_2a96d1_idx'), + ), + migrations.AddIndex( + model_name='productcategory', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'), + ), + migrations.AddIndex( + model_name='productkit', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'), + ), + migrations.AddIndex( + model_name='productkit', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'), + ), + migrations.AddIndex( + model_name='producttag', + index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'), + ), + migrations.AddIndex( + model_name='producttag', + index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'), + ), + ] diff --git a/myproject/products/templates/products/productkit_form.html b/myproject/products/templates/products/productkit_form.html index 303fd6b..630d7cc 100644 --- a/myproject/products/templates/products/productkit_form.html +++ b/myproject/products/templates/products/productkit_form.html @@ -109,6 +109,7 @@
@@ -118,6 +119,12 @@ Выберите фото для комплекта (можно выбрать несколько, до 10 штук) {% endif %} + + +
@@ -184,6 +191,24 @@ {% endif %} + +
+
Автоматический расчет стоимости
+
+
+ Сумма товаров: + 0.00 ₽ +
+
+ Количество товаров: + 0 +
+
+ + Сумма автоматически пересчитывается при добавлении/удалении товаров и изменении количества + +
+ {% for field in form %} {% if field.name not in 'name,sku,categories,description,tags' %} {% if field.name == 'fixed_price' %} @@ -241,26 +266,27 @@ Подсказка: Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров). - -
-
+ +
+
- Поиск товара для добавления + Быстрое добавление товара
-
+
+ -
- - Используйте поиск для быстрого нахождения товара и добавления его в комплект + + + + Товар добавится автоматически при клике на результат поиска +
@@ -354,6 +380,13 @@ {% endfor %}
+ +
+ +
+
Отмена @@ -381,13 +414,6 @@ document.querySelector('[name$="-id"]') !== null && document.querySelector('[name$="-id"]').value !== ''; - // Если режим редактирования - показываем карточку поиска - // Если создание - скрываем (будет одна пустая форма) - const searchCard = document.getElementById('searchCard'); - if (!isEditMode && searchCard) { - searchCard.style.display = 'none'; - } - // ========== ФУНКЦИИ ДЛЯ УПРАВЛЕНИЯ ВИДИМОСТЬЮ ПОЛЕЙ ========== function updateFieldStatus(form) { const productSelect = form.querySelector('[name$="-product"]'); @@ -420,17 +446,25 @@ // Инициализируем все существующие формы const kititemForms = document.querySelectorAll('.kititem-form'); kititemForms.forEach((form) => { - updateFieldStatus(form); - const productSelect = form.querySelector('[name$="-product"]'); - const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); - if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form)); - if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form)); + initializeForm(form); + // Для существующих форм нужно загрузить цены товаров + loadProductPriceForForm(form); + }); + + // Инициализируем расчет цены при загрузке страницы + setTimeout(() => calculateKitPrice(), 500); // Небольшая задержка для загрузки цен + + // Добавляем слушатель на чекбоксы DELETE для пересчета при удалении + document.addEventListener('change', (e) => { + if (e.target.matches('[name$="-DELETE"]')) { + calculateKitPrice(); + } }); // ========== ФУНКЦИИ ДЛЯ ПОИСКА И ДОБАВЛЕНИЯ ТОВАРОВ ========== const searchInput = document.getElementById('productSearch'); - const searchBtn = document.getElementById('searchBtn'); const searchResults = document.getElementById('searchResults'); + const addKitItemBtn = document.getElementById('addKitItemBtn'); const managementForm = document.querySelector('[name$="TOTAL_FORMS"]'); // Функция для выполнения поиска @@ -488,43 +522,76 @@ function addItemToFormset(item) { const formsContainer = document.getElementById('kititem-forms'); const existingForms = formsContainer.querySelectorAll('.kititem-form'); + const itemType = item.type === 'product' ? 'product' : 'variant'; - // Ищем первую пустую форму и используем ее - let emptyForm = null; + // Проверка на дубликаты + let isDuplicate = false; + let duplicateForm = null; existingForms.forEach((form) => { - if (!emptyForm) { - const productSelect = form.querySelector('[name$="-product"]'); - const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); - const deleteCheckbox = form.querySelector('[name$="-DELETE"]'); + const deleteCheckbox = form.querySelector('[name$="-DELETE"]'); + // Пропускаем формы, помеченные на удаление + if (deleteCheckbox && deleteCheckbox.checked) return; - // Если форма пустая и не помечена на удаление - if ((!productSelect || !productSelect.value) && - (!variantGroupSelect || !variantGroupSelect.value) && - (!deleteCheckbox || !deleteCheckbox.checked)) { - emptyForm = form; + if (itemType === 'product') { + const productSelect = form.querySelector('[name$="-product"]'); + if (productSelect && productSelect.value == item.id) { + isDuplicate = true; + duplicateForm = form; + } + } else { + const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); + if (variantGroupSelect && variantGroupSelect.value == item.id) { + isDuplicate = true; + duplicateForm = form; } } }); - // Если нашли пустую форму, заполняем ее - if (emptyForm) { - fillFormWithItem(emptyForm, item); - } else { - // Если нет пустой формы, создаем новую - const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value); - const newFormHtml = createNewKitItemForm(totalForms, item); - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = newFormHtml; - formsContainer.appendChild(tempDiv.firstElementChild); + // Если товар уже добавлен - показываем предупреждение + if (isDuplicate) { + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-warning alert-dismissible fade show'; + alertDiv.setAttribute('role', 'alert'); + alertDiv.innerHTML = ` + ⚠️ Внимание! "${item.display_name || item.name}" уже добавлен в комплект. + Если нужно изменить количество, отредактируйте существующую строку. + + `; + document.querySelector('.card-body').prepend(alertDiv); + setTimeout(() => alertDiv.remove(), 5000); - // Обновляем счетчик форм - document.querySelector('[name*="TOTAL_FORMS"]').value = totalForms + 1; + // Подсвечиваем дубликат + if (duplicateForm) { + duplicateForm.scrollIntoView({ behavior: 'smooth', block: 'center' }); + duplicateForm.style.border = '2px solid #ffc107'; + setTimeout(() => { + duplicateForm.style.border = ''; + }, 2000); + } - // Инициализируем новую форму - const newForm = formsContainer.lastElementChild; - initializeForm(newForm); + // Очищаем поиск + searchInput.value = ''; + searchResults.style.display = 'none'; + return; } + // При добавлении через поиск всегда создаем новую форму + const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value); + const newFormHtml = createNewKitItemForm(totalForms, item); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newFormHtml; + formsContainer.appendChild(tempDiv.firstElementChild); + + // Обновляем счетчик форм + document.querySelector('[name*="TOTAL_FORMS"]').value = totalForms + 1; + + // Инициализируем новую форму + const newForm = formsContainer.lastElementChild; + initializeForm(newForm); + + // Прокручиваем к новой форме + newForm.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Очищаем поиск searchInput.value = ''; searchResults.style.display = 'none'; @@ -534,7 +601,7 @@ alertDiv.className = 'alert alert-success alert-dismissible fade show'; alertDiv.setAttribute('role', 'alert'); alertDiv.innerHTML = ` - ✓ Товар добавлен! "${item.name}" добавлен в комплект. + ✓ Товар добавлен! "${item.display_name || item.name}" добавлен в комплект. `; document.querySelector('.card-body').prepend(alertDiv); @@ -550,8 +617,15 @@ const productSelect = form.querySelector('[name$="-product"]'); if (productSelect) { productSelect.value = selectedId; - productSelect.innerHTML = ``; + productSelect.innerHTML = ``; productSelect.dispatchEvent(new Event('change', { bubbles: true })); + + // Сохраняем цену в data-атрибут поля количества + const quantityInput = form.querySelector('[name$="-quantity"]'); + if (quantityInput && item.price) { + quantityInput.setAttribute('data-price', item.price); + quantityInput.classList.add('product-quantity'); + } } } else { const variantSelect = form.querySelector('[name$="-variant_group"]'); @@ -563,6 +637,11 @@ } updateFieldStatus(form); + + // Пересчитываем цену комплекта + if (typeof calculateKitPrice === 'function') { + calculateKitPrice(); + } } // Функция для инициализации формы @@ -570,8 +649,178 @@ updateFieldStatus(form); const productSelect = form.querySelector('[name$="-product"]'); const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); - if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form)); - if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form)); + const quantityInput = form.querySelector('[name$="-quantity"]'); + + if (productSelect) productSelect.addEventListener('change', () => { + updateFieldStatus(form); + calculateKitPrice(); + }); + if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => { + updateFieldStatus(form); + calculateKitPrice(); + }); + if (quantityInput) quantityInput.addEventListener('input', () => { + calculateKitPrice(); + }); + } + + // Функция для загрузки цены товара для существующей формы + async function loadProductPriceForForm(form) { + const productSelect = form.querySelector('[name$="-product"]'); + const quantityInput = form.querySelector('[name$="-quantity"]'); + + if (productSelect && productSelect.value && quantityInput) { + const productId = productSelect.value; + const productName = productSelect.options[productSelect.selectedIndex]?.text; + + // Если цена уже есть в data-атрибуте, не загружаем повторно + if (quantityInput.getAttribute('data-price')) { + return; + } + + try { + // Делаем запрос к API для получения информации о товаре + // Используем название товара для поиска + if (productName) { + const response = await fetch(`{% url 'products:api-search-products-variants' %}?q=${encodeURIComponent(productName)}&type=product`); + const data = await response.json(); + + // Ищем товар с нужным ID + const product = data.results.find(item => item.id == productId); + if (product && product.price) { + quantityInput.setAttribute('data-price', product.price); + quantityInput.classList.add('product-quantity'); + } + } + } catch (error) { + console.error('Ошибка при загрузке цены товара:', error); + } + } + } + + // Функция для расчета общей стоимости комплекта + function calculateKitPrice() { + const formsContainer = document.getElementById('kititem-forms'); + const existingForms = formsContainer.querySelectorAll('.kititem-form'); + + let totalPrice = 0; + let totalCount = 0; + + existingForms.forEach((form) => { + const deleteCheckbox = form.querySelector('[name$="-DELETE"]'); + // Пропускаем формы, помеченные на удаление + if (deleteCheckbox && deleteCheckbox.checked) return; + + const productSelect = form.querySelector('[name$="-product"]'); + const quantityInput = form.querySelector('[name$="-quantity"]'); + + // Считаем только товары (не группы вариантов) + if (productSelect && productSelect.value && quantityInput) { + const quantity = parseFloat(quantityInput.value) || 0; + const price = parseFloat(quantityInput.getAttribute('data-price')) || 0; + + if (quantity > 0 && price > 0) { + totalPrice += price * quantity; + totalCount++; + } + } + }); + + // Обновляем отображение + const totalProductsPriceElement = document.getElementById('totalProductsPrice'); + const totalProductsCountElement = document.getElementById('totalProductsCount'); + + if (totalProductsPriceElement) { + totalProductsPriceElement.textContent = totalPrice.toFixed(2) + ' ₽'; + } + if (totalProductsCountElement) { + totalProductsCountElement.textContent = totalCount; + } + + return totalPrice; + } + + // Функция для добавления пустой формы товара + function addEmptyKitItemForm() { + const formsContainer = document.getElementById('kititem-forms'); + const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value); + + const newFormHtml = createEmptyKitItemForm(totalForms); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newFormHtml; + formsContainer.appendChild(tempDiv.firstElementChild); + + // Обновляем счетчик форм + document.querySelector('[name*="TOTAL_FORMS"]').value = totalForms + 1; + + // Инициализируем новую форму + const newForm = formsContainer.lastElementChild; + initializeForm(newForm); + + // Прокручиваем к новой форме + newForm.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + // Функция для создания HTML пустой формы + function createEmptyKitItemForm(formIndex) { + const fieldName = `kititem_set-${formIndex}`; + + return ` +
+
+ Компонент #${formIndex + 1} +
+ + +
+
+
+
+
+ + + Конкретный товар (если выбран, группа вариантов не нужна) +
+ +
+ + + Группа вариантов (если выбрана, конкретный товар не нужен) +
+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+ `; } // Функция для создания HTML новой формы @@ -599,9 +848,9 @@ Конкретный товар Конкретный товар (если выбран, группа вариантов не нужна)
@@ -611,7 +860,7 @@ Группа вариантов @@ -624,8 +873,8 @@ - +
@@ -644,21 +893,28 @@ } // Обработчики событий поиска - searchBtn.addEventListener('click', () => { - performSearch(searchInput.value); - }); - - searchInput.addEventListener('keyup', (e) => { - if (e.key === 'Enter') { - performSearch(searchInput.value); - } else { + if (searchInput) { + searchInput.addEventListener('input', (e) => { // Автопоиск с задержкой clearTimeout(searchInput.searchTimeout); searchInput.searchTimeout = setTimeout(() => { performSearch(searchInput.value); }, 300); - } - }); + }); + + searchInput.addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + performSearch(searchInput.value); + } + }); + } + + // Обработчик кнопки "Добавить товар" + if (addKitItemBtn) { + addKitItemBtn.addEventListener('click', () => { + addEmptyKitItemForm(); + }); + } // Очистка результатов при клике вне области document.addEventListener('click', (e) => { @@ -673,6 +929,13 @@ const kitForm = document.querySelector('form[method="post"]'); if (kitForm) { kitForm.addEventListener('submit', function(e) { + // Снимаем disabled со всех селектов перед отправкой + // (иначе их значения не попадут в POST запрос) + const allSelects = kitForm.querySelectorAll('select[disabled]'); + allSelects.forEach(select => { + select.disabled = false; + }); + // Отмечаем пустые компоненты для удаления const formsContainer = document.getElementById('kititem-forms'); if (formsContainer) { @@ -732,5 +995,80 @@ pricingMethodField.addEventListener('change', updatePricingFieldsState); } }); + + // ========== ПРЕДПРОСМОТР ЗАГРУЖАЕМЫХ ФОТО ========== + document.addEventListener('DOMContentLoaded', function() { + const photoInput = document.getElementById('id_photos'); + const photoPreview = document.getElementById('photoPreview'); + const photoPreviewContainer = document.getElementById('photoPreviewContainer'); + const photoCount = document.getElementById('photoCount'); + + // Массив для хранения выбранных файлов + let selectedFiles = []; + + if (photoInput) { + photoInput.addEventListener('change', function(e) { + const files = Array.from(e.target.files); + selectedFiles = files; // Сохраняем выбранные файлы + + if (files.length > 0) { + photoCount.style.display = 'inline'; + photoCount.textContent = `${files.length} выбрано`; + photoPreviewContainer.style.display = 'block'; + photoPreview.innerHTML = ''; + + files.forEach((file, index) => { + const reader = new FileReader(); + + reader.onload = function(event) { + const col = document.createElement('div'); + col.className = 'col-6 col-md-4 col-lg-3'; + + col.innerHTML = ` +
+ Preview +
+ ${file.name} + +
+
+ `; + + photoPreview.appendChild(col); + }; + + reader.readAsDataURL(file); + }); + } else { + photoCount.style.display = 'none'; + photoPreviewContainer.style.display = 'none'; + photoPreview.innerHTML = ''; + } + }); + + // Обработчик удаления отдельных фото + photoPreview.addEventListener('click', function(e) { + if (e.target.classList.contains('remove-photo-btn') || e.target.closest('.remove-photo-btn')) { + const btn = e.target.classList.contains('remove-photo-btn') ? e.target : e.target.closest('.remove-photo-btn'); + const fileIndex = parseInt(btn.getAttribute('data-file-index')); + + // Удаляем файл из массива + selectedFiles.splice(fileIndex, 1); + + // Создаем новый DataTransfer для обновления input + const dataTransfer = new DataTransfer(); + selectedFiles.forEach(file => { + dataTransfer.items.add(file); + }); + photoInput.files = dataTransfer.files; + + // Обновляем отображение + photoInput.dispatchEvent(new Event('change')); + } + }); + } + }); {% endblock %} diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 83073f3..0b6af64 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -46,7 +46,8 @@ def search_products_and_variants(request): products = Product.objects.filter( models.Q(name__icontains=query) | models.Q(sku__icontains=query) | - models.Q(description__icontains=query), + models.Q(description__icontains=query) | + models.Q(search_keywords__icontains=query), is_active=True ).values('id', 'name', 'sku', 'sale_price')[:10] @@ -56,7 +57,9 @@ def search_products_and_variants(request): 'name': f"{product['name']} ({product['sku']})", 'sku': product['sku'], 'type': 'product', - 'price': str(product['sale_price']) + 'price': str(product['sale_price']), + 'display_name': product['name'], + 'display_price': f"{product['sale_price']:.2f} ₽" }) # Поиск групп вариантов