Исправлена работа с 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:
@@ -149,10 +149,10 @@ OrderItemFormSet = inlineformset_factory(
|
|||||||
Order,
|
Order,
|
||||||
OrderItem,
|
OrderItem,
|
||||||
form=OrderItemForm,
|
form=OrderItemForm,
|
||||||
extra=1, # Одна пустая форма для добавления
|
extra=0, # Без пустых форм (будем добавлять через JavaScript)
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
min_num=1, # Минимум 1 товар в заказе
|
min_num=0, # Минимум 0 товаров (валидация на уровне бизнес-логики)
|
||||||
validate_min=True,
|
validate_min=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
|
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@@ -222,16 +237,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 text-end">
|
<div class="col-md-2 text-end">
|
||||||
{% if formset.can_delete %}
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="form-check">
|
<label class="form-label d-block"> </label>
|
||||||
{{ item_form.DELETE }}
|
{% if formset.can_delete %}
|
||||||
<label class="form-check-label" for="{{ item_form.DELETE.id_for_label }}">
|
{{ item_form.DELETE }}
|
||||||
Удалить
|
{% endif %}
|
||||||
</label>
|
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
|
||||||
</div>
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if item_form.errors %}
|
{% if item_form.errors %}
|
||||||
@@ -240,7 +254,58 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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"> </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> Добавить товар
|
<i class="bi bi-plus-circle"></i> Добавить товар
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal">
|
<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 container = document.getElementById('order-items-container');
|
||||||
const addButton = document.getElementById('add-item-btn');
|
const addButton = document.getElementById('add-item-btn');
|
||||||
const totalFormsInput = document.querySelector('#id_items-TOTAL_FORMS');
|
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 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');
|
const templateContent = emptyFormTemplate.querySelector('.order-item-form');
|
||||||
newForm.innerHTML = newForm.innerHTML.replace(regex, `items-${formCount}-`);
|
const newForm = templateContent.cloneNode(true);
|
||||||
|
|
||||||
|
// Заменяем __prefix__ на реальный индекс
|
||||||
|
newForm.innerHTML = newForm.innerHTML.replace(/__prefix__/g, formCount);
|
||||||
newForm.dataset.formIndex = 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);
|
container.appendChild(newForm);
|
||||||
|
|
||||||
|
// Обновляем счетчик форм
|
||||||
totalFormsInput.value = formCount + 1;
|
totalFormsInput.value = formCount + 1;
|
||||||
|
|
||||||
// Инициализируем Select2 для новой формы
|
// Инициализируем Select2 для новой формы
|
||||||
|
const select2Element = newForm.querySelector('.select2-order-item');
|
||||||
|
select2Element.dataset.formIndex = formCount;
|
||||||
initOrderItemSelect2(select2Element);
|
initOrderItemSelect2(select2Element);
|
||||||
|
|
||||||
// Инициализируем отслеживание цены для новой формы
|
// Инициализируем отслеживание цены
|
||||||
initPriceTracking(newForm);
|
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) {
|
document.getElementById('order-form').addEventListener('submit', function(e) {
|
||||||
const forms = container.querySelectorAll('.order-item-form');
|
const visibleForms = Array.from(container.querySelectorAll('.order-item-form'))
|
||||||
forms.forEach(form => {
|
.filter(f => !f.classList.contains('deleted'));
|
||||||
|
|
||||||
|
// Проверяем, что есть хотя бы одна позиция с товаром
|
||||||
|
let hasItems = false;
|
||||||
|
visibleForms.forEach(form => {
|
||||||
const productField = form.querySelector('[name$="-product"]');
|
const productField = form.querySelector('[name$="-product"]');
|
||||||
const kitField = form.querySelector('[name$="-product_kit"]');
|
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||||
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
|
if (productField.value || kitField.value) {
|
||||||
|
hasItems = true;
|
||||||
// Если оба поля пусты - пометить на удаление
|
|
||||||
if (!productField.value && !kitField.value && deleteCheckbox) {
|
|
||||||
deleteCheckbox.checked = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasItems && visibleForms.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Добавьте хотя бы один товар или комплект в заказ');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
// === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
|
||||||
|
|||||||
Reference in New Issue
Block a user