Реализована прогрессивная загрузка товаров в POS с пагинацией и infinite scroll. Первая загрузка только категорий и склада, товары подгружаются по 60 штук при клике на категорию с сортировкой по свободным остаткам (available-reserved) по убыванию. Добавлен API endpoint /pos/api/items/ с фильтрацией по категориям и пагинацией. Infinite scroll для догрузки следующих страниц. Lazy loading для изображений.
This commit is contained in:
@@ -1,19 +1,24 @@
|
|||||||
// POS Terminal JavaScript
|
// POS Terminal JavaScript
|
||||||
|
|
||||||
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
|
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
|
||||||
const ITEMS = JSON.parse(document.getElementById('itemsData').textContent); // Единый массив товаров и комплектов
|
let ITEMS = []; // Будем загружать через API
|
||||||
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); // Витринные комплекты (изменяемый)
|
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
|
||||||
|
|
||||||
let currentCategoryId = null;
|
let currentCategoryId = null;
|
||||||
let isShowcaseView = false; // Флаг режима просмотра витринных букетов
|
let isShowcaseView = false;
|
||||||
const cart = new Map(); // "type-id" -> {id, name, price, qty, type}
|
const cart = new Map();
|
||||||
|
|
||||||
|
// Переменные для пагинации
|
||||||
|
let currentPage = 1;
|
||||||
|
let hasMoreItems = false;
|
||||||
|
let isLoadingItems = false;
|
||||||
|
|
||||||
// Переменные для режима редактирования
|
// Переменные для режима редактирования
|
||||||
let isEditMode = false;
|
let isEditMode = false;
|
||||||
let editingKitId = null;
|
let editingKitId = null;
|
||||||
|
|
||||||
// Временная корзина для модального окна создания/редактирования комплекта
|
// Временная корзина для модального окна создания/редактирования комплекта
|
||||||
const tempCart = new Map(); // Изолированное состояние для модалки
|
const tempCart = new Map();
|
||||||
|
|
||||||
function formatMoney(v) {
|
function formatMoney(v) {
|
||||||
return (Number(v)).toFixed(2);
|
return (Number(v)).toFixed(2);
|
||||||
@@ -52,11 +57,11 @@ function renderCategories() {
|
|||||||
allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
||||||
const allCard = document.createElement('div');
|
const allCard = document.createElement('div');
|
||||||
allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : '');
|
allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : '');
|
||||||
allCard.onclick = () => {
|
allCard.onclick = async () => {
|
||||||
currentCategoryId = null;
|
currentCategoryId = null;
|
||||||
isShowcaseView = false;
|
isShowcaseView = false;
|
||||||
renderCategories();
|
renderCategories();
|
||||||
renderProducts();
|
await loadItems(); // Загрузка через API
|
||||||
};
|
};
|
||||||
const allBody = document.createElement('div');
|
const allBody = document.createElement('div');
|
||||||
allBody.className = 'card-body';
|
allBody.className = 'card-body';
|
||||||
@@ -75,11 +80,11 @@ function renderCategories() {
|
|||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
|
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
|
||||||
card.onclick = () => {
|
card.onclick = async () => {
|
||||||
currentCategoryId = cat.id;
|
currentCategoryId = cat.id;
|
||||||
isShowcaseView = false;
|
isShowcaseView = false;
|
||||||
renderCategories();
|
renderCategories();
|
||||||
renderProducts();
|
await loadItems(); // Загрузка через API
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
@@ -105,14 +110,13 @@ function renderProducts() {
|
|||||||
|
|
||||||
// Если выбран режим витрины - показываем витринные комплекты
|
// Если выбран режим витрины - показываем витринные комплекты
|
||||||
if (isShowcaseView) {
|
if (isShowcaseView) {
|
||||||
filtered = showcaseKits; // Используем изменяемую переменную
|
filtered = showcaseKits;
|
||||||
} else {
|
} else {
|
||||||
// Обычный режим - показываем товары и комплекты
|
// Обычный режим - ITEMS уже отфильтрованы по категории на сервере
|
||||||
filtered = currentCategoryId
|
filtered = ITEMS;
|
||||||
? ITEMS.filter(item => (item.category_ids || []).includes(currentCategoryId))
|
|
||||||
: ITEMS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поиск - клиентская фильтрация
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(item => item.name.toLowerCase().includes(searchTerm));
|
filtered = filtered.filter(item => item.name.toLowerCase().includes(searchTerm));
|
||||||
}
|
}
|
||||||
@@ -152,6 +156,7 @@ function renderProducts() {
|
|||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = item.image;
|
img.src = item.image;
|
||||||
img.alt = item.name;
|
img.alt = item.name;
|
||||||
|
img.loading = 'lazy'; // Lazy loading
|
||||||
imageDiv.appendChild(img);
|
imageDiv.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
|
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
|
||||||
@@ -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) {
|
function addToCart(item) {
|
||||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||||
|
|
||||||
@@ -852,9 +927,9 @@ document.getElementById('scheduleLater').onclick = async () => {
|
|||||||
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality - клиентская фильтрация
|
||||||
document.getElementById('searchInput').addEventListener('input', () => {
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
renderProducts();
|
renderProducts(); // Просто перерисовываем с фильтрацией
|
||||||
});
|
});
|
||||||
|
|
||||||
// Customer selection
|
// Customer selection
|
||||||
@@ -927,8 +1002,9 @@ function getCsrfToken() {
|
|||||||
|
|
||||||
// Инициализация
|
// Инициализация
|
||||||
renderCategories();
|
renderCategories();
|
||||||
renderProducts();
|
renderProducts(); // Сначала пустая сетка
|
||||||
renderCart();
|
renderCart();
|
||||||
|
setupInfiniteScroll(); // Установка infinite scroll
|
||||||
|
|
||||||
// Установить фокус на строку поиска
|
// Установить фокус на строку поиска
|
||||||
document.getElementById('searchInput').focus();
|
document.getElementById('searchInput').focus();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ app_name = 'pos'
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.pos_terminal, name='terminal'),
|
path('', views.pos_terminal, name='terminal'),
|
||||||
path('api/set-warehouse/<int:warehouse_id>/', views.set_warehouse, name='set-warehouse'),
|
path('api/set-warehouse/<int:warehouse_id>/', 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/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'),
|
||||||
|
|||||||
@@ -231,47 +231,6 @@ def pos_terminal(request):
|
|||||||
|
|
||||||
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
|
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 = Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name')
|
||||||
warehouses_list = [{
|
warehouses_list = [{
|
||||||
@@ -282,7 +241,7 @@ def pos_terminal(request):
|
|||||||
|
|
||||||
context = {
|
context = {
|
||||||
'categories_json': json.dumps(categories),
|
'categories_json': json.dumps(categories),
|
||||||
'items_json': json.dumps(all_items),
|
'items_json': json.dumps([]), # Пустой массив - загрузка по API при клике на категорию
|
||||||
'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API
|
'showcase_kits_json': json.dumps([]), # Пустой массив - загрузка по API
|
||||||
'current_warehouse': {
|
'current_warehouse': {
|
||||||
'id': current_warehouse.id,
|
'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
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def get_product_kit_details(request, kit_id):
|
def get_product_kit_details(request, kit_id):
|
||||||
|
|||||||
Reference in New Issue
Block a user