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:
2025-10-29 23:33:11 +03:00
parent 2341cf57c1
commit 9ff1f2d184
11 changed files with 1084 additions and 59 deletions

View File

@@ -95,6 +95,7 @@
<th>Артикул</th>
<th>Категория</th>
<th>Цена продажи</th>
<th>В наличии</th>
<th>Статус</th>
<th style="width: 200px;">Действия</th>
</tr>
@@ -146,9 +147,21 @@
{{ item.get_sale_price|floatformat:2 }} руб.
{% endif %}
</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>
{% if item.is_active %}
<span class="badge bg-success">Активен</span>
<span class="badge bg-info">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}

View File

@@ -161,11 +161,21 @@
<th>Цена продажи:</th>
<td>{{ product.sale_price }} руб.</td>
</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>
<th>Статус:</th>
<td>
{% if product.is_active %}
<span class="badge bg-success">Активен</span>
<span class="badge bg-info">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}

View File

@@ -138,6 +138,26 @@
<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: Фотографии -->
<div class="mb-4 p-3 bg-light rounded">
<h5 class="mb-3">Фотографии</h5>

View File

@@ -19,6 +19,7 @@
<th>Артикул</th>
<th>Категория</th>
<th>Цена продажи</th>
<th>В наличии</th>
<th>Статус</th>
<th>Действия</th>
</tr>
@@ -50,9 +51,16 @@
{% endif %}
</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>
{% if product.is_active %}
<span class="badge bg-success">Активен</span>
<span class="badge bg-info">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}

View File

@@ -326,75 +326,93 @@
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) {
.col-lg-8, .col-lg-4 {
max-width: 100%;
}
/* Для мобильных устройств делаем разделитель более компактным */
.kit-item-separator {
min-height: 35px;
}
.kit-item-separator .separator-text {
font-size: 0.7rem;
letter-spacing: 0.5px;
}
}
</style>
<!-- Инициализация Select2 для поиска товаров (переиспользуемый модуль) -->
{% include 'products/includes/select2-product-init.html' %}
<script>
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-product-init.html модуля
function initSelect2(element, type, preloadedData) {
const config = {
theme: 'bootstrap-5',
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
};
// Инициализируем через переиспользуемый модуль
window.initProductSelect2(element, type, '{% url "products:api-search-products-variants" %}');
// Если есть предзагруженные данные, создаем option с ними
if (preloadedData) {
// Если есть предзагруженные данные, добавляем их
if (preloadedData && !$(element).find('option[value="' + preloadedData.id + '"]').length) {
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 при ошибке валидации)
@@ -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-body p-2">
<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>
<select class="form-control form-control-sm" name="kititem-${newFormId}-product" id="kititem-${newFormId}-product">
<option value="">---------</option>
</select>
</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">
<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">
<option value="">---------</option>
</select>
</div>
<!-- КОЛИЧЕСТВО -->
<div class="col-md-2">
<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">
</div>
<!-- УДАЛЕНИЕ -->
<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="Удалить">
<i class="bi bi-x-lg"></i>
@@ -557,6 +593,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Увеличиваем счетчик форм
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 для новых полей
const productSelect = newForm.querySelector('[name$="-product"]');
const variantSelect = newForm.querySelector('[name$="-variant_group"]');

View File

@@ -61,7 +61,7 @@
</td>
<td>
{% if kit.is_active %}
<span class="badge bg-success">Активен</span>
<span class="badge bg-info">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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