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
|
||||
|
||||
Reference in New Issue
Block a user