feat: Добавить мобильную адаптацию для POS-терминала

- Добавить фиксированную панель корзины внизу экрана на мобильных
  - Отображение количества товаров и суммы
  - Кнопки "Продать" и "Очистить" всегда доступны
  - Тап на панель открывает корзину как overlay

- Фиксировать поиск и категории сверху на мобильных
  - Поиск всегда виден при скролле
  - Категории в collapsible-блоке (сворачиваются)
  - Категории в 3 колонки на мобильных

- Улучшить поиск по токенам (разбивает фразу на слова)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 23:03:40 +03:00
parent 3095e01659
commit c77fcaf669
4 changed files with 454 additions and 12 deletions

View File

@@ -576,3 +576,272 @@ body {
}
/* На мобильных - такой же вид с текстом */
/* ============================================================
МОБИЛЬНАЯ ПЛАВАЮЩАЯ КОРЗИНА
============================================================ */
/* Фиксированный бар внизу экрана */
.mobile-cart-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 2px solid #dee2e6;
padding: 0.75rem;
display: none; /* скрываем на десктопе */
justify-content: space-between;
align-items: center;
gap: 0.75rem;
z-index: 1000;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
}
/* Сводка по корзине (кликабельная) */
.mobile-cart-summary {
flex: 1;
display: flex;
flex-direction: column;
cursor: pointer;
padding: 0.25rem;
border-radius: 8px;
transition: background-color 0.2s;
}
.mobile-cart-summary:hover {
background-color: #f8f9fa;
}
.mobile-cart-summary:active {
background-color: #e9ecef;
}
/* Количество товаров */
.mobile-cart-count {
font-size: 0.8rem;
color: #6c757d;
font-weight: 500;
}
/* Итоговая сумма */
.mobile-cart-total {
font-size: 1.35rem;
font-weight: 700;
color: #198754;
line-height: 1.2;
}
/* Кнопки действий в мобильном баре */
.mobile-cart-actions {
display: flex;
gap: 0.5rem;
}
.mobile-cart-actions .btn {
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
white-space: nowrap;
}
.mobile-cart-actions .btn span {
display: inline;
}
/* Overlay корзины (фон затемнения) */
.mobile-cart-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1100;
display: none;
}
.mobile-cart-overlay.active {
display: flex;
align-items: flex-end;
}
/* Контент корзины в overlay */
.mobile-cart-content {
background: white;
width: 100%;
max-height: 75vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
border-radius: 16px 16px 0 0;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Заголовок мобильной корзины */
.mobile-cart-header {
padding: 1rem;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border-radius: 16px 16px 0 0;
}
.mobile-cart-header h6 {
font-weight: 600;
}
/* Тело корзины с прокруткой */
.mobile-cart-body {
padding: 1rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Копия стилей корзины для мобильного view */
.mobile-cart-body .cart-item {
padding: 0.5rem 0;
}
.mobile-cart-body .item-name-price .fw-semibold {
font-size: 0.95rem;
}
/* Показываем мобильный бар только на маленьких экранах */
@media (max-width: 767.98px) {
.mobile-cart-bar {
display: flex;
}
/* Скрываем десктопную правую панель на мобильных */
.right-panel-fixed {
display: none !important;
}
/* Добавляем отступы чтобы контент не перекрывался барами */
.pos-container {
padding-bottom: 80px;
padding-top: 70px; /* место для фиксированного поиска */
}
/* Фиксируем основной контейнер для работы с позиционированием */
.pos-main-container {
overflow: visible; /* меняем для работы fixed внутри */
}
/* Создаём фиксированный бар поиска на мобильных */
.pos-container > .row {
position: relative;
}
/* Левая колонка с товарами */
.pos-container > .row > .col-12:first-child {
position: relative;
}
/* Блок поиска выносим в фиксированный бар */
.pos-container > .row > .col-12:first-child > div:first-child,
.pos-container > .row > .col-12:first-child > .mb-3:first-child {
position: fixed;
top: 56px; /* сразу под navbar */
left: 0;
right: 0;
z-index: 900;
background: white;
padding: 0.75rem;
margin: 0;
border-bottom: 1px solid #dee2e6;
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
}
/* Категории тоже фиксируем под поиском */
.pos-container > .row > .col-12:first-child > div:nth-child(2) {
position: fixed;
top: 115px; /* поиск (56+~55) + категории */
left: 0;
right: 0;
z-index: 899;
background: white;
padding: 0.5rem 0.75rem;
margin: 0;
border-bottom: 1px solid #e9ecef;
}
/* Сворачивание категорий на мобильных */
.categories-wrapper {
margin-bottom: 0.5rem !important;
}
/* Кнопка переключения категорий */
.categories-toggle {
cursor: pointer;
color: #495057;
font-size: 0.9rem;
}
.categories-toggle:hover {
color: #212529;
}
/* Иконка chevron с поворотом */
.categories-toggle .bi-chevron-down {
transition: transform 0.2s ease;
}
.categories-toggle.collapsed .bi-chevron-down {
transform: rotate(-90deg);
}
/* Содержимое категорий */
.categories-content {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
-webkit-overflow-scrolling: touch;
}
.categories-content.collapsed {
max-height: 0;
opacity: 0;
overflow: hidden;
}
/* Меньшие карточки категорий на мобильных */
.category-card {
min-height: 32px !important;
padding: 0.25rem 0.5rem !important;
}
.category-card .card-body {
padding: 0.25rem !important;
}
.category-name {
font-size: 0.75rem !important;
}
/* Уменьшаем gap между категориями */
#categoryGrid {
gap: 0.5rem !important;
}
/* Категории в 3 колонки на мобильных */
#categoryGrid > div {
flex: 0 0 calc(33.333% - 0.33rem);
max-width: calc(33.333% - 0.33rem);
}
/* Прокручиваемая область товаров смещаем вниз */
.products-scrollable {
margin-top: 90px; /* учитываем поиск и категории */
}
}

View File

@@ -787,10 +787,12 @@ function renderProducts() {
// Для витрины — клиентская фильтрация по поиску
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
if (searchTerm) {
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
filtered = filtered.filter(item => {
const name = (item.name || '').toLowerCase();
const sku = (item.sku || '').toLowerCase();
return name.includes(searchTerm) || sku.includes(searchTerm);
// Каждый токен должен совпадать хотя бы с одним словом в name или sku
return tokens.every(token => name.includes(token) || sku.includes(token));
});
}
} else {
@@ -1261,6 +1263,7 @@ function renderCart() {
list.innerHTML = '<p class="text-muted text-center py-4 small">Корзина пуста</p>';
document.getElementById('cartTotal').textContent = '0.00';
updateShowcaseButtonState(); // Обновляем состояние кнопки
updateMobileCartBar(); // Обновляем мобильный бар даже когда корзина пуста
return;
}
@@ -1423,6 +1426,56 @@ function renderCart() {
// Обновляем состояние кнопки "НА ВИТРИНУ"
updateShowcaseButtonState();
// Обновляем мобильный бар корзины
updateMobileCartBar();
}
/**
* Склонение слов в зависимости от числа
* @param {number} number - число
* @param {string} one - форма для 1 (товар)
* @param {string} two - форма для 2-4 (товара)
* @param {string} five - форма для 5+ (товаров)
*/
function getNoun(number, one, two, five) {
const n = Math.abs(number);
const n10 = n % 10;
const n100 = n % 100;
if (n100 >= 11 && n100 <= 19) {
return five;
}
if (n10 === 1) {
return one;
}
if (n10 >= 2 && n10 <= 4) {
return two;
}
return five;
}
/**
* Обновляет мобильный бар корзины
*/
function updateMobileCartBar() {
const countEl = document.querySelector('.mobile-cart-count');
const totalEl = document.querySelector('.mobile-cart-total');
if (!countEl || !totalEl) return;
let count = 0;
let total = 0;
cart.forEach((item) => {
count += item.qty;
total += item.qty * item.price;
});
// Округляем количество до целого для отображения
const displayCount = Math.round(count);
countEl.textContent = `${displayCount} ${getNoun(displayCount, 'товар', 'товара', 'товаров')}`;
totalEl.textContent = formatMoney(total);
}
async function removeFromCart(cartKey) {
@@ -3260,6 +3313,85 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('confirmAddUnitToCart').addEventListener('click', () => {
addToCartFromModal();
});
// ===== МОБИЛЬНАЯ КОРЗИНА =====
// Тап на бар — открываем корзину
const mobileCartSummary = document.getElementById('mobileCartSummary');
if (mobileCartSummary) {
mobileCartSummary.addEventListener('click', () => {
const overlay = document.getElementById('mobileCartOverlay');
const body = document.getElementById('mobileCartBody');
// Копируем содержимое корзины
if (body && overlay) {
const cartList = document.getElementById('cartList');
body.innerHTML = cartList ? cartList.innerHTML : '<p class="text-muted text-center py-4">Корзина пуста</p>';
overlay.classList.add('active');
}
});
}
// Кнопка закрытия мобильной корзины
const mobileCartClose = document.getElementById('mobileCartClose');
if (mobileCartClose) {
mobileCartClose.addEventListener('click', () => {
const overlay = document.getElementById('mobileCartOverlay');
if (overlay) {
overlay.classList.remove('active');
}
});
}
// Закрытие по клику на фон
const mobileCartOverlay = document.getElementById('mobileCartOverlay');
if (mobileCartOverlay) {
mobileCartOverlay.addEventListener('click', (e) => {
if (e.target.id === 'mobileCartOverlay') {
e.target.classList.remove('active');
}
});
}
// Мобильная кнопка "Продать"
const mobileCheckoutBtn = document.getElementById('mobileCheckoutBtn');
if (mobileCheckoutBtn) {
mobileCheckoutBtn.addEventListener('click', () => {
const checkoutBtn = document.getElementById('checkoutNow');
if (checkoutBtn) {
checkoutBtn.click();
}
});
}
// Мобильная кнопка "Очистить"
const mobileClearCartBtn = document.getElementById('mobileClearCartBtn');
if (mobileClearCartBtn) {
mobileClearCartBtn.addEventListener('click', () => {
const clearBtn = document.getElementById('clearCart');
if (clearBtn) {
clearBtn.click();
}
});
}
// ===== СВОРАЧИВАНИЕ КАТЕГОРИЙ НА МОБИЛЬНЫХ =====
const categoriesToggle = document.getElementById('categoriesToggle');
const categoriesContent = document.getElementById('categoriesContent');
if (categoriesToggle && categoriesContent) {
categoriesToggle.addEventListener('click', () => {
categoriesToggle.classList.toggle('collapsed');
categoriesContent.classList.toggle('collapsed');
});
// Автоматически сворачиваем категории на мобильных при загрузке
if (window.innerWidth <= 767) {
categoriesToggle.classList.add('collapsed');
categoriesContent.classList.add('collapsed');
}
}
});
// Смена склада

View File

@@ -28,8 +28,14 @@
</div>
<!-- Categories -->
<div class="mb-3">
<div class="row g-3" id="categoryGrid"></div>
<div class="mb-3 categories-wrapper" id="categoriesWrapper">
<button class="categories-toggle w-100 d-flex align-items-center justify-content-between py-2 px-0 bg-transparent border-0" type="button" id="categoriesToggle">
<span class="fw-semibold small">Категории</span>
<i class="bi bi-chevron-down transition-transform" id="categoriesChevron"></i>
</button>
<div class="categories-content" id="categoriesContent">
<div class="row g-2" id="categoryGrid"></div>
</div>
</div>
<!-- Products Grid (Блок товаров) - Прокручиваемая область -->
@@ -123,6 +129,35 @@
</div>
</div>
<!-- Мобильная плавающая корзина -->
<div class="mobile-cart-bar" id="mobileCartBar">
<div class="mobile-cart-summary" id="mobileCartSummary">
<span class="mobile-cart-count">0 товаров</span>
<span class="mobile-cart-total">0.00 ₽</span>
</div>
<div class="mobile-cart-actions">
<button class="btn btn-success btn-sm" id="mobileCheckoutBtn">
<i class="bi bi-check2-circle"></i> <span>Продать</span>
</button>
<button class="btn btn-outline-secondary btn-sm" id="mobileClearCartBtn" title="Очистить корзину">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- Мобильная корзина (overlay) -->
<div class="mobile-cart-overlay" id="mobileCartOverlay">
<div class="mobile-cart-content">
<div class="mobile-cart-header">
<h6 class="mb-0">Корзина</h6>
<button class="btn-close" id="mobileCartClose"></button>
</div>
<div class="mobile-cart-body" id="mobileCartBody">
<!-- Содержимое корзины динамически -->
</div>
</div>
</div>
<!-- Modal: Создание временного комплекта на витрину -->
<div class="modal fade" id="createTempKitModal" tabindex="-1" aria-labelledby="createTempKitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-fullscreen-sm-down">

View File

@@ -774,10 +774,13 @@ def get_items_api(request):
if category_id:
products_qs = products_qs.filter(categories__id=category_id)
# Фильтруем по поисковому запросу (name или sku)
# Фильтруем по поисковому запросу (name или sku) - разбиваем на токены
if search_query:
tokens = search_query.split()
for token in tokens:
if token: # Пропускаем пустые токены
products_qs = products_qs.filter(
Q(name__icontains=search_query) | Q(sku__icontains=search_query)
Q(name__icontains=token) | Q(sku__icontains=token)
)
# Сериализуем товары
@@ -863,10 +866,13 @@ def get_items_api(request):
if category_id:
kits_qs = kits_qs.filter(categories__id=category_id)
# Фильтруем комплекты по поисковому запросу (name или sku)
# Фильтруем комплекты по поисковому запросу (name или sku) - разбиваем на токены
if search_query:
tokens = search_query.split()
for token in tokens:
if token: # Пропускаем пустые токены
kits_qs = kits_qs.filter(
Q(name__icontains=search_query) | Q(sku__icontains=search_query)
Q(name__icontains=token) | Q(sku__icontains=token)
)
# Сериализуем комплекты