feat: Добавить отображение наличия товаров и цены вариантов во все шаблоны CRUD
Добавлена визуализация статуса наличия (in_stock) и цены вариантов во все CRUD шаблоны товаров и групп вариантов.
Product (товары):
- product_list.html: добавлена колонка "В наличии" с бейджами (зелёный/красный)
- product_detail.html: добавлена строка "В наличии" в таблицу данных товара
- product_form.html: добавлена информационная секция о наличии при редактировании
- all_products_list.html: добавлена колонка "В наличии" для товаров
- productkit_list.html: обновлены стили бейджей статуса
ProductVariantGroup (группы вариантов):
- variantgroup_list.html: добавлены колонки "В наличии" и "Цена" в таблицу групп
- variantgroup_detail.html: добавлены отображение наличия и цены в информационный блок слева
- variantgroup_detail.html: добавлена колонка "В наличии" в таблицу товаров группы
- variantgroup_form.html: добавлены отображение артикула, цены и статуса наличия в formset таблице
- variantgroup_form.html: добавлен JavaScript код для динамического обновления данных товара при выборе через Select2
- variantgroup_confirm_delete.html: добавлена информация о наличии и цене группы в окно подтверждения удаления
Views optimization:
- ProductVariantGroupListView: добавлен prefetch_related('items__product') для оптимизации N+1 запросов
- Все представления используют оптимизированные запросы для вычисления in_stock и price свойств
UI/UX улучшения:
- Используются Bootstrap 5 бейджи с иконками (bg-success/bg-danger)
- Визуальное выделение статуса наличия через цвет и значки
- Информативное отображение цены варианта во всех местах
- Динамическое обновление информации при выборе товаров в formset
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,7 @@
|
|||||||
<th>Артикул</th>
|
<th>Артикул</th>
|
||||||
<th>Категория</th>
|
<th>Категория</th>
|
||||||
<th>Цена продажи</th>
|
<th>Цена продажи</th>
|
||||||
|
<th>В наличии</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th style="width: 200px;">Действия</th>
|
<th style="width: 200px;">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -146,9 +147,21 @@
|
|||||||
{{ item.get_sale_price|floatformat:2 }} руб.
|
{{ item.get_sale_price|floatformat:2 }} руб.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.item_type == 'product' %}
|
||||||
|
{% if item.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<!-- Для комплектов проверяем есть ли вариант в наличии -->
|
||||||
|
<span class="text-muted small">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.is_active %}
|
{% if item.is_active %}
|
||||||
<span class="badge bg-success">Активен</span>
|
<span class="badge bg-info">Активен</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Неактивен</span>
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -161,11 +161,21 @@
|
|||||||
<th>Цена продажи:</th>
|
<th>Цена продажи:</th>
|
||||||
<td>{{ product.sale_price }} руб.</td>
|
<td>{{ product.sale_price }} руб.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>В наличии:</th>
|
||||||
|
<td>
|
||||||
|
{% if product.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да, в наличии</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет, закончился</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Статус:</th>
|
<th>Статус:</th>
|
||||||
<td>
|
<td>
|
||||||
{% if product.is_active %}
|
{% if product.is_active %}
|
||||||
<span class="badge bg-success">Активен</span>
|
<span class="badge bg-info">Активен</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Неактивен</span>
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -138,6 +138,26 @@
|
|||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Блок 2.5: Информация о наличии (только при редактировании) -->
|
||||||
|
{% if object %}
|
||||||
|
<div class="mb-4 p-3 bg-info-light rounded border border-info">
|
||||||
|
<h5 class="mb-3"><i class="bi bi-info-circle"></i> Информация о наличии</h5>
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>Статус:</strong>
|
||||||
|
{% if object.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет в наличии</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<small class="text-muted">Статус обновляется автоматически на основе остатков на складе</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
<!-- Блок 3: Фотографии -->
|
<!-- Блок 3: Фотографии -->
|
||||||
<div class="mb-4 p-3 bg-light rounded">
|
<div class="mb-4 p-3 bg-light rounded">
|
||||||
<h5 class="mb-3">Фотографии</h5>
|
<h5 class="mb-3">Фотографии</h5>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<th>Артикул</th>
|
<th>Артикул</th>
|
||||||
<th>Категория</th>
|
<th>Категория</th>
|
||||||
<th>Цена продажи</th>
|
<th>Цена продажи</th>
|
||||||
|
<th>В наличии</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -50,9 +51,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ product.sale_price }} руб.</td>
|
<td>{{ product.sale_price }} руб.</td>
|
||||||
|
<td>
|
||||||
|
{% if product.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if product.is_active %}
|
{% if product.is_active %}
|
||||||
<span class="badge bg-success">Активен</span>
|
<span class="badge bg-info">Активен</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Неактивен</span>
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -326,75 +326,93 @@
|
|||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Разделитель ИЛИ между полями товара и вариантов */
|
||||||
|
.kit-item-separator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-item-separator .separator-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #adb5bd;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-item-separator .separator-help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: help;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-item-separator .separator-help:hover {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap Tooltip стили */
|
||||||
|
.tooltip-inner {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 250px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-arrow::before {
|
||||||
|
border-top-color: #2c3e50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Адаптивность */
|
/* Адаптивность */
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.col-lg-8, .col-lg-4 {
|
.col-lg-8, .col-lg-4 {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Для мобильных устройств делаем разделитель более компактным */
|
||||||
|
.kit-item-separator {
|
||||||
|
min-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-item-separator .separator-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Инициализация Select2 для поиска товаров (переиспользуемый модуль) -->
|
||||||
|
{% include 'products/includes/select2-product-init.html' %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// ========== ИНИЦИАЛИЗАЦИЯ BOOTSTRAP TOOLTIPS ==========
|
||||||
|
// Инициализируем все tooltips на странице
|
||||||
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl, {
|
||||||
|
delay: { show: 200, hide: 100 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== НАСТРОЙКА SELECT2 С AJAX ПОИСКОМ ==========
|
// ========== НАСТРОЙКА SELECT2 С AJAX ПОИСКОМ ==========
|
||||||
|
// Используется функция из select2-product-init.html модуля
|
||||||
function initSelect2(element, type, preloadedData) {
|
function initSelect2(element, type, preloadedData) {
|
||||||
const config = {
|
// Инициализируем через переиспользуемый модуль
|
||||||
theme: 'bootstrap-5',
|
window.initProductSelect2(element, type, '{% url "products:api-search-products-variants" %}');
|
||||||
placeholder: type === 'product' ? 'Начните вводить название товара...' : 'Начните вводить название группы...',
|
|
||||||
allowClear: true,
|
|
||||||
language: 'ru',
|
|
||||||
minimumInputLength: 0,
|
|
||||||
ajax: {
|
|
||||||
url: '{% url "products:api-search-products-variants" %}',
|
|
||||||
dataType: 'json',
|
|
||||||
delay: 250,
|
|
||||||
data: function (params) {
|
|
||||||
return {
|
|
||||||
q: params.term || '',
|
|
||||||
type: type,
|
|
||||||
page: params.page || 1
|
|
||||||
};
|
|
||||||
},
|
|
||||||
processResults: function (data) {
|
|
||||||
return {
|
|
||||||
results: data.results,
|
|
||||||
pagination: {
|
|
||||||
more: data.pagination.more
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
cache: true
|
|
||||||
},
|
|
||||||
templateResult: formatSelectResult,
|
|
||||||
templateSelection: formatSelectSelection
|
|
||||||
};
|
|
||||||
|
|
||||||
// Если есть предзагруженные данные, создаем option с ними
|
// Если есть предзагруженные данные, добавляем их
|
||||||
if (preloadedData) {
|
if (preloadedData && !$(element).find('option[value="' + preloadedData.id + '"]').length) {
|
||||||
const option = new Option(preloadedData.text, preloadedData.id, true, true);
|
const option = new Option(preloadedData.text, preloadedData.id, true, true);
|
||||||
$(element).append(option);
|
$(element).append(option).trigger('change');
|
||||||
}
|
}
|
||||||
|
|
||||||
$(element).select2(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Форматирование результата в выпадающем списке
|
|
||||||
function formatSelectResult(item) {
|
|
||||||
if (item.loading) return item.text;
|
|
||||||
|
|
||||||
var $container = $('<div class="select2-result-item">');
|
|
||||||
$container.text(item.text);
|
|
||||||
|
|
||||||
if (item.price) {
|
|
||||||
$container.append($('<div class="text-muted small">').text(item.price + ' ₽'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $container;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Форматирование выбранного элемента
|
|
||||||
function formatSelectSelection(item) {
|
|
||||||
return item.text || item.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подготавливаем данные для предзагрузки (из контекста Django при ошибке валидации)
|
// Подготавливаем данные для предзагрузки (из контекста Django при ошибке валидации)
|
||||||
@@ -515,22 +533,40 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<div class="card mb-2 kititem-form border new-item" data-form-index="${newFormId}">
|
<div class="card mb-2 kititem-form border new-item" data-form-index="${newFormId}">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<div class="col-md-5">
|
<!-- ТОВАР -->
|
||||||
|
<div class="col-md-4">
|
||||||
<label class="form-label small text-muted mb-1">Товар</label>
|
<label class="form-label small text-muted mb-1">Товар</label>
|
||||||
<select class="form-control form-control-sm" name="kititem-${newFormId}-product" id="kititem-${newFormId}-product">
|
<select class="form-control form-control-sm" name="kititem-${newFormId}-product" id="kititem-${newFormId}-product">
|
||||||
<option value="">---------</option>
|
<option value="">---------</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- РАЗДЕЛИТЕЛЬ ИЛИ -->
|
||||||
|
<div class="col-md-1 d-flex justify-content-center align-items-center">
|
||||||
|
<div class="kit-item-separator">
|
||||||
|
<span class="separator-text">ИЛИ</span>
|
||||||
|
<i class="bi bi-info-circle separator-help"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="top"
|
||||||
|
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ГРУППА ВАРИАНТОВ -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
||||||
<select class="form-control form-control-sm" name="kititem-${newFormId}-variant_group" id="kititem-${newFormId}-variant_group">
|
<select class="form-control form-control-sm" name="kititem-${newFormId}-variant_group" id="kititem-${newFormId}-variant_group">
|
||||||
<option value="">---------</option>
|
<option value="">---------</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- КОЛИЧЕСТВО -->
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small text-muted mb-1">Кол-во</label>
|
<label class="form-label small text-muted mb-1">Кол-во</label>
|
||||||
<input type="number" class="form-control form-control-sm" name="kititem-${newFormId}-quantity" id="kititem-${newFormId}-quantity" value="1" step="0.001" min="0">
|
<input type="number" class="form-control form-control-sm" name="kititem-${newFormId}-quantity" id="kititem-${newFormId}-quantity" value="1" step="0.001" min="0">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- УДАЛЕНИЕ -->
|
||||||
<div class="col-md-1 text-end">
|
<div class="col-md-1 text-end">
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
|
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
@@ -557,6 +593,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Увеличиваем счетчик форм
|
// Увеличиваем счетчик форм
|
||||||
totalFormsInput.value = totalForms + 1;
|
totalFormsInput.value = totalForms + 1;
|
||||||
|
|
||||||
|
// Инициализируем tooltip для новой формы
|
||||||
|
const tooltipElement = newForm.querySelector('[data-bs-toggle="tooltip"]');
|
||||||
|
if (tooltipElement) {
|
||||||
|
new bootstrap.Tooltip(tooltipElement, {
|
||||||
|
delay: { show: 200, hide: 100 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Инициализируем Select2 для новых полей
|
// Инициализируем Select2 для новых полей
|
||||||
const productSelect = newForm.querySelector('[name$="-product"]');
|
const productSelect = newForm.querySelector('[name$="-product"]');
|
||||||
const variantSelect = newForm.querySelector('[name$="-variant_group"]');
|
const variantSelect = newForm.querySelector('[name$="-variant_group"]');
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if kit.is_active %}
|
{% if kit.is_active %}
|
||||||
<span class="badge bg-success">Активен</span>
|
<span class="badge bg-info">Активен</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Неактивен</span>
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Удалить группу вариантов{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container px-4 py-3" style="max-width: 700px;">
|
||||||
|
<div class="alert alert-danger alert-dismissible" role="alert">
|
||||||
|
<h5 class="alert-heading">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Удаление группы вариантов
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
Вы собираетесь удалить группу вариантов <strong>{{ object.name }}</strong>.
|
||||||
|
Эта операция <strong>необратима</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Информация о группе -->
|
||||||
|
<hr>
|
||||||
|
<div class="mb-0">
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>Статус:</strong>
|
||||||
|
{% if object.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет в наличии</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Цена:</strong>
|
||||||
|
{% if object.price %}
|
||||||
|
<span>{{ object.price }} руб.</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="card border-warning mb-3">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h6 class="card-title mb-2">
|
||||||
|
<i class="bi bi-info-circle me-1 text-warning"></i>
|
||||||
|
Товары в группе ({{ items_count }}):
|
||||||
|
</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for item in items %}
|
||||||
|
<li>{{ item.product.name }} (приоритет {{ item.priority }})</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="d-flex gap-2 mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Удалить безвозвратно
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'products:variantgroup-list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Отмена
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
114
myproject/products/templates/products/variantgroup_detail.html
Normal file
114
myproject/products/templates/products/variantgroup_detail.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{{ variant_group.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4 py-3" style="max-width: 1000px;">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products:variantgroup-list' %}">Группы вариантов</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ variant_group.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Информация -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title">{{ variant_group.name }}</h5>
|
||||||
|
<p class="card-text text-muted small">{{ variant_group.description }}</p>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Информация о наличии -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<small class="text-muted d-block mb-2"><strong>Статус:</strong></small>
|
||||||
|
<div>
|
||||||
|
{% if variant_group.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> В наличии</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет в наличии</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Цена варианта -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<small class="text-muted d-block mb-2"><strong>Цена:</strong></small>
|
||||||
|
<div>
|
||||||
|
{% if variant_group.price %}
|
||||||
|
<span class="h5 text-success">{{ variant_group.price }} руб.</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Создано: {{ variant_group.created_at|date:"d.m.Y H:i" }}<br>
|
||||||
|
Обновлено: {{ variant_group.updated_at|date:"d.m.Y H:i" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Товары -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title mb-3">Товары в группе ({{ items.count }})</h5>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">№</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th style="width: 120px;">Артикул</th>
|
||||||
|
<th style="width: 100px;">Цена</th>
|
||||||
|
<th style="width: 100px;">В наличии</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ item.priority }}</td>
|
||||||
|
<td>{{ item.product.name }}</td>
|
||||||
|
<td><small class="text-muted">{{ item.product.sku }}</small></td>
|
||||||
|
<td><strong>{{ item.product.sale_price }} ₽</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if item.product.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-4">Нет товаров в группе</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки действий -->
|
||||||
|
<div class="mt-4 d-flex gap-2 justify-content-start">
|
||||||
|
<a href="{% url 'products:variantgroup-update' variant_group.pk %}" class="btn btn-warning btn-sm">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Редактировать
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:variantgroup-delete' variant_group.pk %}" class="btn btn-danger btn-sm">
|
||||||
|
<i class="bi bi-trash me-1"></i>Удалить
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:variantgroup-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Назад
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
323
myproject/products/templates/products/variantgroup_form.html
Normal file
323
myproject/products/templates/products/variantgroup_form.html
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if is_create %}Создать группу вариантов{% else %}Редактировать группу вариантов{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4 py-3" style="max-width: 1200px;">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'products:variantgroup-list' %}">Группы вариантов</a></li>
|
||||||
|
<li class="breadcrumb-item active">{% if is_create %}Новая{% else %}Редактирование{% endif %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if form.non_field_errors or items_formset.non_form_errors %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
|
||||||
|
{% for error in items_formset.non_form_errors %}{{ error }}{% endfor %}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- ЛЕВАЯ КОЛОНКА: Основная информация -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title mb-3">Информация о группе</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.name.id_for_label }}" class="form-label">{{ form.name.label }}</label>
|
||||||
|
{{ form.name }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ 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.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ form.description.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ПРАВАЯ КОЛОНКА: Товары в группе -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<h5 class="card-title mb-3">Товары в группе (в порядке приоритета)</h5>
|
||||||
|
|
||||||
|
{{ items_formset.management_form }}
|
||||||
|
|
||||||
|
<div id="items-container" class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">№</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th style="width: 100px;">Артикул</th>
|
||||||
|
<th style="width: 90px;">Цена</th>
|
||||||
|
<th style="width: 100px;">В наличии</th>
|
||||||
|
<th style="width: 110px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="items-tbody">
|
||||||
|
{% for item_form in items_formset %}
|
||||||
|
<tr class="item-row" data-form-index="{{ forloop.counter0 }}"{% if item_form.DELETE.value %} style="display: none;"{% endif %}>
|
||||||
|
<td class="item-priority text-center fw-bold">{{ forloop.counter }}</td>
|
||||||
|
<td>
|
||||||
|
{{ item_form.product }}
|
||||||
|
{% if item_form.product.errors %}
|
||||||
|
<div class="text-danger small">{{ item_form.product.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted" data-product-sku="-">-</small></td>
|
||||||
|
<td><small class="text-muted" data-product-price="-">-</small></td>
|
||||||
|
<td><small class="text-muted" data-product-stock="-">-</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-up-btn me-1" title="Вверх">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-down-btn me-1" title="Вниз">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger delete-btn" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{{ item_form.priority }}
|
||||||
|
{{ item_form.id }}
|
||||||
|
{{ item_form.DELETE }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка для добавления товара -->
|
||||||
|
<div class="mt-3 pt-3 border-top">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success w-100" id="add-item-btn">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Добавить товар
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Footer -->
|
||||||
|
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
|
||||||
|
<a href="{% url 'products:variantgroup-list' %}" class="btn btn-outline-secondary">Отмена</a>
|
||||||
|
<button type="submit" class="btn btn-success px-4">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>{% if is_create %}Создать{% else %}Сохранить{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.item-row { position: relative; }
|
||||||
|
.item-row .btn-outline-secondary { opacity: 0.6; }
|
||||||
|
.item-row .btn-outline-secondary:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Скрыть поля priority и DELETE */
|
||||||
|
[name$="-priority"], [name$="-DELETE"], [name$="-id"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-bottom { position: sticky; bottom: 0; z-index: 100; }
|
||||||
|
.table { margin-bottom: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Инициализация Select2 для поиска товаров -->
|
||||||
|
{% include 'products/includes/select2-product-init.html' %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const container = document.getElementById('items-tbody');
|
||||||
|
const totalFormsInput = document.querySelector('[name$="TOTAL_FORMS"]');
|
||||||
|
const apiUrl = '{% url "products:api-search-products-variants" %}';
|
||||||
|
|
||||||
|
// Инициализируем Select2 для всех селектов товаров
|
||||||
|
function initSelect2ForRow(row) {
|
||||||
|
const productSelect = row.querySelector('[name$="-product"]');
|
||||||
|
if (productSelect) {
|
||||||
|
window.initProductSelect2(productSelect, 'product', apiUrl);
|
||||||
|
|
||||||
|
// Обработчик события при выборе товара
|
||||||
|
productSelect.addEventListener('select2:select', function(e) {
|
||||||
|
updateRowData(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обновления данных строки при выборе товара
|
||||||
|
function updateRowData(row) {
|
||||||
|
const productSelect = row.querySelector('[name$="-product"]');
|
||||||
|
if (!productSelect || !productSelect.value) {
|
||||||
|
row.querySelector('[data-product-sku]').textContent = '-';
|
||||||
|
row.querySelector('[data-product-price]').textContent = '-';
|
||||||
|
row.querySelector('[data-product-stock]').innerHTML = '-';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим selected option для получения данных
|
||||||
|
const selectedOption = productSelect.options[productSelect.selectedIndex];
|
||||||
|
const text = selectedOption.text;
|
||||||
|
|
||||||
|
// Пытаемся извлечь SKU из текста опции (формат: "Name (SKU)")
|
||||||
|
const skuMatch = text.match(/\(([^)]+)\)$/);
|
||||||
|
const sku = skuMatch ? skuMatch[1] : '-';
|
||||||
|
|
||||||
|
// Получаем цену и статус через AJAX
|
||||||
|
fetch(`{% url "products:api-search-products-variants" %}?id=${productSelect.value}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
const product = data.results[0];
|
||||||
|
row.querySelector('[data-product-sku]').textContent = product.sku || sku;
|
||||||
|
row.querySelector('[data-product-price]').innerHTML = `<strong>${product.price}</strong> ₽` || '-';
|
||||||
|
|
||||||
|
// Отображаем статус наличия
|
||||||
|
const stockCell = row.querySelector('[data-product-stock]');
|
||||||
|
if (product.in_stock) {
|
||||||
|
stockCell.innerHTML = '<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>';
|
||||||
|
} else {
|
||||||
|
stockCell.innerHTML = '<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
row.querySelector('[data-product-sku]').textContent = sku;
|
||||||
|
row.querySelector('[data-product-price]').textContent = '-';
|
||||||
|
row.querySelector('[data-product-stock]').textContent = '-';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем для существующих строк
|
||||||
|
container.querySelectorAll('.item-row').forEach(initSelect2ForRow);
|
||||||
|
|
||||||
|
// ДОБАВЛЕНИЕ НОВОГО ТОВАРА
|
||||||
|
document.getElementById('add-item-btn').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const totalForms = totalFormsInput.value;
|
||||||
|
const newFormIndex = parseInt(totalForms);
|
||||||
|
|
||||||
|
// Создаем новую строку
|
||||||
|
const newRow = document.createElement('tr');
|
||||||
|
newRow.className = 'item-row';
|
||||||
|
newRow.innerHTML = `
|
||||||
|
<td class="item-priority text-center fw-bold">${newFormIndex + 1}</td>
|
||||||
|
<td>
|
||||||
|
<select name="items-${newFormIndex}-product" id="items-${newFormIndex}-product" class="form-control form-control-sm">
|
||||||
|
<option value="">---------</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted" data-product-sku="-">-</small></td>
|
||||||
|
<td><small class="text-muted" data-product-price="-">-</small></td>
|
||||||
|
<td><small class="text-muted" data-product-stock="-">-</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-up-btn me-1" title="Вверх">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-down-btn me-1" title="Вниз">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger delete-btn" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="items-${newFormIndex}-priority" value="${newFormIndex + 1}">
|
||||||
|
<input type="hidden" name="items-${newFormIndex}-id" value="">
|
||||||
|
<input type="checkbox" name="items-${newFormIndex}-DELETE">
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(newRow);
|
||||||
|
|
||||||
|
// Увеличиваем счетчик форм
|
||||||
|
totalFormsInput.value = newFormIndex + 1;
|
||||||
|
|
||||||
|
// Инициализируем Select2 для нового селекта
|
||||||
|
initSelect2ForRow(newRow);
|
||||||
|
|
||||||
|
// Обновляем приоритеты
|
||||||
|
updatePriorities();
|
||||||
|
|
||||||
|
// Скролим к новой форме
|
||||||
|
newRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// UP/DOWN функции для локального перемещения
|
||||||
|
function moveItemUp(btn) {
|
||||||
|
const row = btn.closest('.item-row');
|
||||||
|
const prevRow = row.previousElementSibling;
|
||||||
|
if (prevRow && prevRow.classList.contains('item-row')) {
|
||||||
|
row.parentNode.insertBefore(row, prevRow);
|
||||||
|
updatePriorities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveItemDown(btn) {
|
||||||
|
const row = btn.closest('.item-row');
|
||||||
|
const nextRow = row.nextElementSibling;
|
||||||
|
if (nextRow && nextRow.classList.contains('item-row')) {
|
||||||
|
row.parentNode.insertBefore(nextRow, row);
|
||||||
|
updatePriorities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItem(btn) {
|
||||||
|
const row = btn.closest('.item-row');
|
||||||
|
const deleteCheckbox = row.querySelector('[name$="-DELETE"]');
|
||||||
|
if (deleteCheckbox) {
|
||||||
|
deleteCheckbox.checked = true;
|
||||||
|
row.style.display = 'none';
|
||||||
|
updatePriorities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePriorities() {
|
||||||
|
let priority = 1;
|
||||||
|
container.querySelectorAll('.item-row:not([style*="display: none"])').forEach(row => {
|
||||||
|
const priorityCell = row.querySelector('.item-priority');
|
||||||
|
const priorityInput = row.querySelector('[name$="-priority"]');
|
||||||
|
if (priorityCell) priorityCell.textContent = priority;
|
||||||
|
if (priorityInput) priorityInput.value = priority;
|
||||||
|
priority++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики событий
|
||||||
|
container.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.move-up-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
moveItemUp(e.target.closest('.move-up-btn'));
|
||||||
|
}
|
||||||
|
if (e.target.closest('.move-down-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
moveItemDown(e.target.closest('.move-down-btn'));
|
||||||
|
}
|
||||||
|
if (e.target.closest('.delete-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteItem(e.target.closest('.delete-btn'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация при загрузке
|
||||||
|
updatePriorities();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
137
myproject/products/templates/products/variantgroup_list.html
Normal file
137
myproject/products/templates/products/variantgroup_list.html
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Группы вариантов{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4 py-3">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb breadcrumb-sm mb-0">
|
||||||
|
<li class="breadcrumb-item active">Группы вариантов</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Кнопка создания -->
|
||||||
|
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="mb-0">Группы вариантов</h4>
|
||||||
|
<a href="{% url 'products:variantgroup-create' %}" class="btn btn-success btn-sm">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Создать группу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поиск -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<form method="get" class="row g-2">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input type="text" name="search" class="form-control form-control-sm"
|
||||||
|
placeholder="Поиск по названию..." value="{{ search_query }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-search"></i> Поиск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th style="width: 100px;">Товаров</th>
|
||||||
|
<th style="width: 100px;">В наличии</th>
|
||||||
|
<th style="width: 100px;">Цена</th>
|
||||||
|
<th style="width: 150px;">Дата обновления</th>
|
||||||
|
<th style="width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in variant_groups %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ group.name }}</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ group.description|truncatewords:10 }}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-info">{{ group.items_count }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if group.in_stock %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if group.price %}
|
||||||
|
<strong>{{ group.price }}</strong> руб.
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">{{ group.updated_at|date:"d.m.Y H:i" }}</small></td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:variantgroup-detail' group.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary" title="Просмотр">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:variantgroup-update' group.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-warning" title="Редактировать">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:variantgroup-delete' group.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted py-4">
|
||||||
|
Нет групп вариантов. <a href="{% url 'products:variantgroup-create' %}">Создать первую</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Пагинация -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav class="mt-4" aria-label="Pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% if search_query %}&search={{ search_query }}{% endif %}">Первая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">Предыдущая</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}">Следующая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&search={{ search_query }}{% endif %}">Последняя</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
292
myproject/products/views/variant_group_views.py
Normal file
292
myproject/products/views/variant_group_views.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
CRUD представления для групп вариантов товаров (ProductVariantGroup).
|
||||||
|
Включает управление товарами в группе с приоритизацией.
|
||||||
|
"""
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||||
|
from django.urls import reverse_lazy, reverse
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from django.db import transaction
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from ..models import ProductVariantGroup, ProductVariantGroupItem
|
||||||
|
from ..forms import (
|
||||||
|
ProductVariantGroupForm,
|
||||||
|
ProductVariantGroupItemFormSetCreate,
|
||||||
|
ProductVariantGroupItemFormSetUpdate
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupListView(LoginRequiredMixin, ListView):
|
||||||
|
"""Список всех групп вариантов с поиском и фильтрацией"""
|
||||||
|
model = ProductVariantGroup
|
||||||
|
template_name = 'products/variantgroup_list.html'
|
||||||
|
context_object_name = 'variant_groups'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Оптимизация: загружаем товары для вычисления in_stock и price
|
||||||
|
queryset = queryset.prefetch_related('items__product')
|
||||||
|
|
||||||
|
# Добавляем количество товаров в каждую группу
|
||||||
|
queryset = queryset.annotate(items_count=Count('items'))
|
||||||
|
|
||||||
|
# Поиск по названию и описанию
|
||||||
|
search_query = self.request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(description__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.order_by('-updated_at')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['search_query'] = self.request.GET.get('search', '')
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
"""Создание новой группы вариантов с добавлением товаров"""
|
||||||
|
model = ProductVariantGroup
|
||||||
|
form_class = ProductVariantGroupForm
|
||||||
|
template_name = 'products/variantgroup_form.html'
|
||||||
|
success_url = reverse_lazy('products:variantgroup-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.POST:
|
||||||
|
context['items_formset'] = ProductVariantGroupItemFormSetCreate(
|
||||||
|
self.request.POST,
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context['items_formset'] = ProductVariantGroupItemFormSetCreate(
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
context['is_create'] = True
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Сохраняем группу и товары в одной транзакции"""
|
||||||
|
items_formset = ProductVariantGroupItemFormSetCreate(
|
||||||
|
self.request.POST,
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем валидность основной формы и формсета
|
||||||
|
if not form.is_valid() or not items_formset.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Сохраняем группу
|
||||||
|
self.object = form.save(commit=True)
|
||||||
|
|
||||||
|
# Сохраняем товары
|
||||||
|
items_formset.instance = self.object
|
||||||
|
items_formset.save()
|
||||||
|
|
||||||
|
# Пересчитываем приоритеты
|
||||||
|
self._recalculate_priorities(self.object)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
f'Группа вариантов "{self.object.name}" успешно создана!'
|
||||||
|
)
|
||||||
|
return redirect('products:variantgroup-list')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recalculate_priorities(variant_group):
|
||||||
|
"""Пересчитывает приоритеты товаров в группе (по порядку в БД)"""
|
||||||
|
items = variant_group.items.all().order_by('id')
|
||||||
|
for idx, item in enumerate(items, start=1):
|
||||||
|
item.priority = idx
|
||||||
|
item.save(update_fields=['priority'])
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
"""Просмотр группы вариантов с список товаров в ней"""
|
||||||
|
model = ProductVariantGroup
|
||||||
|
template_name = 'products/variantgroup_detail.html'
|
||||||
|
context_object_name = 'variant_group'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
return queryset.prefetch_related('items__product')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Получаем товары с приоритетами
|
||||||
|
context['items'] = self.object.items.all().select_related('product').order_by('priority')
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""Редактирование группы вариантов и управление товарами в ней"""
|
||||||
|
model = ProductVariantGroup
|
||||||
|
form_class = ProductVariantGroupForm
|
||||||
|
template_name = 'products/variantgroup_form.html'
|
||||||
|
success_url = reverse_lazy('products:variantgroup-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.POST:
|
||||||
|
context['items_formset'] = ProductVariantGroupItemFormSetUpdate(
|
||||||
|
self.request.POST,
|
||||||
|
instance=self.object,
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context['items_formset'] = ProductVariantGroupItemFormSetUpdate(
|
||||||
|
instance=self.object,
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
context['is_create'] = False
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Сохраняем изменения группы и товаров"""
|
||||||
|
items_formset = ProductVariantGroupItemFormSetUpdate(
|
||||||
|
self.request.POST,
|
||||||
|
instance=self.object,
|
||||||
|
prefix='items'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not form.is_valid() or not items_formset.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
self.object = form.save(commit=True)
|
||||||
|
items_formset.instance = self.object
|
||||||
|
items_formset.save()
|
||||||
|
|
||||||
|
# Пересчитываем приоритеты после редактирования
|
||||||
|
self._recalculate_priorities(self.object)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
f'Группа вариантов "{self.object.name}" успешно обновлена!'
|
||||||
|
)
|
||||||
|
return redirect('products:variantgroup-list')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recalculate_priorities(variant_group):
|
||||||
|
"""Пересчитывает приоритеты товаров в группе"""
|
||||||
|
items = variant_group.items.all().order_by('id')
|
||||||
|
for idx, item in enumerate(items, start=1):
|
||||||
|
item.priority = idx
|
||||||
|
item.save(update_fields=['priority'])
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVariantGroupDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
"""Удаление группы вариантов с подтверждением"""
|
||||||
|
model = ProductVariantGroup
|
||||||
|
template_name = 'products/variantgroup_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('products:variantgroup-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Добавляем информацию о товарах в группе
|
||||||
|
context['items'] = self.object.items.all().select_related('product').order_by('priority')
|
||||||
|
context['items_count'] = context['items'].count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""Удаляем группу и показываем сообщение об успехе"""
|
||||||
|
group_name = self.get_object().name
|
||||||
|
response = super().delete(request, *args, **kwargs)
|
||||||
|
messages.success(request, f'Группа вариантов "{group_name}" успешно удалена!')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def product_variant_group_item_move(request, item_id, direction):
|
||||||
|
"""
|
||||||
|
AJAX view для перемещения товара вверх/вниз в группе (UP/DOWN кнопки).
|
||||||
|
Перемещает товар вверх (up) или вниз (down) в списке приоритетов.
|
||||||
|
Автоматически пересчитывает приоритеты.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем товар в группе
|
||||||
|
item = get_object_or_404(ProductVariantGroupItem, pk=item_id)
|
||||||
|
variant_group = item.variant_group
|
||||||
|
|
||||||
|
# Получаем все товары в группе отсортированные по приоритету
|
||||||
|
all_items = list(
|
||||||
|
variant_group.items.all()
|
||||||
|
.order_by('priority')
|
||||||
|
.values_list('id', 'priority')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Находим текущую позицию товара
|
||||||
|
current_index = next(
|
||||||
|
(idx for idx, (item_pk, _) in enumerate(all_items) if item_pk == item_id),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_index is None:
|
||||||
|
return JsonResponse({'error': 'Товар не найден'}, status=404)
|
||||||
|
|
||||||
|
# Перемещаем товар
|
||||||
|
if direction == 'up' and current_index > 0:
|
||||||
|
# Меняем местами с товаром выше
|
||||||
|
all_items[current_index], all_items[current_index - 1] = \
|
||||||
|
all_items[current_index - 1], all_items[current_index]
|
||||||
|
elif direction == 'down' and current_index < len(all_items) - 1:
|
||||||
|
# Меняем местами с товаром ниже
|
||||||
|
all_items[current_index], all_items[current_index + 1] = \
|
||||||
|
all_items[current_index + 1], all_items[current_index]
|
||||||
|
else:
|
||||||
|
# Товар уже в крайней позиции
|
||||||
|
return JsonResponse({
|
||||||
|
'error': f'Товар уже в крайней позиции',
|
||||||
|
'items': _get_items_data(variant_group)
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Пересчитываем приоритеты
|
||||||
|
with transaction.atomic():
|
||||||
|
for idx, (item_pk, _) in enumerate(all_items, start=1):
|
||||||
|
ProductVariantGroupItem.objects.filter(pk=item_pk).update(priority=idx)
|
||||||
|
|
||||||
|
# Возвращаем обновленный список товаров
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Товар перемещен',
|
||||||
|
'items': _get_items_data(variant_group)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_items_data(variant_group):
|
||||||
|
"""Возвращает данные о товарах для обновления таблицы"""
|
||||||
|
items = variant_group.items.all().select_related('product').order_by('priority')
|
||||||
|
items_data = []
|
||||||
|
for item in items:
|
||||||
|
items_data.append({
|
||||||
|
'id': item.id,
|
||||||
|
'product_name': item.product.name,
|
||||||
|
'product_sku': item.product.sku,
|
||||||
|
'product_price': str(item.product.sale_price),
|
||||||
|
'priority': item.priority,
|
||||||
|
'can_move_up': item.priority > 1,
|
||||||
|
'can_move_down': item.priority < items.count()
|
||||||
|
})
|
||||||
|
return items_data
|
||||||
Reference in New Issue
Block a user