diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 9c79ead..20d07a1 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -1,19 +1,24 @@ // POS Terminal JavaScript const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent); -const ITEMS = JSON.parse(document.getElementById('itemsData').textContent); // Единый массив товаров и комплектов -let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); // Витринные комплекты (изменяемый) +let ITEMS = []; // Будем загружать через API +let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); let currentCategoryId = null; -let isShowcaseView = false; // Флаг режима просмотра витринных букетов -const cart = new Map(); // "type-id" -> {id, name, price, qty, type} +let isShowcaseView = false; +const cart = new Map(); + +// Переменные для пагинации +let currentPage = 1; +let hasMoreItems = false; +let isLoadingItems = false; // Переменные для режима редактирования let isEditMode = false; let editingKitId = null; // Временная корзина для модального окна создания/редактирования комплекта -const tempCart = new Map(); // Изолированное состояние для модалки +const tempCart = new Map(); function formatMoney(v) { return (Number(v)).toFixed(2); @@ -52,11 +57,11 @@ function renderCategories() { allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2'; const allCard = document.createElement('div'); allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : ''); - allCard.onclick = () => { + allCard.onclick = async () => { currentCategoryId = null; isShowcaseView = false; renderCategories(); - renderProducts(); + await loadItems(); // Загрузка через API }; const allBody = document.createElement('div'); allBody.className = 'card-body'; @@ -75,11 +80,11 @@ function renderCategories() { const card = document.createElement('div'); card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : ''); - card.onclick = () => { + card.onclick = async () => { currentCategoryId = cat.id; isShowcaseView = false; renderCategories(); - renderProducts(); + await loadItems(); // Загрузка через API }; const body = document.createElement('div'); @@ -105,14 +110,13 @@ function renderProducts() { // Если выбран режим витрины - показываем витринные комплекты if (isShowcaseView) { - filtered = showcaseKits; // Используем изменяемую переменную + filtered = showcaseKits; } else { - // Обычный режим - показываем товары и комплекты - filtered = currentCategoryId - ? ITEMS.filter(item => (item.category_ids || []).includes(currentCategoryId)) - : ITEMS; + // Обычный режим - ITEMS уже отфильтрованы по категории на сервере + filtered = ITEMS; } + // Поиск - клиентская фильтрация if (searchTerm) { filtered = filtered.filter(item => item.name.toLowerCase().includes(searchTerm)); } @@ -152,6 +156,7 @@ function renderProducts() { const img = document.createElement('img'); img.src = item.image; img.alt = item.name; + img.loading = 'lazy'; // Lazy loading imageDiv.appendChild(img); } else { imageDiv.innerHTML = ''; @@ -241,6 +246,76 @@ function renderProducts() { }); } +// Загрузка товаров через API +async function loadItems(append = false) { + if (isLoadingItems) return; + + isLoadingItems = true; + + if (!append) { + currentPage = 1; + ITEMS = []; + } + + try { + const params = new URLSearchParams({ + page: currentPage, + page_size: 60 + }); + + if (currentCategoryId) { + params.append('category_id', currentCategoryId); + } + + const response = await fetch(`/pos/api/items/?${params}`); + const data = await response.json(); + + if (data.success) { + if (append) { + ITEMS = ITEMS.concat(data.items); + } else { + ITEMS = data.items; + } + + hasMoreItems = data.has_more; + + if (data.has_more) { + currentPage = data.next_page; + } + + renderProducts(); + } + } catch (error) { + console.error('Ошибка загрузки товаров:', error); + } finally { + isLoadingItems = false; + } +} + +// Infinite scroll +function setupInfiniteScroll() { + const grid = document.getElementById('productGrid'); + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && hasMoreItems && !isLoadingItems && !isShowcaseView) { + loadItems(true); // Догрузка + } + }); + }, + { + rootMargin: '200px' + } + ); + + // Наблюдаем за концом грида + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + sentinel.style.height = '1px'; + grid.parentElement.appendChild(sentinel); + observer.observe(sentinel); +} + function addToCart(item) { const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" @@ -852,9 +927,9 @@ document.getElementById('scheduleLater').onclick = async () => { alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.'); }; -// Search functionality +// Search functionality - клиентская фильтрация document.getElementById('searchInput').addEventListener('input', () => { - renderProducts(); + renderProducts(); // Просто перерисовываем с фильтрацией }); // Customer selection @@ -927,8 +1002,9 @@ function getCsrfToken() { // Инициализация renderCategories(); -renderProducts(); +renderProducts(); // Сначала пустая сетка renderCart(); +setupInfiniteScroll(); // Установка infinite scroll // Установить фокус на строку поиска document.getElementById('searchInput').focus(); diff --git a/myproject/pos/urls.py b/myproject/pos/urls.py index ee43333..129f8e6 100644 --- a/myproject/pos/urls.py +++ b/myproject/pos/urls.py @@ -7,6 +7,7 @@ app_name = 'pos' urlpatterns = [ path('', views.pos_terminal, name='terminal'), path('api/set-warehouse//', views.set_warehouse, name='set-warehouse'), + path('api/items/', views.get_items_api, name='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/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'), diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 7261fa3..29e6880 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -231,47 +231,6 @@ def pos_terminal(request): categories = [{'id': c.id, 'name': c.name} for c in categories_qs] - # Сериализация товаров с оптимизацией фото - products = [] - for p in products_qs: - image_url = None - if hasattr(p, 'first_photo_list') and p.first_photo_list: - image_url = p.first_photo_list[0].get_thumbnail_url() - - products.append({ - 'id': p.id, - 'name': p.name, - 'price': str(p.actual_price), - 'category_ids': [c.id for c in p.categories.all()], - 'in_stock': p.in_stock, - 'sku': p.sku or '', - 'image': image_url, - 'type': 'product', - 'available_qty': str(p.available_qty), - 'reserved_qty': str(p.reserved_qty) - }) - - # Сериализация комплектов с оптимизацией фото - kits = [] - for k in kits_qs: - image_url = None - if hasattr(k, 'first_photo_list') and k.first_photo_list: - image_url = k.first_photo_list[0].get_thumbnail_url() - - kits.append({ - 'id': k.id, - 'name': k.name, - 'price': str(k.actual_price), - 'category_ids': [c.id for c in k.categories.all()], - 'in_stock': False, # Комплекты всегда "Под заказ" - 'sku': k.sku or '', - 'image': image_url, - 'type': 'kit' - }) - - # Объединяем все позиции - all_items = products + kits - # Список всех активных складов для модалки выбора warehouses = Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name') warehouses_list = [{ @@ -282,7 +241,7 @@ def pos_terminal(request): context = { 'categories_json': json.dumps(categories), - 'items_json': json.dumps(all_items), + 'items_json': json.dumps([]), # Пустой массив - загрузка по API при клике на категорию 'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API 'current_warehouse': { 'id': current_warehouse.id, @@ -397,6 +356,161 @@ def get_showcase_kits_api(request): }) +@login_required +@require_http_methods(["GET"]) +def get_items_api(request): + """ + API endpoint для получения товаров и комплектов с пагинацией. + Параметры: + - category_id: ID категории (опционально, для фильтрации) + - page: номер страницы (по умолчанию 1) + - page_size: размер страницы (по умолчанию 60) + Сортировка по умолчанию: по свободному остатку (available - reserved) DESC + """ + from products.models import ProductPhoto, ProductKitPhoto + from django.core.paginator import Paginator + + # Получаем текущий склад + current_warehouse = get_pos_warehouse(request) + if not current_warehouse: + return JsonResponse({ + 'success': False, + 'error': 'Нет активного склада' + }, status=400) + + # Параметры пагинации + category_id = request.GET.get('category_id') + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 60)) + + # Prefetch для первого фото товаров + first_product_photo = Prefetch( + 'photos', + queryset=ProductPhoto.objects.order_by('order')[:1], + to_attr='first_photo_list' + ) + + # Подзапросы для остатков по текущему складу + stock_available_subquery = Stock.objects.filter( + product=OuterRef('pk'), + warehouse=current_warehouse + ).values('quantity_available')[:1] + + stock_reserved_subquery = Stock.objects.filter( + product=OuterRef('pk'), + warehouse=current_warehouse + ).values('quantity_reserved')[:1] + + # Фильтруем только активные товары + products_qs = Product.objects.filter(status='active').annotate( + available_qty=Coalesce( + Subquery(stock_available_subquery, output_field=DecimalField()), + Decimal('0'), + output_field=DecimalField() + ), + reserved_qty=Coalesce( + Subquery(stock_reserved_subquery, output_field=DecimalField()), + Decimal('0'), + output_field=DecimalField() + ) + ).prefetch_related( + 'categories', + first_product_photo + ) + + # Фильтруем по категории, если указана + if category_id: + products_qs = products_qs.filter(categories__id=category_id) + + # Сериализуем товары + products = [] + for p in products_qs: + image_url = None + if hasattr(p, 'first_photo_list') and p.first_photo_list: + image_url = p.first_photo_list[0].get_thumbnail_url() + + available = p.available_qty + reserved = p.reserved_qty + free_qty = available - reserved + + products.append({ + 'id': p.id, + 'name': p.name, + 'price': str(p.actual_price), + 'category_ids': [c.id for c in p.categories.all()], + 'in_stock': p.in_stock, + 'sku': p.sku or '', + 'image': image_url, + 'type': 'product', + 'available_qty': str(available), + 'reserved_qty': str(reserved), + 'free_qty': float(free_qty) # Для сортировки + }) + + # Prefetch для первого фото комплектов + first_kit_photo = Prefetch( + 'photos', + queryset=ProductKitPhoto.objects.order_by('order')[:1], + to_attr='first_photo_list' + ) + + # Активные комплекты (не временные) + kits_qs = ProductKit.objects.filter( + is_temporary=False, + status='active' + ).prefetch_related( + 'categories', + first_kit_photo + ) + + # Фильтруем комплекты по категории, если указана + if category_id: + kits_qs = kits_qs.filter(categories__id=category_id) + + # Сериализуем комплекты + kits = [] + for k in kits_qs: + image_url = None + if hasattr(k, 'first_photo_list') and k.first_photo_list: + image_url = k.first_photo_list[0].get_thumbnail_url() + + kits.append({ + 'id': k.id, + 'name': k.name, + 'price': str(k.actual_price), + 'category_ids': [c.id for c in k.categories.all()], + 'in_stock': False, # Комплекты всегда "Под заказ" + 'sku': k.sku or '', + 'image': image_url, + 'type': 'kit', + 'free_qty': 0 # Для сортировки комплекты всегда внизу + }) + + # Объединяем и сортируем по free_qty DESC + all_items = products + kits + all_items.sort(key=lambda x: x['free_qty'], reverse=True) + + # Пагинация + paginator = Paginator(all_items, page_size) + page_obj = paginator.get_page(page) + + # Удаляем временное поле free_qty из результата + items_to_return = [] + for item in page_obj.object_list: + item_copy = item.copy() + item_copy.pop('free_qty', None) + items_to_return.append(item_copy) + + return JsonResponse({ + 'success': True, + 'items': items_to_return, + 'has_more': page_obj.has_next(), + 'next_page': page_obj.next_page_number() if page_obj.has_next() else None, + 'total_pages': paginator.num_pages, + 'total_items': paginator.count + }) + + @login_required @require_http_methods(["GET"]) def get_product_kit_details(request, kit_id):