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:
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,29 +522,60 @@
|
|||||||
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 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 (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');
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
<strong>⚠️ Внимание!</strong> "${item.display_name || item.name}" уже добавлен в комплект.
|
||||||
|
Если нужно изменить количество, отредактируйте существующую строку.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
document.querySelector('.card-body').prepend(alertDiv);
|
||||||
|
setTimeout(() => alertDiv.remove(), 5000);
|
||||||
|
|
||||||
|
// Подсвечиваем дубликат
|
||||||
|
if (duplicateForm) {
|
||||||
|
duplicateForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
duplicateForm.style.border = '2px solid #ffc107';
|
||||||
|
setTimeout(() => {
|
||||||
|
duplicateForm.style.border = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем поиск
|
||||||
|
searchInput.value = '';
|
||||||
|
searchResults.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// При добавлении через поиск всегда создаем новую форму
|
||||||
const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value);
|
const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value);
|
||||||
const newFormHtml = createNewKitItemForm(totalForms, item);
|
const newFormHtml = createNewKitItemForm(totalForms, item);
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
@@ -523,7 +588,9 @@
|
|||||||
// Инициализируем новую форму
|
// Инициализируем новую форму
|
||||||
const newForm = formsContainer.lastElementChild;
|
const newForm = formsContainer.lastElementChild;
|
||||||
initializeForm(newForm);
|
initializeForm(newForm);
|
||||||
}
|
|
||||||
|
// Прокручиваем к новой форме
|
||||||
|
newForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
// Очищаем поиск
|
// Очищаем поиск
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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} ₽"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Поиск групп вариантов
|
# Поиск групп вариантов
|
||||||
|
|||||||
Reference in New Issue
Block a user