feat: Улучшенная форма создания комплектов товаров

Реализованы следующие улучшения для формы создания/редактирования комплектов:

1. **Улучшенный API поиска товаров:**
   - Добавлен поиск по полю search_keywords для более точных результатов
   - Добавлены дополнительные поля: display_name, display_price

2. **Предпросмотр загружаемых фото:**
   - Миниатюры выбранных файлов перед загрузкой
   - Счетчик выбранных файлов
   - Возможность удаления отдельных фото до отправки формы

3. **Динамическое добавление товаров:**
   - Кнопка "Добавить товар в комплект" для создания новых строк
   - Автопоиск товаров при вводе текста (задержка 300мс)
   - Автоматическое добавление при клике на результат
   - Визуальные уведомления об успешном добавлении
   - Прокрутка к новой форме после добавления

4. **Валидация на дубликаты:**
   - Предупреждение при попытке добавить существующий товар
   - Подсветка дубликата на 2 секунды
   - Предложение изменить количество в существующей строке

5. **Автоматический расчет цены:**
   - Информационный блок с суммой товаров и их количеством
   - Пересчет при добавлении/удалении товаров
   - Пересчет при изменении количества
   - Асинхронная загрузка цен для существующих товаров

6. **Исправления:**
   - Снятие disabled с полей select перед отправкой формы
   - Правильное создание новых форм при добавлении товаров через поиск
   - Использование display_name для корректного отображения

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-23 15:52:29 +03:00
parent 9a232c6813
commit dd2b1f21f7
3 changed files with 545 additions and 71 deletions

View File

@@ -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'),
),
]

View File

@@ -109,6 +109,7 @@
<div class="mb-0"> <div class="mb-0">
<label for="id_photos" class="form-label fw-bold"> <label for="id_photos" class="form-label fw-bold">
{% if object %} Добавить новые фото{% else %}📷 Загрузить фото{% endif %} {% if object %} Добавить новые фото{% else %}📷 Загрузить фото{% endif %}
<span id="photoCount" class="badge bg-secondary ms-2" style="display: none;">0 выбрано</span>
</label> </label>
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos"> <input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
<small class="form-text text-muted"> <small class="form-text text-muted">
@@ -118,6 +119,12 @@
Выберите фото для комплекта (можно выбрать несколько, до 10 штук) Выберите фото для комплекта (можно выбрать несколько, до 10 штук)
{% endif %} {% endif %}
</small> </small>
<!-- Контейнер для предпросмотра выбранных фото -->
<div id="photoPreviewContainer" class="mt-3" style="display: none;">
<h6 class="mb-2">Выбранные фото:</h6>
<div id="photoPreview" class="row g-2"></div>
</div>
</div> </div>
</div> </div>
@@ -184,6 +191,24 @@
{% endif %} {% endif %}
</div> </div>
<!-- Информационный блок с расчетом стоимости -->
<div class="alert alert-info mb-4" id="priceCalculationInfo">
<h6 class="mb-2"><i class="bi bi-calculator"></i> Автоматический расчет стоимости</h6>
<div class="row">
<div class="col-md-6">
<strong>Сумма товаров:</strong>
<span id="totalProductsPrice" class="fs-5 text-primary">0.00 ₽</span>
</div>
<div class="col-md-6">
<strong>Количество товаров:</strong>
<span id="totalProductsCount" class="fs-5">0</span>
</div>
</div>
<small class="text-muted mt-2 d-block">
Сумма автоматически пересчитывается при добавлении/удалении товаров и изменении количества
</small>
</div>
{% for field in form %} {% for field in form %}
{% if field.name not in 'name,sku,categories,description,tags' %} {% if field.name not in 'name,sku,categories,description,tags' %}
{% if field.name == 'fixed_price' %} {% if field.name == 'fixed_price' %}
@@ -241,26 +266,27 @@
<strong>Подсказка:</strong> Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров). <strong>Подсказка:</strong> Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров).
</div> </div>
<!-- Поиск товаров для быстрого добавления (показываем только при редактировании) --> <!-- Поиск товаров для быстрого добавления -->
<div class="card mb-4 bg-light border-info" id="searchCard"> <div class="card mb-4 bg-light border-success" id="searchCard">
<div class="card-header bg-info text-white"> <div class="card-header bg-success text-white">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="bi bi-search"></i> Поиск товара для добавления <i class="bi bi-search"></i> Быстрое добавление товара
</h6> </h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="input-group mb-2"> <div class="mb-2">
<label for="productSearch" class="form-label">Найти товар:</label>
<input type="text" <input type="text"
id="productSearch" id="productSearch"
class="form-control" class="form-control"
placeholder="Введите название или артикул товара (минимум 2 символа)..." placeholder="Начните вводить название или артикул (минимум 2 символа)..."
autocomplete="off"> autocomplete="off">
<button class="btn btn-info" type="button" id="searchBtn">
<i class="bi bi-search"></i> Поиск
</button>
</div> </div>
<div id="searchResults" class="list-group" style="display: none; max-height: 300px; overflow-y: auto;"></div> <div id="searchResults" class="list-group mb-2" style="display: none; max-height: 250px; overflow-y: auto;"></div>
<small class="text-muted">Используйте поиск для быстрого нахождения товара и добавления его в комплект</small> <small class="text-muted">
<i class="bi bi-info-circle"></i>
Товар добавится автоматически при клике на результат поиска
</small>
</div> </div>
</div> </div>
@@ -354,6 +380,13 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Кнопка добавления нового товара -->
<div class="mb-3">
<button type="button" class="btn btn-success" id="addKitItemBtn">
<i class="bi bi-plus-circle"></i> Добавить товар в комплект
</button>
</div>
<!-- Кнопки действия --> <!-- Кнопки действия -->
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap"> <div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
<a href="{% url 'products:productkit-list' %}" class="btn btn-secondary">Отмена</a> <a href="{% url 'products:productkit-list' %}" class="btn btn-secondary">Отмена</a>
@@ -381,13 +414,6 @@
document.querySelector('[name$="-id"]') !== null && document.querySelector('[name$="-id"]') !== null &&
document.querySelector('[name$="-id"]').value !== ''; document.querySelector('[name$="-id"]').value !== '';
// Если режим редактирования - показываем карточку поиска
// Если создание - скрываем (будет одна пустая форма)
const searchCard = document.getElementById('searchCard');
if (!isEditMode && searchCard) {
searchCard.style.display = 'none';
}
// ========== ФУНКЦИИ ДЛЯ УПРАВЛЕНИЯ ВИДИМОСТЬЮ ПОЛЕЙ ========== // ========== ФУНКЦИИ ДЛЯ УПРАВЛЕНИЯ ВИДИМОСТЬЮ ПОЛЕЙ ==========
function updateFieldStatus(form) { function updateFieldStatus(form) {
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
@@ -420,17 +446,25 @@
// Инициализируем все существующие формы // Инициализируем все существующие формы
const kititemForms = document.querySelectorAll('.kititem-form'); const kititemForms = document.querySelectorAll('.kititem-form');
kititemForms.forEach((form) => { kititemForms.forEach((form) => {
updateFieldStatus(form); initializeForm(form);
const productSelect = form.querySelector('[name$="-product"]'); // Для существующих форм нужно загрузить цены товаров
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); loadProductPriceForForm(form);
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form)); });
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
// Инициализируем расчет цены при загрузке страницы
setTimeout(() => calculateKitPrice(), 500); // Небольшая задержка для загрузки цен
// Добавляем слушатель на чекбоксы DELETE для пересчета при удалении
document.addEventListener('change', (e) => {
if (e.target.matches('[name$="-DELETE"]')) {
calculateKitPrice();
}
}); });
// ========== ФУНКЦИИ ДЛЯ ПОИСКА И ДОБАВЛЕНИЯ ТОВАРОВ ========== // ========== ФУНКЦИИ ДЛЯ ПОИСКА И ДОБАВЛЕНИЯ ТОВАРОВ ==========
const searchInput = document.getElementById('productSearch'); const searchInput = document.getElementById('productSearch');
const searchBtn = document.getElementById('searchBtn');
const searchResults = document.getElementById('searchResults'); const searchResults = document.getElementById('searchResults');
const addKitItemBtn = document.getElementById('addKitItemBtn');
const managementForm = document.querySelector('[name$="TOTAL_FORMS"]'); const managementForm = document.querySelector('[name$="TOTAL_FORMS"]');
// Функция для выполнения поиска // Функция для выполнения поиска
@@ -488,43 +522,76 @@
function addItemToFormset(item) { function addItemToFormset(item) {
const formsContainer = document.getElementById('kititem-forms'); const formsContainer = document.getElementById('kititem-forms');
const existingForms = formsContainer.querySelectorAll('.kititem-form'); 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) => { existingForms.forEach((form) => {
if (!emptyForm) { const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
const productSelect = form.querySelector('[name$="-product"]'); // Пропускаем формы, помеченные на удаление
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); if (deleteCheckbox && deleteCheckbox.checked) return;
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
// Если форма пустая и не помечена на удаление if (itemType === 'product') {
if ((!productSelect || !productSelect.value) && const productSelect = form.querySelector('[name$="-product"]');
(!variantGroupSelect || !variantGroupSelect.value) && if (productSelect && productSelect.value == item.id) {
(!deleteCheckbox || !deleteCheckbox.checked)) { isDuplicate = true;
emptyForm = form; duplicateForm = form;
}
} else {
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
if (variantGroupSelect && variantGroupSelect.value == item.id) {
isDuplicate = true;
duplicateForm = form;
} }
} }
}); });
// Если нашли пустую форму, заполняем ее // Если товар уже добавлен - показываем предупреждение
if (emptyForm) { if (isDuplicate) {
fillFormWithItem(emptyForm, item); const alertDiv = document.createElement('div');
} else { alertDiv.className = 'alert alert-warning alert-dismissible fade show';
// Если нет пустой формы, создаем новую alertDiv.setAttribute('role', 'alert');
const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value); alertDiv.innerHTML = `
const newFormHtml = createNewKitItemForm(totalForms, item); <strong>⚠️ Внимание!</strong> "${item.display_name || item.name}" уже добавлен в комплект.
const tempDiv = document.createElement('div'); Если нужно изменить количество, отредактируйте существующую строку.
tempDiv.innerHTML = newFormHtml; <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
formsContainer.appendChild(tempDiv.firstElementChild); `;
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; searchInput.value = '';
initializeForm(newForm); 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 = ''; searchInput.value = '';
searchResults.style.display = 'none'; searchResults.style.display = 'none';
@@ -534,7 +601,7 @@
alertDiv.className = 'alert alert-success alert-dismissible fade show'; alertDiv.className = 'alert alert-success alert-dismissible fade show';
alertDiv.setAttribute('role', 'alert'); alertDiv.setAttribute('role', 'alert');
alertDiv.innerHTML = ` alertDiv.innerHTML = `
<strong>✓ Товар добавлен!</strong> "${item.name}" добавлен в комплект. <strong>✓ Товар добавлен!</strong> "${item.display_name || item.name}" добавлен в комплект.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`; `;
document.querySelector('.card-body').prepend(alertDiv); document.querySelector('.card-body').prepend(alertDiv);
@@ -550,8 +617,15 @@
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
if (productSelect) { if (productSelect) {
productSelect.value = selectedId; productSelect.value = selectedId;
productSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.name}</option>`; productSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.display_name || item.name}</option>`;
productSelect.dispatchEvent(new Event('change', { bubbles: true })); 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 { } else {
const variantSelect = form.querySelector('[name$="-variant_group"]'); const variantSelect = form.querySelector('[name$="-variant_group"]');
@@ -563,6 +637,11 @@
} }
updateFieldStatus(form); updateFieldStatus(form);
// Пересчитываем цену комплекта
if (typeof calculateKitPrice === 'function') {
calculateKitPrice();
}
} }
// Функция для инициализации формы // Функция для инициализации формы
@@ -570,8 +649,178 @@
updateFieldStatus(form); updateFieldStatus(form);
const productSelect = form.querySelector('[name$="-product"]'); const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]'); const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form)); const quantityInput = form.querySelector('[name$="-quantity"]');
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
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 `
<div class="card mb-3 kititem-form" data-form-index="${formIndex}">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span><strong>Компонент #${formIndex + 1}</strong></span>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="${fieldName}-DELETE" name="${fieldName}-DELETE">
<label class="form-check-label" for="${fieldName}-DELETE">
Удалить
</label>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="${fieldName}-product" class="form-label">
Конкретный товар
</label>
<select class="form-control" id="${fieldName}-product" name="${fieldName}-product" data-new-record="False">
<option value="">---------</option>
</select>
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
</div>
<div class="col-md-6 mb-3">
<label for="${fieldName}-variant_group" class="form-label">
Группа вариантов
</label>
<select class="form-control" id="${fieldName}-variant_group" name="${fieldName}-variant_group" data-new-record="False">
<option value="">---------</option>
</select>
<small class="form-text text-muted">Группа вариантов (если выбрана, конкретный товар не нужен)</small>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="${fieldName}-quantity" class="form-label">
Количество
</label>
<input type="number" class="form-control product-quantity" id="${fieldName}-quantity" name="${fieldName}-quantity"
value="1" step="0.001" min="0" data-price="0">
</div>
<div class="col-md-8 mb-3">
<label for="${fieldName}-notes" class="form-label">
Примечание
</label>
<input type="text" class="form-control" id="${fieldName}-notes" name="${fieldName}-notes"
placeholder="Опциональное примечание">
</div>
</div>
<input type="hidden" name="${fieldName}-id">
</div>
</div>
`;
} }
// Функция для создания HTML новой формы // Функция для создания HTML новой формы
@@ -599,9 +848,9 @@
Конкретный товар Конкретный товар
</label> </label>
<select class="form-select" id="${fieldName}-product" name="${fieldName}-product" <select class="form-select" id="${fieldName}-product" name="${fieldName}-product"
data-new-record="False" ${itemType === 'variant' ? 'disabled' : ''} style="${itemType === 'variant' ? 'opacity: 0.6' : ''}"> data-new-record="False" style="${itemType === 'variant' ? 'opacity: 0.6' : ''}">
<option value="">---------</option> <option value="">---------</option>
${itemType === 'product' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''} ${itemType === 'product' ? `<option value="${selectedId}" selected>${item.display_name || item.name}</option>` : ''}
</select> </select>
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small> <small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
</div> </div>
@@ -611,7 +860,7 @@
Группа вариантов Группа вариантов
</label> </label>
<select class="form-select" id="${fieldName}-variant_group" name="${fieldName}-variant_group" <select class="form-select" id="${fieldName}-variant_group" name="${fieldName}-variant_group"
data-new-record="False" ${itemType === 'product' ? 'disabled' : ''} style="${itemType === 'product' ? 'opacity: 0.6' : ''}"> data-new-record="False" style="${itemType === 'product' ? 'opacity: 0.6' : ''}">
<option value="">---------</option> <option value="">---------</option>
${itemType === 'variant' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''} ${itemType === 'variant' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''}
</select> </select>
@@ -624,8 +873,8 @@
<label for="${fieldName}-quantity" class="form-label"> <label for="${fieldName}-quantity" class="form-label">
Количество Количество
</label> </label>
<input type="number" class="form-control" id="${fieldName}-quantity" name="${fieldName}-quantity" <input type="number" class="form-control product-quantity" id="${fieldName}-quantity" name="${fieldName}-quantity"
value="1" step="0.001" min="0"> value="1" step="0.001" min="0" data-price="${item.price || 0}">
</div> </div>
<div class="col-md-8 mb-3"> <div class="col-md-8 mb-3">
@@ -644,21 +893,28 @@
} }
// Обработчики событий поиска // Обработчики событий поиска
searchBtn.addEventListener('click', () => { if (searchInput) {
performSearch(searchInput.value); searchInput.addEventListener('input', (e) => {
});
searchInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
performSearch(searchInput.value);
} else {
// Автопоиск с задержкой // Автопоиск с задержкой
clearTimeout(searchInput.searchTimeout); clearTimeout(searchInput.searchTimeout);
searchInput.searchTimeout = setTimeout(() => { searchInput.searchTimeout = setTimeout(() => {
performSearch(searchInput.value); performSearch(searchInput.value);
}, 300); }, 300);
} });
});
searchInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
performSearch(searchInput.value);
}
});
}
// Обработчик кнопки "Добавить товар"
if (addKitItemBtn) {
addKitItemBtn.addEventListener('click', () => {
addEmptyKitItemForm();
});
}
// Очистка результатов при клике вне области // Очистка результатов при клике вне области
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@@ -673,6 +929,13 @@
const kitForm = document.querySelector('form[method="post"]'); const kitForm = document.querySelector('form[method="post"]');
if (kitForm) { if (kitForm) {
kitForm.addEventListener('submit', function(e) { 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'); const formsContainer = document.getElementById('kititem-forms');
if (formsContainer) { if (formsContainer) {
@@ -732,5 +995,80 @@
pricingMethodField.addEventListener('change', updatePricingFieldsState); 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 = `
<div class="card position-relative" data-file-index="${index}">
<img src="${event.target.result}" class="card-img-top" alt="Preview" style="height: 150px; object-fit: cover;">
<div class="card-body p-2">
<small class="text-truncate d-block">${file.name}</small>
<button type="button" class="btn btn-sm btn-danger w-100 mt-1 remove-photo-btn" data-file-index="${index}">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
`;
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'));
}
});
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -46,7 +46,8 @@ def search_products_and_variants(request):
products = Product.objects.filter( products = Product.objects.filter(
models.Q(name__icontains=query) | models.Q(name__icontains=query) |
models.Q(sku__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 is_active=True
).values('id', 'name', 'sku', 'sale_price')[:10] ).values('id', 'name', 'sku', 'sale_price')[:10]
@@ -56,7 +57,9 @@ def search_products_and_variants(request):
'name': f"{product['name']} ({product['sku']})", 'name': f"{product['name']} ({product['sku']})",
'sku': product['sku'], 'sku': product['sku'],
'type': 'product', 'type': 'product',
'price': str(product['sale_price']) 'price': str(product['sale_price']),
'display_name': product['name'],
'display_price': f"{product['sale_price']:.2f}"
}) })
# Поиск групп вариантов # Поиск групп вариантов