Feat: Add inline price editing for products in catalog
Implemented inline editing functionality for product prices directly in the catalog view with support for both regular and sale prices. Features: - Click-to-edit price fields with visual hover indicators - Separate editing for price and sale_price fields - Add/remove sale price with validation - Real-time UI updates without page reload - Permission-based access control - Server-side validation for price constraints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -562,6 +562,238 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Inline-редактирование цен (только для Product, не для ProductKit)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Обработчик клика на цене (редактирование)
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const priceSpan = e.target.closest('.editable-price');
|
||||||
|
if (!priceSpan) return;
|
||||||
|
|
||||||
|
// Уже редактируется?
|
||||||
|
if (priceSpan.querySelector('input')) return;
|
||||||
|
|
||||||
|
const productId = priceSpan.dataset.productId;
|
||||||
|
const field = priceSpan.dataset.field; // 'price' или 'sale_price'
|
||||||
|
const currentValue = priceSpan.dataset.currentValue;
|
||||||
|
|
||||||
|
// Создаем input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.step = '0.01';
|
||||||
|
input.min = '0';
|
||||||
|
input.value = currentValue;
|
||||||
|
input.className = 'price-edit-input';
|
||||||
|
|
||||||
|
// Сохраняем оригинальный HTML
|
||||||
|
const originalHTML = priceSpan.innerHTML;
|
||||||
|
priceSpan.innerHTML = '';
|
||||||
|
priceSpan.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
// Функция сохранения
|
||||||
|
const savePrice = async () => {
|
||||||
|
const newValue = input.value.trim();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!newValue || parseFloat(newValue) < 0) {
|
||||||
|
// Отмена - пустое или отрицательное значение
|
||||||
|
priceSpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, изменилось ли значение
|
||||||
|
if (parseFloat(newValue) === parseFloat(currentValue)) {
|
||||||
|
// Значение не изменилось
|
||||||
|
priceSpan.innerHTML = originalHTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку
|
||||||
|
input.disabled = true;
|
||||||
|
input.style.opacity = '0.5';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/products/api/products/${productId}/update-price/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
field: field,
|
||||||
|
value: newValue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Обновляем весь контейнер с ценами
|
||||||
|
updatePriceDisplay(productId, data.price, data.sale_price);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка при обновлении цены');
|
||||||
|
priceSpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка сети');
|
||||||
|
priceSpan.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция отмены
|
||||||
|
const cancelEdit = () => {
|
||||||
|
priceSpan.innerHTML = originalHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enter - сохранить
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
savePrice();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Потеря фокуса - сохранить
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(savePrice, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавление скидочной цены
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const addBtn = e.target.closest('.add-sale-price');
|
||||||
|
if (!addBtn) return;
|
||||||
|
|
||||||
|
const productId = addBtn.dataset.productId;
|
||||||
|
const priceContainer = addBtn.closest('.price-edit-container');
|
||||||
|
const regularPriceSpan = priceContainer.querySelector('.editable-price[data-field="price"]');
|
||||||
|
const currentPrice = parseFloat(regularPriceSpan.dataset.currentValue);
|
||||||
|
|
||||||
|
// Запрашиваем скидочную цену
|
||||||
|
const salePriceInput = prompt('Введите скидочную цену (меньше ' + currentPrice.toFixed(2) + ' руб.):', '');
|
||||||
|
|
||||||
|
if (!salePriceInput) return; // Отменено
|
||||||
|
|
||||||
|
const salePrice = parseFloat(salePriceInput);
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (isNaN(salePrice) || salePrice <= 0) {
|
||||||
|
alert('Некорректная цена');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (salePrice >= currentPrice) {
|
||||||
|
alert('Скидочная цена должна быть меньше обычной цены (' + currentPrice.toFixed(2) + ' руб.)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
updatePriceViaAPI(productId, 'sale_price', salePrice.toFixed(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удаление скидочной цены
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const removeBtn = e.target.closest('.remove-sale-price');
|
||||||
|
if (!removeBtn) return;
|
||||||
|
|
||||||
|
const productId = removeBtn.dataset.productId;
|
||||||
|
|
||||||
|
if (!confirm('Убрать скидочную цену?')) return;
|
||||||
|
|
||||||
|
// Отправляем null для удаления sale_price
|
||||||
|
updatePriceViaAPI(productId, 'sale_price', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вспомогательная функция для обновления цены через API
|
||||||
|
async function updatePriceViaAPI(productId, field, value) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/products/api/products/${productId}/update-price/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
field: field,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Обновляем отображение
|
||||||
|
updatePriceDisplay(productId, data.price, data.sale_price);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка при обновлении цены');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка сети');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция обновления отображения цен
|
||||||
|
function updatePriceDisplay(productId, price, salePrice) {
|
||||||
|
// Находим контейнер с ценами для этого товара
|
||||||
|
const catalogItem = document.querySelector(`.catalog-item .editable-price[data-product-id="${productId}"]`)?.closest('.price-edit-container');
|
||||||
|
|
||||||
|
if (!catalogItem) return;
|
||||||
|
|
||||||
|
// Формируем новый HTML
|
||||||
|
let newHTML = '';
|
||||||
|
|
||||||
|
if (salePrice) {
|
||||||
|
// Есть скидочная цена
|
||||||
|
newHTML = `
|
||||||
|
<span class="editable-price sale-price fw-bold text-success small"
|
||||||
|
data-product-id="${productId}"
|
||||||
|
data-field="sale_price"
|
||||||
|
data-current-value="${salePrice}"
|
||||||
|
title="Скидочная цена (клик для редактирования)">
|
||||||
|
${parseFloat(salePrice).toFixed(2)} руб.
|
||||||
|
</span>
|
||||||
|
<span class="editable-price regular-price text-muted text-decoration-line-through small"
|
||||||
|
data-product-id="${productId}"
|
||||||
|
data-field="price"
|
||||||
|
data-current-value="${price}"
|
||||||
|
title="Обычная цена (клик для редактирования)">
|
||||||
|
${parseFloat(price).toFixed(2)} руб.
|
||||||
|
</span>
|
||||||
|
<i class="bi bi-x-circle text-danger remove-sale-price"
|
||||||
|
data-product-id="${productId}"
|
||||||
|
title="Убрать скидку"
|
||||||
|
style="cursor: pointer; font-size: 0.85rem;"></i>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Только обычная цена
|
||||||
|
newHTML = `
|
||||||
|
<span class="editable-price fw-bold text-primary small"
|
||||||
|
data-product-id="${productId}"
|
||||||
|
data-field="price"
|
||||||
|
data-current-value="${price}"
|
||||||
|
title="Цена (клик для редактирования)">
|
||||||
|
${parseFloat(price).toFixed(2)} руб.
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm add-sale-price py-0 px-1"
|
||||||
|
data-product-id="${productId}"
|
||||||
|
title="Добавить скидочную цену"
|
||||||
|
style="font-size: 0.7rem; line-height: 1.2;">
|
||||||
|
+ скидка
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogItem.innerHTML = newHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// Получение CSRF токена
|
// Получение CSRF токена
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
const cookieValue = document.cookie
|
const cookieValue = document.cookie
|
||||||
|
|||||||
@@ -140,6 +140,56 @@
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
color: #198754;
|
color: #198754;
|
||||||
}
|
}
|
||||||
|
/* Редактируемые цены */
|
||||||
|
.editable-price {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.editable-price:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px dashed #dee2e6;
|
||||||
|
}
|
||||||
|
.editable-price.sale-price:hover {
|
||||||
|
background-color: #d1f4e0;
|
||||||
|
border-color: #28a745;
|
||||||
|
}
|
||||||
|
.editable-price.regular-price:hover {
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
.price-edit-input {
|
||||||
|
width: 90px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 2px solid #0d6efd;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.price-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0a58ca;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||||
|
}
|
||||||
|
.remove-sale-price {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.price-edit-container:hover .remove-sale-price {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.remove-sale-price:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
.add-sale-price {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.price-edit-container:hover .add-sale-price {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -217,7 +267,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||||
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:0 }} руб.</span>
|
{% if item.item_type == 'product' %}
|
||||||
|
<div class="price-edit-container d-flex align-items-center gap-1 flex-wrap">
|
||||||
|
{% if item.sale_price %}
|
||||||
|
{# Скидочная цена #}
|
||||||
|
<span class="editable-price sale-price fw-bold text-success small"
|
||||||
|
data-product-id="{{ item.pk }}"
|
||||||
|
data-field="sale_price"
|
||||||
|
data-current-value="{{ item.sale_price }}"
|
||||||
|
title="Скидочная цена (клик для редактирования)">
|
||||||
|
{{ item.sale_price|floatformat:2 }} руб.
|
||||||
|
</span>
|
||||||
|
{# Обычная цена зачеркнутая #}
|
||||||
|
<span class="editable-price regular-price text-muted text-decoration-line-through small"
|
||||||
|
data-product-id="{{ item.pk }}"
|
||||||
|
data-field="price"
|
||||||
|
data-current-value="{{ item.price }}"
|
||||||
|
title="Обычная цена (клик для редактирования)">
|
||||||
|
{{ item.price|floatformat:2 }} руб.
|
||||||
|
</span>
|
||||||
|
{# Кнопка удаления скидки #}
|
||||||
|
<i class="bi bi-x-circle text-danger remove-sale-price"
|
||||||
|
data-product-id="{{ item.pk }}"
|
||||||
|
title="Убрать скидку"
|
||||||
|
style="cursor: pointer; font-size: 0.85rem;"></i>
|
||||||
|
{% else %}
|
||||||
|
{# Только обычная цена #}
|
||||||
|
<span class="editable-price fw-bold text-primary small"
|
||||||
|
data-product-id="{{ item.pk }}"
|
||||||
|
data-field="price"
|
||||||
|
data-current-value="{{ item.price }}"
|
||||||
|
title="Цена (клик для редактирования)">
|
||||||
|
{{ item.price|floatformat:2 }} руб.
|
||||||
|
</span>
|
||||||
|
{# Кнопка добавления скидки #}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm add-sale-price py-0 px-1"
|
||||||
|
data-product-id="{{ item.pk }}"
|
||||||
|
title="Добавить скидочную цену"
|
||||||
|
style="font-size: 0.7rem; line-height: 1.2;">
|
||||||
|
+ скидка
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{# ProductKit - не редактируется #}
|
||||||
|
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:2 }} руб.</span>
|
||||||
|
{% endif %}
|
||||||
<small class="text-muted">{{ item.sku }}</small>
|
<small class="text-muted">{{ item.sku }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ urlpatterns = [
|
|||||||
path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'),
|
path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'),
|
||||||
path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
|
path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
|
||||||
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
||||||
|
path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
|
||||||
|
|
||||||
# Photo processing status API (for AJAX polling)
|
# Photo processing status API (for AJAX polling)
|
||||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||||
|
|||||||
@@ -1081,3 +1081,139 @@ def create_category_api(request):
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': f'Ошибка при создании категории: {str(e)}'
|
'error': f'Ошибка при создании категории: {str(e)}'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def update_product_price_api(request, pk):
|
||||||
|
"""
|
||||||
|
AJAX endpoint для изменения цены товара (inline editing в каталоге).
|
||||||
|
|
||||||
|
Принимает JSON:
|
||||||
|
{
|
||||||
|
"field": "price" | "sale_price",
|
||||||
|
"value": "150.50" | null
|
||||||
|
}
|
||||||
|
|
||||||
|
Возвращает JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"price": "199.00",
|
||||||
|
"sale_price": "150.00" | null,
|
||||||
|
"actual_price": "150.00"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Метод не поддерживается'
|
||||||
|
}, status=405)
|
||||||
|
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm('products.change_product'):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'У вас нет прав для изменения цен товаров'
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
data = json.loads(request.body)
|
||||||
|
field = data.get('field')
|
||||||
|
value = data.get('value')
|
||||||
|
|
||||||
|
# Валидация поля
|
||||||
|
if field not in ['price', 'sale_price']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Недопустимое поле. Разрешены: price, sale_price'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Получаем товар
|
||||||
|
product = Product.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Обработка значения
|
||||||
|
if value is None:
|
||||||
|
# Очистка sale_price
|
||||||
|
if field == 'sale_price':
|
||||||
|
product.sale_price = None
|
||||||
|
else:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Основная цена не может быть пустой'
|
||||||
|
}, status=400)
|
||||||
|
else:
|
||||||
|
# Валидация значения
|
||||||
|
try:
|
||||||
|
decimal_value = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Некорректное числовое значение'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Проверка диапазона
|
||||||
|
if decimal_value <= 0:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Цена должна быть положительной'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if decimal_value > Decimal('999999.99'):
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Цена слишком большая (максимум 999999.99)'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Проверка десятичных знаков
|
||||||
|
if decimal_value.as_tuple().exponent < -2:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Максимум 2 знака после запятой'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Устанавливаем значение
|
||||||
|
if field == 'price':
|
||||||
|
product.price = decimal_value
|
||||||
|
# Проверка: sale_price должна быть меньше price
|
||||||
|
if product.sale_price and product.sale_price >= decimal_value:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Скидочная цена должна быть меньше обычной цены'
|
||||||
|
}, status=400)
|
||||||
|
else: # sale_price
|
||||||
|
# Проверка: sale_price должна быть меньше price
|
||||||
|
if decimal_value >= product.price:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Скидочная цена должна быть меньше обычной цены'
|
||||||
|
}, status=400)
|
||||||
|
product.sale_price = decimal_value
|
||||||
|
|
||||||
|
# Сохраняем
|
||||||
|
product.save(update_fields=[field])
|
||||||
|
|
||||||
|
# Возвращаем обновлённые данные
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'price': str(product.price),
|
||||||
|
'sale_price': str(product.sale_price) if product.sale_price else None,
|
||||||
|
'actual_price': str(product.actual_price)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Товар не найден'
|
||||||
|
}, status=404)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Некорректный JSON'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Ошибка при обновлении цены товара: {str(e)}')
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Ошибка при обновлении цены: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|||||||
Reference in New Issue
Block a user