refactor: Двухэтапное создание комплектов товаров

Реализован современный двухэтапный подход к созданию комплектов:

**Шаг 1: Создание базовой информации** (productkit_create.html)
- Упрощённая форма только с базовой информацией
- Название, описание, категории, теги
- Фотографии с предпросмотром
- Настройки ценообразования
- Чистый современный UI с breadcrumbs
- Прогрессивное раскрытие (collapsible секции)

**Шаг 2: Добавление товаров** (productkit_edit.html)
- Автоматический redirect после создания
- Работа с товарами через существующий функционал
- Улучшенный заголовок с указанием шага

**Изменения в коде:**
- ProductKitCreateView: упрощён, убраны formsets
- ProductKitUpdateView: использует новый шаблон
- productkit_form.html → productkit_edit.html
- Новый шаблон productkit_create.html

**Преимущества:**
 Простая и надёжная логика сохранения
 Нет проблем с disabled полями в formsets
 Понятный UX с чёткими шагами
 Современный дизайн с иконками Bootstrap
 Предпросмотр фото перед загрузкой

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-23 16:05:13 +03:00
parent dd2b1f21f7
commit 9d5ce11951
3 changed files with 1469 additions and 46 deletions

View File

@@ -0,0 +1,363 @@
{% extends 'base.html' %}
{% block title %}Создать комплект{% endblock %}
{% block content %}
<div class="container mt-4">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'products:productkit-list' %}">Комплекты</a></li>
<li class="breadcrumb-item active" aria-current="page">Создать комплект</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-box-seam"></i> Создание нового комплекта
</h4>
<small class="text-white-50">Шаг 1 из 2: Базовая информация</small>
</div>
<div class="card-body">
<!-- Информационное сообщение -->
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle"></i>
<strong>Как это работает:</strong> Сначала создайте комплект с базовой информацией,
затем вы сможете добавить товары на следующем шаге.
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- 1. ОСНОВНАЯ ИНФОРМАЦИЯ -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-pencil-square"></i> Основная информация
</h5>
<!-- Название -->
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label fw-bold required">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<!-- Описание -->
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description }}
{% if form.description.help_text %}
<small class="form-text text-muted">{{ form.description.help_text }}</small>
{% endif %}
{% if form.description.errors %}
<div class="text-danger">{{ form.description.errors }}</div>
{% endif %}
</div>
<!-- Категории -->
<div class="mb-3">
<label class="form-label">{{ form.categories.label }}</label>
<div class="border rounded p-3 bg-light">
{{ form.categories }}
</div>
{% if form.categories.errors %}
<div class="text-danger">{{ form.categories.errors }}</div>
{% endif %}
</div>
<!-- Теги -->
<div class="mb-3">
<label class="form-label">{{ form.tags.label }}</label>
<div class="border rounded p-3 bg-light">
{{ form.tags }}
</div>
{% if form.tags.errors %}
<div class="text-danger">{{ form.tags.errors }}</div>
{% endif %}
</div>
</div>
<!-- 2. ФОТОГРАФИИ -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-images"></i> Фотографии комплекта
</h5>
<div class="mb-3">
<label for="id_photos" class="form-label">
Загрузить фото
<span id="photoCount" class="badge bg-secondary ms-2" style="display: none;">0 выбрано</span>
</label>
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
<small class="form-text text-muted">
Выберите фото для комплекта (можно выбрать несколько, до 10 штук)
</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>
<!-- 3. ЦЕНООБРАЗОВАНИЕ -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-calculator"></i> Ценообразование
</h5>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Обратите внимание:</strong> Метод ценообразования можно настроить после добавления товаров в комплект.
По умолчанию цена будет рассчитываться как сумма цен входящих товаров.
</div>
<!-- Метод ценообразования -->
<div class="mb-3">
<label for="{{ form.pricing_method.id_for_label }}" class="form-label">
{{ form.pricing_method.label }}
</label>
{{ form.pricing_method }}
{% if form.pricing_method.help_text %}
<small class="form-text text-muted">{{ form.pricing_method.help_text }}</small>
{% endif %}
{% if form.pricing_method.errors %}
<div class="text-danger">{{ form.pricing_method.errors }}</div>
{% endif %}
</div>
<!-- Фиксированная цена (показывается при выборе 'fixed') -->
<div id="fixedPriceBlock" style="display: none;">
<div class="mb-3">
<label for="{{ form.fixed_price.id_for_label }}" class="form-label">
{{ form.fixed_price.label }}
</label>
{{ form.fixed_price }}
{% if form.fixed_price.errors %}
<div class="text-danger">{{ form.fixed_price.errors }}</div>
{% endif %}
</div>
</div>
<!-- Процент наценки -->
<div id="markupPercentBlock" style="display: none;">
<div class="mb-3">
<label for="{{ form.markup_percent.id_for_label }}" class="form-label">
{{ form.markup_percent.label }}
</label>
{{ form.markup_percent }}
{% if form.markup_percent.errors %}
<div class="text-danger">{{ form.markup_percent.errors }}</div>
{% endif %}
</div>
</div>
<!-- Фиксированная наценка -->
<div id="markupAmountBlock" style="display: none;">
<div class="mb-3">
<label for="{{ form.markup_amount.id_for_label }}" class="form-label">
{{ form.markup_amount.label }}
</label>
{{ form.markup_amount }}
{% if form.markup_amount.errors %}
<div class="text-danger">{{ form.markup_amount.errors }}</div>
{% endif %}
</div>
</div>
</div>
<!-- 4. ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (Collapsible) -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<a class="text-decoration-none text-dark" data-bs-toggle="collapse" href="#additionalSettings" role="button" aria-expanded="false">
<i class="bi bi-gear"></i> Дополнительные настройки
<i class="bi bi-chevron-down float-end"></i>
</a>
</h5>
<div class="collapse" id="additionalSettings">
<!-- Артикул -->
<div class="mb-3">
<label for="{{ form.sku.id_for_label }}" class="form-label">
{{ form.sku.label }}
</label>
{{ form.sku }}
{% if form.sku.help_text %}
<small class="form-text text-muted">{{ form.sku.help_text }}</small>
{% endif %}
{% if form.sku.errors %}
<div class="text-danger">{{ form.sku.errors }}</div>
{% endif %}
</div>
<!-- Активен -->
<div class="form-check">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{{ form.is_active.label }}
</label>
{% if form.is_active.errors %}
<div class="text-danger">{{ form.is_active.errors }}</div>
{% endif %}
</div>
</div>
</div>
<!-- 5. КНОПКИ ДЕЙСТВИЙ (Sticky footer) -->
<div class="d-flex justify-content-between mt-4 border-top pt-3">
<a href="{% url 'products:productkit-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-arrow-right-circle"></i> Далее: Добавить товары
</button>
</div>
</form>
</div>
</div>
<!-- Подсказка -->
<div class="alert alert-light mt-3">
<strong>💡 Совет:</strong> После создания базовой информации вы попадёте на страницу редактирования,
где сможете добавить товары в комплект через удобный поиск.
</div>
</div>
</div>
</div>
<style>
.required::after {
content: " *";
color: red;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// ========== УПРАВЛЕНИЕ ВИДИМОСТЬЮ ПОЛЕЙ ЦЕНООБРАЗОВАНИЯ ==========
const pricingMethodField = document.querySelector('[name="pricing_method"]');
const fixedPriceBlock = document.getElementById('fixedPriceBlock');
const markupPercentBlock = document.getElementById('markupPercentBlock');
const markupAmountBlock = document.getElementById('markupAmountBlock');
function updatePricingFieldsVisibility() {
if (!pricingMethodField) return;
const method = pricingMethodField.value;
// Скрываем все блоки
fixedPriceBlock.style.display = 'none';
markupPercentBlock.style.display = 'none';
markupAmountBlock.style.display = 'none';
// Показываем нужный блок
if (method === 'fixed') {
fixedPriceBlock.style.display = 'block';
} else if (method === 'from_cost_plus_percent') {
markupPercentBlock.style.display = 'block';
} else if (method === 'from_cost_plus_amount') {
markupAmountBlock.style.display = 'block';
}
}
// Инициализация
updatePricingFieldsVisibility();
// Обработчик изменения метода
if (pricingMethodField) {
pricingMethodField.addEventListener('change', updatePricingFieldsVisibility);
}
// ========== ПРЕДПРОСМОТР ЗАГРУЖАЕМЫХ ФОТО ==========
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);
const dataTransfer = new DataTransfer();
selectedFiles.forEach(file => {
dataTransfer.items.add(file);
});
photoInput.files = dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
}
});
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -83,72 +83,47 @@ class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
"""
View для создания нового комплекта с компонентами.
View для создания нового комплекта (только базовая информация).
После создания redirect на страницу редактирования для добавления товаров.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
template_name = 'products/productkit_create.html'
permission_required = 'products.add_productkit'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, instance=self.object)
else:
context['kititem_formset'] = KitItemFormSetCreate(instance=self.object)
return context
def form_valid(self, form):
# Получаем формсет из POST
kititem_formset = KitItemFormSetCreate(self.request.POST, instance=self.object)
# Проверяем валидность формсета
if kititem_formset.is_valid():
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем компоненты
kititem_formset.instance = self.object
kititem_formset.save()
# Обработка фотографий
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
messages.success(self.request, f'Комплект "{self.object.name}" успешно создан!')
messages.success(
self.request,
f'Комплект "{self.object.name}" создан! Теперь добавьте товары в комплект.'
)
# Проверяем, какую кнопку нажали
if self.request.POST.get('action') == 'continue':
# Всегда redirect на страницу редактирования для добавления товаров
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
else:
# Если формсет невалиден, показываем форму с ошибками
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
return self.form_invalid(form)
def form_invalid(self, form):
# Получаем формсет для отображения ошибок
context = self.get_context_data(form=form)
return self.render_to_response(context)
def get_success_url(self):
return reverse_lazy('products:productkit-list')
# Этот метод не используется, т.к. мы делаем redirect в form_valid
return reverse_lazy('products:productkit-update', kwargs={'pk': self.object.pk})
class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
"""
View для редактирования существующего комплекта.
View для редактирования существующего комплекта и добавления товаров.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
template_name = 'products/productkit_edit.html'
permission_required = 'products.change_productkit'
def get_context_data(self, **kwargs):