feat: добавлено редактирование витринных комплектов и изолированное состояние tempCart

- Добавлены API endpoints для получения и обновления витринных комплектов
  - GET /pos/api/product-kits/<id>/ - получение деталей комплекта
  - POST /pos/api/product-kits/<id>/update/ - обновление комплекта
- Реализовано редактирование комплектов из POS интерфейса
  - Кнопка редактирования (карандаш) на карточках витринных букетов
  - Модальное окно предзаполняется данными комплекта
  - Поддержка изменения состава, цен, описания и фото
  - Умное управление резервами при изменении состава
- Введено изолированное состояние tempCart для модального окна
  - Основная корзина (cart) больше не затрагивается при редактировании
  - tempCart используется для создания и редактирования комплектов
  - Автоочистка tempCart при закрытии модального окна
- Устранён побочный эффект загрузки состава комплекта в основную корзину
This commit is contained in:
2025-11-16 23:41:27 +03:00
parent 9dff9cc200
commit cefd6c98a2
3 changed files with 364 additions and 19 deletions

View File

@@ -8,6 +8,13 @@ let currentCategoryId = null;
let isShowcaseView = false; // Флаг режима просмотра витринных букетов let isShowcaseView = false; // Флаг режима просмотра витринных букетов
const cart = new Map(); // "type-id" -> {id, name, price, qty, type} const cart = new Map(); // "type-id" -> {id, name, price, qty, type}
// Переменные для режима редактирования
let isEditMode = false;
let editingKitId = null;
// Временная корзина для модального окна создания/редактирования комплекта
const tempCart = new Map(); // Изолированное состояние для модалки
function formatMoney(v) { function formatMoney(v) {
return (Number(v)).toFixed(2); return (Number(v)).toFixed(2);
} }
@@ -116,8 +123,25 @@ function renderProducts() {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card product-card'; card.className = 'card product-card';
card.style.position = 'relative';
card.onclick = () => addToCart(item); card.onclick = () => addToCart(item);
// Если это витринный комплект - добавляем кнопку редактирования
if (item.type === 'showcase_kit') {
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-sm btn-outline-primary';
editBtn.style.position = 'absolute';
editBtn.style.top = '5px';
editBtn.style.right = '5px';
editBtn.style.zIndex = '10';
editBtn.innerHTML = '<i class="bi bi-pencil"></i>';
editBtn.onclick = (e) => {
e.stopPropagation();
openEditKitModal(item.id);
};
card.appendChild(editBtn);
}
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'card-body'; body.className = 'card-body';
@@ -319,6 +343,12 @@ async function openCreateTempKitModal() {
return; return;
} }
// Копируем содержимое cart в tempCart (изолированное состояние модалки)
tempCart.clear();
cart.forEach((item, key) => {
tempCart.set(key, {...item}); // Глубокая копия объекта
});
// Генерируем название по умолчанию // Генерируем название по умолчанию
const now = new Date(); const now = new Date();
const defaultName = `Витрина — ${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`; const defaultName = `Витрина — ${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`;
@@ -327,7 +357,7 @@ async function openCreateTempKitModal() {
// Загружаем список витрин // Загружаем список витрин
await loadShowcases(); await loadShowcases();
// Заполняем список товаров из корзины // Заполняем список товаров из tempCart
renderTempKitItems(); renderTempKitItems();
// Открываем модальное окно // Открываем модальное окно
@@ -335,6 +365,87 @@ async function openCreateTempKitModal() {
modal.show(); modal.show();
} }
// Открытие модального окна для редактирования комплекта
async function openEditKitModal(kitId) {
try {
// Загружаем данные комплекта
const response = await fetch(`/pos/api/product-kits/${kitId}/`);
const data = await response.json();
if (!data.success) {
alert(`Ошибка: ${data.error}`);
return;
}
const kit = data.kit;
// Устанавливаем режим редактирования
isEditMode = true;
editingKitId = kitId;
// Загружаем список витрин
await loadShowcases();
// Очищаем tempCart и заполняем составом комплекта
tempCart.clear();
kit.items.forEach(item => {
const cartKey = `product-${item.product_id}`;
tempCart.set(cartKey, {
id: item.product_id,
name: item.name,
price: Number(item.price),
qty: Number(item.qty),
type: 'product'
});
});
renderTempKitItems(); // Отображаем товары в модальном окне
// Заполняем поля формы
document.getElementById('tempKitName').value = kit.name;
document.getElementById('tempKitDescription').value = kit.description;
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type;
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value;
if (kit.sale_price) {
document.getElementById('useSalePrice').checked = true;
document.getElementById('salePrice').value = kit.sale_price;
document.getElementById('salePriceBlock').style.display = 'block';
} else {
document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none';
}
// Выбираем витрину
if (kit.showcase_id) {
document.getElementById('showcaseSelect').value = kit.showcase_id;
}
// Отображаем фото, если есть
if (kit.photo_url) {
document.getElementById('photoPreviewImg').src = kit.photo_url;
document.getElementById('photoPreview').style.display = 'block';
} else {
document.getElementById('photoPreview').style.display = 'none';
}
// Обновляем цены
updatePriceCalculations();
// Меняем заголовок и кнопку
document.getElementById('createTempKitModalLabel').textContent = 'Редактирование витринного букета';
document.getElementById('confirmCreateTempKit').textContent = 'Сохранить изменения';
// Открываем модальное окно
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
modal.show();
} catch (error) {
console.error('Error loading kit for edit:', error);
alert('Ошибка при загрузке комплекта');
}
}
// Обновление списка витринных комплектов // Обновление списка витринных комплектов
async function refreshShowcaseKits() { async function refreshShowcaseKits() {
try { try {
@@ -388,14 +499,14 @@ async function loadShowcases() {
} }
} }
// Отображение товаров из корзины в модальном окне // Отображение товаров из tempCart в модальном окне
function renderTempKitItems() { function renderTempKitItems() {
const container = document.getElementById('tempKitItemsList'); const container = document.getElementById('tempKitItemsList');
container.innerHTML = ''; container.innerHTML = '';
let estimatedTotal = 0; let estimatedTotal = 0;
cart.forEach((item, cartKey) => { tempCart.forEach((item, cartKey) => {
// Только товары (не комплекты) // Только товары (не комплекты)
if (item.type !== 'product') return; if (item.type !== 'product') return;
@@ -422,10 +533,10 @@ function renderTempKitItems() {
// Расчет и обновление всех цен // Расчет и обновление всех цен
function updatePriceCalculations(basePrice = null) { function updatePriceCalculations(basePrice = null) {
// Если basePrice не передан, пересчитываем из корзины // Если basePrice не передан, пересчитываем из tempCart
if (basePrice === null) { if (basePrice === null) {
basePrice = 0; basePrice = 0;
cart.forEach((item, cartKey) => { tempCart.forEach((item, cartKey) => {
if (item.type === 'product') { if (item.type === 'product') {
basePrice += item.qty * item.price; basePrice += item.qty * item.price;
} }
@@ -524,7 +635,7 @@ document.getElementById('removePhoto').addEventListener('click', function() {
document.getElementById('photoPreviewImg').src = ''; document.getElementById('photoPreviewImg').src = '';
}); });
// Подтверждение создания временного комплекта // Подтверждение создания/редактирования временного комплекта
document.getElementById('confirmCreateTempKit').onclick = async () => { document.getElementById('confirmCreateTempKit').onclick = async () => {
const kitName = document.getElementById('tempKitName').value.trim(); const kitName = document.getElementById('tempKitName').value.trim();
const showcaseId = document.getElementById('showcaseSelect').value; const showcaseId = document.getElementById('showcaseSelect').value;
@@ -537,14 +648,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
return; return;
} }
if (!showcaseId) { if (!showcaseId && !isEditMode) {
alert('Выберите витрину'); alert('Выберите витрину');
return; return;
} }
// Собираем товары из корзины // Собираем товары из tempCart (изолированное состояние модалки)
const items = []; const items = [];
cart.forEach((item, cartKey) => { tempCart.forEach((item, cartKey) => {
if (item.type === 'product') { if (item.type === 'product') {
items.push({ items.push({
product_id: item.id, product_id: item.id,
@@ -567,7 +678,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Формируем FormData для отправки с файлом // Формируем FormData для отправки с файлом
const formData = new FormData(); const formData = new FormData();
formData.append('kit_name', kitName); formData.append('kit_name', kitName);
formData.append('showcase_id', showcaseId); if (showcaseId) {
formData.append('showcase_id', showcaseId);
}
formData.append('description', description); formData.append('description', description);
formData.append('items', JSON.stringify(items)); formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType); formData.append('price_adjustment_type', priceAdjustmentType);
@@ -575,17 +688,28 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
if (useSalePrice && salePrice > 0) { if (useSalePrice && salePrice > 0) {
formData.append('sale_price', salePrice); formData.append('sale_price', salePrice);
} }
// Фото: для редактирования проверяем, удалено ли оно
if (photoFile) { if (photoFile) {
formData.append('photo', photoFile); formData.append('photo', photoFile);
} else if (isEditMode && document.getElementById('photoPreview').style.display === 'none') {
// Если фото было удалено
formData.append('remove_photo', '1');
} }
// Отправляем запрос на сервер // Отправляем запрос на сервер
const confirmBtn = document.getElementById('confirmCreateTempKit'); const confirmBtn = document.getElementById('confirmCreateTempKit');
confirmBtn.disabled = true; confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Создание...';
const url = isEditMode
? `/pos/api/product-kits/${editingKitId}/update/`
: '/pos/api/create-temp-kit/';
const actionText = isEditMode ? 'Сохранение...' : 'Создание...';
confirmBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${actionText}`;
try { try {
const response = await fetch('/pos/api/create-temp-kit/', { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'X-CSRFToken': getCookie('csrftoken') 'X-CSRFToken': getCookie('csrftoken')
@@ -598,14 +722,18 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
if (data.success) { if (data.success) {
// Успех! // Успех!
alert(`${data.message} const successMessage = isEditMode
? `${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
: `${data.message}
Комплект: ${data.kit_name} Комплект: ${data.kit_name}
Цена: ${data.kit_price} руб. Цена: ${data.kit_price} руб.
Зарезервировано компонентов: ${data.reservations_count}`); Зарезервировано компонентов: ${data.reservations_count}`;
// Очищаем корзину alert(successMessage);
clearCart();
// Очищаем tempCart (изолированное состояние модалки)
tempCart.clear();
// Сбрасываем поля формы // Сбрасываем поля формы
document.getElementById('tempKitDescription').value = ''; document.getElementById('tempKitDescription').value = '';
@@ -618,6 +746,10 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
document.getElementById('salePrice').value = ''; document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none'; document.getElementById('salePriceBlock').style.display = 'none';
// Сбрасываем режим редактирования
isEditMode = false;
editingKitId = null;
// Закрываем модальное окно // Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
modal.hide(); modal.hide();
@@ -632,11 +764,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
alert(`Ошибка: ${data.error}`); alert(`Ошибка: ${data.error}`);
} }
} catch (error) { } catch (error) {
console.error('Error creating temp kit:', error); console.error('Error saving kit:', error);
alert('Ошибка при создании комплекта'); alert('Ошибка при сохранении комплекта');
} finally { } finally {
confirmBtn.disabled = false; confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать'; const btnText = isEditMode
? '<i class="bi bi-check-circle"></i> Сохранить изменения'
: '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
confirmBtn.innerHTML = btnText;
} }
}; };
@@ -656,6 +791,22 @@ function getCookie(name) {
return cookieValue; return cookieValue;
} }
// Сброс режима редактирования при закрытии модального окна
document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function() {
// Очищаем tempCart (изолированное состояние модалки)
tempCart.clear();
if (isEditMode) {
// Сбрасываем режим редактирования
isEditMode = false;
editingKitId = null;
// Восстанавливаем заголовок и текст кнопки
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
}
});
// Заглушки для функционала (будет реализовано позже) // Заглушки для функционала (будет реализовано позже)
document.getElementById('checkoutNow').onclick = async () => { document.getElementById('checkoutNow').onclick = async () => {
alert('Функционал будет подключен позже: создание заказа и списание со склада.'); alert('Функционал будет подключен позже: создание заказа и списание со склада.');

View File

@@ -9,5 +9,7 @@ urlpatterns = [
path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'), path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'),
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'), path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'), path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'),
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'), path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
] ]

View File

@@ -209,6 +209,56 @@ def get_showcase_kits_api(request):
}) })
@login_required
@require_http_methods(["GET"])
def get_product_kit_details(request, kit_id):
"""
API endpoint для получения полных данных комплекта для редактирования.
"""
try:
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id)
# Получаем витрину, на которой размещен комплект
showcase_reservation = Reservation.objects.filter(
product__in=kit.kit_items.values_list('product_id', flat=True),
showcase__isnull=False,
showcase__is_active=True,
status='reserved'
).select_related('showcase').first()
showcase_id = showcase_reservation.showcase.id if showcase_reservation else None
# Собираем данные о составе
items = [{
'product_id': ki.product.id,
'name': ki.product.name,
'qty': str(ki.quantity),
'price': str(ki.product.actual_price)
} for ki in kit.kit_items.all()]
# Фото
photo_url = kit.photos.first().image.url if kit.photos.exists() else None
return JsonResponse({
'success': True,
'kit': {
'id': kit.id,
'name': kit.name,
'description': kit.description or '',
'price_adjustment_type': kit.price_adjustment_type,
'price_adjustment_value': str(kit.price_adjustment_value),
'sale_price': str(kit.sale_price) if kit.sale_price else '',
'base_price': str(kit.base_price),
'final_price': str(kit.actual_price),
'showcase_id': showcase_id,
'items': items,
'photo_url': photo_url
}
})
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def create_temp_kit_to_showcase(request): def create_temp_kit_to_showcase(request):
@@ -362,3 +412,145 @@ def create_temp_kit_to_showcase(request):
'success': False, 'success': False,
'error': f'Ошибка при создании комплекта: {str(e)}' 'error': f'Ошибка при создании комплекта: {str(e)}'
}, status=500) }, status=500)
@login_required
@require_http_methods(["POST"])
def update_product_kit(request, kit_id):
"""
API endpoint для обновления временного комплекта.
Payload (multipart/form-data):
- kit_name: Новое название
- description: Описание
- items: JSON список [{product_id, quantity}, ...]
- price_adjustment_type, price_adjustment_value, sale_price
- photo: Новое фото (опционально)
- remove_photo: '1' для удаления фото
"""
try:
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True)
# Получаем данные
kit_name = request.POST.get('kit_name', '').strip()
description = request.POST.get('description', '').strip()
items_json = request.POST.get('items', '[]')
price_adjustment_type = request.POST.get('price_adjustment_type', 'none')
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
remove_photo = request.POST.get('remove_photo', '') == '1'
items = json.loads(items_json)
sale_price = None
if sale_price_str:
try:
sale_price = Decimal(str(sale_price_str))
if sale_price <= 0:
sale_price = None
except (ValueError, InvalidOperation):
sale_price = None
# Валидация
if not kit_name:
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
if not items:
return JsonResponse({'success': False, 'error': 'Состав не может быть пустым'}, status=400)
# Проверяем товары
product_ids = [item['product_id'] for item in items]
products = Product.objects.in_bulk(product_ids)
if len(products) != len(product_ids):
return JsonResponse({'success': False, 'error': 'Некоторые товары не найдены'}, status=400)
# Агрегируем количества
aggregated_items = {}
for item in items:
product_id = item['product_id']
quantity = Decimal(str(item['quantity']))
aggregated_items[product_id] = aggregated_items.get(product_id, Decimal('0')) + quantity
with transaction.atomic():
# Получаем старый состав для сравнения
old_items = {ki.product_id: ki.quantity for ki in kit.kit_items.all()}
# Получаем витрину для резервов
showcase_reservation = Reservation.objects.filter(
product__in=old_items.keys(),
showcase__isnull=False,
status='reserved'
).select_related('showcase').first()
showcase = showcase_reservation.showcase if showcase_reservation else None
# Вычисляем разницу в составе
all_product_ids = set(old_items.keys()) | set(aggregated_items.keys())
for product_id in all_product_ids:
old_qty = old_items.get(product_id, Decimal('0'))
new_qty = aggregated_items.get(product_id, Decimal('0'))
diff = new_qty - old_qty
if diff > 0 and showcase:
# Нужно дозарезервировать
result = ShowcaseManager.reserve_product_to_showcase(
product=products[product_id],
showcase=showcase,
quantity=diff
)
if not result['success']:
raise Exception(f"Недостаточно запасов: {result['message']}")
elif diff < 0 and showcase:
# Нужно освободить резерв
ShowcaseManager.release_showcase_reservation(
product=products[product_id],
showcase=showcase,
quantity=abs(diff)
)
# Обновляем комплект
kit.name = kit_name
kit.description = description
kit.price_adjustment_type = price_adjustment_type
kit.price_adjustment_value = price_adjustment_value
kit.sale_price = sale_price
kit.save()
# Обновляем состав
kit.kit_items.all().delete()
for product_id, quantity in aggregated_items.items():
KitItem.objects.create(
kit=kit,
product=products[product_id],
quantity=quantity
)
kit.recalculate_base_price()
# Обновляем фото
if remove_photo:
kit.photos.all().delete()
if photo_file:
from products.models import ProductKitPhoto
kit.photos.all().delete() # Удаляем старое
ProductKitPhoto.objects.create(kit=kit, image=photo_file, order=0)
return JsonResponse({
'success': True,
'message': f'Комплект "{kit.name}" обновлён',
'kit_id': kit.id,
'kit_name': kit.name,
'kit_price': str(kit.actual_price)
})
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)