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):