diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 6c95ff7..8c67a24 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -8,6 +8,13 @@ let currentCategoryId = null; let isShowcaseView = false; // Флаг режима просмотра витринных букетов const cart = new Map(); // "type-id" -> {id, name, price, qty, type} +// Переменные для режима редактирования +let isEditMode = false; +let editingKitId = null; + +// Временная корзина для модального окна создания/редактирования комплекта +const tempCart = new Map(); // Изолированное состояние для модалки + function formatMoney(v) { return (Number(v)).toFixed(2); } @@ -116,8 +123,25 @@ function renderProducts() { const card = document.createElement('div'); card.className = 'card product-card'; + card.style.position = 'relative'; 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 = ''; + editBtn.onclick = (e) => { + e.stopPropagation(); + openEditKitModal(item.id); + }; + card.appendChild(editBtn); + } + const body = document.createElement('div'); body.className = 'card-body'; @@ -319,6 +343,12 @@ async function openCreateTempKitModal() { return; } + // Копируем содержимое cart в tempCart (изолированное состояние модалки) + tempCart.clear(); + cart.forEach((item, key) => { + tempCart.set(key, {...item}); // Глубокая копия объекта + }); + // Генерируем название по умолчанию const now = new Date(); 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(); - // Заполняем список товаров из корзины + // Заполняем список товаров из tempCart renderTempKitItems(); // Открываем модальное окно @@ -335,6 +365,87 @@ async function openCreateTempKitModal() { 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() { try { @@ -388,14 +499,14 @@ async function loadShowcases() { } } -// Отображение товаров из корзины в модальном окне +// Отображение товаров из tempCart в модальном окне function renderTempKitItems() { const container = document.getElementById('tempKitItemsList'); container.innerHTML = ''; let estimatedTotal = 0; - cart.forEach((item, cartKey) => { + tempCart.forEach((item, cartKey) => { // Только товары (не комплекты) if (item.type !== 'product') return; @@ -422,10 +533,10 @@ function renderTempKitItems() { // Расчет и обновление всех цен function updatePriceCalculations(basePrice = null) { - // Если basePrice не передан, пересчитываем из корзины + // Если basePrice не передан, пересчитываем из tempCart if (basePrice === null) { basePrice = 0; - cart.forEach((item, cartKey) => { + tempCart.forEach((item, cartKey) => { if (item.type === 'product') { basePrice += item.qty * item.price; } @@ -524,7 +635,7 @@ document.getElementById('removePhoto').addEventListener('click', function() { document.getElementById('photoPreviewImg').src = ''; }); -// Подтверждение создания временного комплекта +// Подтверждение создания/редактирования временного комплекта document.getElementById('confirmCreateTempKit').onclick = async () => { const kitName = document.getElementById('tempKitName').value.trim(); const showcaseId = document.getElementById('showcaseSelect').value; @@ -537,14 +648,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { return; } - if (!showcaseId) { + if (!showcaseId && !isEditMode) { alert('Выберите витрину'); return; } - // Собираем товары из корзины + // Собираем товары из tempCart (изолированное состояние модалки) const items = []; - cart.forEach((item, cartKey) => { + tempCart.forEach((item, cartKey) => { if (item.type === 'product') { items.push({ product_id: item.id, @@ -567,7 +678,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { // Формируем FormData для отправки с файлом const formData = new FormData(); formData.append('kit_name', kitName); - formData.append('showcase_id', showcaseId); + if (showcaseId) { + formData.append('showcase_id', showcaseId); + } formData.append('description', description); formData.append('items', JSON.stringify(items)); formData.append('price_adjustment_type', priceAdjustmentType); @@ -575,17 +688,28 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { if (useSalePrice && salePrice > 0) { formData.append('sale_price', salePrice); } + + // Фото: для редактирования проверяем, удалено ли оно if (photoFile) { formData.append('photo', photoFile); + } else if (isEditMode && document.getElementById('photoPreview').style.display === 'none') { + // Если фото было удалено + formData.append('remove_photo', '1'); } // Отправляем запрос на сервер const confirmBtn = document.getElementById('confirmCreateTempKit'); confirmBtn.disabled = true; - confirmBtn.innerHTML = 'Создание...'; + + const url = isEditMode + ? `/pos/api/product-kits/${editingKitId}/update/` + : '/pos/api/create-temp-kit/'; + + const actionText = isEditMode ? 'Сохранение...' : 'Создание...'; + confirmBtn.innerHTML = `${actionText}`; try { - const response = await fetch('/pos/api/create-temp-kit/', { + const response = await fetch(url, { method: 'POST', headers: { 'X-CSRFToken': getCookie('csrftoken') @@ -598,14 +722,18 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { 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_price} руб. -Зарезервировано компонентов: ${data.reservations_count}`); +Зарезервировано компонентов: ${data.reservations_count}`; - // Очищаем корзину - clearCart(); + alert(successMessage); + + // Очищаем tempCart (изолированное состояние модалки) + tempCart.clear(); // Сбрасываем поля формы document.getElementById('tempKitDescription').value = ''; @@ -618,6 +746,10 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { document.getElementById('salePrice').value = ''; document.getElementById('salePriceBlock').style.display = 'none'; + // Сбрасываем режим редактирования + isEditMode = false; + editingKitId = null; + // Закрываем модальное окно const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); modal.hide(); @@ -632,11 +764,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { alert(`Ошибка: ${data.error}`); } } catch (error) { - console.error('Error creating temp kit:', error); - alert('Ошибка при создании комплекта'); + console.error('Error saving kit:', error); + alert('Ошибка при сохранении комплекта'); } finally { confirmBtn.disabled = false; - confirmBtn.innerHTML = ' Создать и зарезервировать'; + const btnText = isEditMode + ? ' Сохранить изменения' + : ' Создать и зарезервировать'; + confirmBtn.innerHTML = btnText; } }; @@ -656,6 +791,22 @@ function getCookie(name) { 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 = ' Создать и зарезервировать'; + } +}); + // Заглушки для функционала (будет реализовано позже) document.getElementById('checkoutNow').onclick = async () => { alert('Функционал будет подключен позже: создание заказа и списание со склада.'); diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index 47ac5b2..fa027bb 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -9,5 +9,7 @@ urlpatterns = [ 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/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'), + path('api/product-kits//', views.get_product_kit_details, name='get-product-kit-details'), + path('api/product-kits//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'), ] diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 804737f..5bb15d8 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -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 @require_http_methods(["POST"]) def create_temp_kit_to_showcase(request): @@ -362,3 +412,145 @@ def create_temp_kit_to_showcase(request): 'success': False, 'error': f'Ошибка при создании комплекта: {str(e)}' }, 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)