Исправлена работа с formset товаров в форме заказа

Проблемы:
- Отображались лишние пустые формы (extra=1)
- Новые формы не инициализировали Select2 корректно
- Неудобное удаление через checkbox

Исправления:
1. forms.py:
   - Изменен OrderItemFormSet: extra=0, min_num=0
   - Формы добавляются только через JavaScript

2. order_form.html:
   - Добавлен шаблон пустой формы (#empty-form-template)
   - Заменен checkbox "Удалить" на кнопку с иконкой
   - Полностью переписана логика добавления/удаления форм
   - Добавлена валидация: нельзя удалить единственную позицию
   - Скрытые формы (DELETE=true) визуально помечаются
   - Автодобавление первой формы при пустом formset
   - Валидация перед отправкой: минимум 1 товар

Результат:
- При создании заказа отображается только 1 пустая форма
- Кнопка "Добавить товар" корректно создает новые формы
- Select2 работает в каждой новой форме
- Кнопка "Удалить" работает корректно
- Единственную позицию удалить нельзя

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 22:49:26 +03:00
parent 1edeaeb552
commit 809d5a127a
2 changed files with 159 additions and 67 deletions

View File

@@ -149,10 +149,10 @@ OrderItemFormSet = inlineformset_factory(
Order,
OrderItem,
form=OrderItemForm,
extra=1, # Одна пустая форма для добавления
extra=0, # Без пустых форм (будем добавлять через JavaScript)
can_delete=True,
min_num=1, # Минимум 1 товар в заказе
validate_min=True,
min_num=0, # Минимум 0 товаров (валидация на уровне бизнес-логики)
validate_min=False,
)

View File

@@ -3,6 +3,21 @@
{% block title %}{{ title }}{% endblock %}
{% block extra_css %}
<style>
/* Скрываем DELETE checkbox */
input[name$="-DELETE"] {
display: none;
}
/* Визуально помечаем удаленные формы */
.order-item-form.deleted {
opacity: 0.5;
pointer-events: none;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
@@ -222,16 +237,15 @@
</div>
</div>
<div class="col-md-2 text-end">
{% if formset.can_delete %}
<div class="mb-2">
<div class="form-check">
{{ item_form.DELETE }}
<label class="form-check-label" for="{{ item_form.DELETE.id_for_label }}">
Удалить
</label>
</div>
<label class="form-label d-block">&nbsp;</label>
{% if formset.can_delete %}
{{ item_form.DELETE }}
{% endif %}
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
{% endif %}
</div>
</div>
{% if item_form.errors %}
@@ -240,7 +254,58 @@
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-secondary" id="add-item-btn">
<!-- Скрытый шаблон для новых форм -->
<div id="empty-form-template" style="display: none;">
<div class="order-item-form border rounded p-3 mb-3" data-form-index="__prefix__">
<input type="hidden" name="items-__prefix__-id" id="id_items-__prefix__-id">
<input type="hidden" name="items-__prefix__-product" id="id_items-__prefix__-product">
<input type="hidden" name="items-__prefix__-product_kit" id="id_items-__prefix__-product_kit">
<input type="hidden" name="items-__prefix__-is_custom_price" id="id_items-__prefix__-is_custom_price" value="false">
<div class="row align-items-end">
<div class="col-md-5">
<div class="mb-2">
<label class="form-label">Товар или комплект</label>
<select class="form-select select2-order-item" data-form-index="__prefix__">
<option value=""></option>
</select>
</div>
</div>
<div class="col-md-2">
<div class="mb-2">
<label class="form-label">Количество</label>
<input type="number" name="items-__prefix__-quantity" step="1" min="1" value="1" class="form-control" id="id_items-__prefix__-quantity">
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label class="form-label">Цена</label>
<div class="position-relative">
<input type="number" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
Изменена
</span>
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
Оригинальная: <span class="original-price-value"></span> руб.
</small>
</div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="mb-2">
<label class="form-label d-block">&nbsp;</label>
<input type="checkbox" name="items-__prefix__-DELETE" id="id_items-__prefix__-DELETE">
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-success" id="add-item-btn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
<button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal">
@@ -459,80 +524,107 @@ document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('order-items-container');
const addButton = document.getElementById('add-item-btn');
const totalFormsInput = document.querySelector('#id_items-TOTAL_FORMS');
const emptyFormTemplate = document.getElementById('empty-form-template');
addButton.addEventListener('click', function() {
// Функция для добавления новой формы
function addNewForm() {
const formCount = parseInt(totalFormsInput.value);
const lastForm = container.querySelector('.order-item-form:last-child');
const newForm = lastForm.cloneNode(true);
// Обновляем индексы в новой форме
const regex = new RegExp('items-(\\d+)-', 'g');
newForm.innerHTML = newForm.innerHTML.replace(regex, `items-${formCount}-`);
// Клонируем шаблон
const templateContent = emptyFormTemplate.querySelector('.order-item-form');
const newForm = templateContent.cloneNode(true);
// Заменяем __prefix__ на реальный индекс
newForm.innerHTML = newForm.innerHTML.replace(/__prefix__/g, formCount);
newForm.dataset.formIndex = formCount;
// Очищаем значения
newForm.querySelectorAll('input[type="hidden"]').forEach(input => {
if (!input.name.includes('-id')) {
input.value = '';
}
});
newForm.querySelectorAll('input[type="number"]').forEach(input => {
input.value = '';
});
// Очищаем поле цены
const priceField = newForm.querySelector('[name$="-price"]');
if (priceField) {
priceField.value = '';
delete priceField.dataset.originalPrice;
}
newForm.querySelectorAll('input[type="checkbox"]').forEach(input => {
if (input.name.includes('DELETE')) {
input.checked = false;
}
});
// Скрываем индикаторы кастомной цены
const badge = newForm.querySelector('.custom-price-badge');
const priceInfo = newForm.querySelector('.original-price-info');
if (badge) badge.style.display = 'none';
if (priceInfo) priceInfo.style.display = 'none';
// Удаляем и пересоздаем Select2
const select2Element = newForm.querySelector('.select2-order-item');
const $select2Element = $(select2Element);
// Если Select2 уже был инициализирован, уничтожаем его
if ($select2Element.data('select2')) {
$select2Element.select2('destroy');
}
// Очищаем все опции, кроме первой пустой
select2Element.innerHTML = '<option value=""></option>';
select2Element.dataset.formIndex = formCount;
// Добавляем форму в контейнер
container.appendChild(newForm);
// Обновляем счетчик форм
totalFormsInput.value = formCount + 1;
// Инициализируем Select2 для новой формы
const select2Element = newForm.querySelector('.select2-order-item');
select2Element.dataset.formIndex = formCount;
initOrderItemSelect2(select2Element);
// Инициализируем отслеживание цены для новой формы
// Инициализируем отслеживание цены
initPriceTracking(newForm);
// Добавляем обработчик удаления
const removeBtn = newForm.querySelector('.remove-item-btn');
removeBtn.addEventListener('click', function() {
removeForm(newForm);
});
console.log(`Added new form with index ${formCount}`);
return newForm;
}
// Функция для удаления формы
function removeForm(form) {
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
const idField = form.querySelector('input[name$="-id"]');
// Проверяем, не последняя ли это форма
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
.filter(f => !f.classList.contains('deleted'));
if (visibleForms.length <= 1) {
alert('Нельзя удалить единственную позицию. Добавьте новую позицию перед удалением этой.');
return;
}
// Если форма уже сохранена (есть ID), помечаем на удаление
if (idField && idField.value) {
deleteCheckbox.checked = true;
form.classList.add('deleted');
console.log('Form marked for deletion');
} else {
// Если форма новая, просто удаляем из DOM
form.remove();
console.log('Form removed from DOM');
}
}
// Обработчик кнопки добавления
addButton.addEventListener('click', addNewForm);
// Добавляем обработчики удаления для существующих форм
container.querySelectorAll('.remove-item-btn').forEach(btn => {
btn.addEventListener('click', function() {
const form = btn.closest('.order-item-form');
removeForm(form);
});
});
// Перед отправкой: удалить пустые формы
// Если нет ни одной формы, добавляем одну автоматически
if (container.querySelectorAll('.order-item-form').length === 0) {
addNewForm();
}
// Валидация перед отправкой
document.getElementById('order-form').addEventListener('submit', function(e) {
const forms = container.querySelectorAll('.order-item-form');
forms.forEach(form => {
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
.filter(f => !f.classList.contains('deleted'));
// Проверяем, что есть хотя бы одна позиция с товаром
let hasItems = false;
visibleForms.forEach(form => {
const productField = form.querySelector('[name$="-product"]');
const kitField = form.querySelector('[name$="-product_kit"]');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
// Если оба поля пусты - пометить на удаление
if (!productField.value && !kitField.value && deleteCheckbox) {
deleteCheckbox.checked = true;
if (productField.value || kitField.value) {
hasItems = true;
}
});
if (!hasItems && visibleForms.length > 0) {
e.preventDefault();
alert('Добавьте хотя бы один товар или комплект в заказ');
return false;
}
});
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===