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 токена
|
||||
function getCsrfToken() {
|
||||
const cookieValue = document.cookie
|
||||
|
||||
@@ -140,6 +140,56 @@
|
||||
opacity: 1 !important;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -217,7 +267,52 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
</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/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/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
|
||||
|
||||
# Photo processing status API (for AJAX polling)
|
||||
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,
|
||||
'error': f'Ошибка при создании категории: {str(e)}'
|
||||
}, 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