Files
octopus/myproject/pos/static/pos/js/terminal.js
Andrey Smakotin 763ad2ce07 Add styled +/- quantity controls to POS cart
Implemented intuitive quantity management in the cart with:
- Minus button (-): Decreases quantity by 1, removes item if qty becomes 0
- Plus button (+): Increases quantity by 1
- Quantity input field: Centered, uniform styling with buttons
- All controls styled with Bootstrap outline-secondary (gray) for cohesive look
- Buttons feature Bootstrap Icons (dash-circle, plus-circle) at 1.2em size
- Auto-save to Redis after each quantity change

Benefits:
 Faster quantity adjustments without keyboard input
 Consistent gray styling across all quantity controls
 Mobile-friendly touch targets
 Automatic cart persistence maintained

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:19:28 +03:00

1610 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// POS Terminal JavaScript
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
let currentCategoryId = null;
let isShowcaseView = false;
const cart = new Map();
// Переменные для пагинации
let currentPage = 1;
let hasMoreItems = false;
let isLoadingItems = false;
let currentSearchQuery = ''; // Текущий поисковый запрос
let searchDebounceTimer = null;
// Переменные для режима редактирования
let isEditMode = false;
let editingKitId = null;
// Временная корзина для модального окна создания/редактирования комплекта
const tempCart = new Map();
// ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS =====
let saveCartTimeout = null;
/**
* Сохраняет корзину в Redis с debounce 500ms
*/
function saveCartToRedis() {
// Отменяем предыдущий таймер
if (saveCartTimeout) {
clearTimeout(saveCartTimeout);
}
// Устанавливаем новый таймер
saveCartTimeout = setTimeout(() => {
// Конвертируем Map в обычный объект
const cartObj = {};
cart.forEach((value, key) => {
cartObj[key] = value;
});
// Отправляем на сервер
fetch('/pos/api/save-cart/', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ cart: cartObj })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.error('Ошибка сохранения корзины:', data.error);
}
})
.catch(error => {
console.error('Ошибка при сохранении корзины в Redis:', error);
});
}, 500); // Debounce 500ms
}
// ===== УПРАВЛЕНИЕ КЛИЕНТОМ =====
// Загружаем данные системного клиента
const SYSTEM_CUSTOMER = JSON.parse(document.getElementById('systemCustomerData').textContent);
// Текущий выбранный клиент (загружается из Redis или системный)
let selectedCustomer = JSON.parse(document.getElementById('selectedCustomerData').textContent);
function formatMoney(v) {
return (Number(v)).toFixed(2);
}
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
/**
* Обновляет отображение выбранного клиента в UI
* Обновляет:
* - Кнопку "Выбрать клиента" в корзине (показывает имя клиента)
* - Имя клиента в модалке продажи
* - Видимость кнопки сброса (показываем только для не-системного клиента)
*/
function updateCustomerDisplay() {
// Обновляем текст кнопки - всегда показываем имя клиента
const btnText = document.getElementById('customerSelectBtnText');
btnText.textContent = selectedCustomer.name;
// Обновляем имя клиента в модалке продажи
const checkoutCustomerName = document.getElementById('checkoutCustomerName');
if (checkoutCustomerName) {
checkoutCustomerName.textContent = selectedCustomer.name;
}
// Показываем/скрываем кнопку сброса
const resetBtn = document.getElementById('resetCustomerBtn');
if (resetBtn) {
// Показываем кнопку сброса только если выбран НЕ системный клиент
if (selectedCustomer.id !== SYSTEM_CUSTOMER.id) {
resetBtn.style.display = 'block';
} else {
resetBtn.style.display = 'none';
}
}
}
/**
* Устанавливает нового клиента и сохраняет в Redis
* @param {number} customerId - ID клиента
* @param {string} customerName - Имя клиента
*/
function selectCustomer(customerId, customerName) {
selectedCustomer = {
id: customerId,
name: customerName
};
updateCustomerDisplay();
// Сохраняем выбор в Redis через AJAX
fetch(`/pos/api/set-customer/${customerId}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.error('Ошибка сохранения клиента:', data.error);
}
})
.catch(error => {
console.error('Ошибка при сохранении клиента в Redis:', error);
});
}
/**
* Инициализация Select2 для поиска клиента
*/
function initCustomerSelect2() {
const $searchInput = $('#customerSearchInput');
$searchInput.select2({
theme: 'bootstrap-5',
dropdownParent: $('#selectCustomerModal'),
placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)',
minimumInputLength: 3,
allowClear: true,
ajax: {
url: '/customers/api/search/',
dataType: 'json',
delay: 300,
data: function(params) {
return {
q: params.term
};
},
processResults: function(data) {
return {
results: data.results
};
},
cache: true
},
templateResult: formatCustomerOption, // Форматирование результатов в выпадающем списке
templateSelection: formatCustomerSelection // Форматирование выбранного значения
});
// Обработка выбора клиента из списка
$searchInput.on('select2:select', function(e) {
const data = e.params.data;
// Проверяем это не опция "Создать нового клиента"
if (data.id === 'create_new') {
// Открываем модалку создания
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
modal.hide();
openCreateCustomerModal(data.text);
return;
}
// Выбираем клиента
selectCustomer(parseInt(data.id), data.name);
// Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
modal.hide();
// Очищаем Select2
$searchInput.val(null).trigger('change');
});
}
/**
* Форматирование опции клиента в выпадающем списке Select2
* Показывает: Имя, телефон, email в одну строку
*/
function formatCustomerOption(customer) {
if (customer.loading) {
return customer.text;
}
// Если это опция "Создать нового клиента"
if (customer.id === 'create_new') {
return $('<span><i class="bi bi-person-plus"></i> ' + customer.text + '</span>');
}
// Формируем текст в одну строку: Имя (жирным) + контакты (мелким)
const parts = [];
// Имя
const name = customer.name || customer.text;
parts.push('<span class="fw-bold">' + $('<div>').text(name).html() + '</span>');
// Телефон и Email
const contactInfo = [];
if (customer.phone) {
contactInfo.push($('<div>').text(customer.phone).html());
}
if (customer.email) {
contactInfo.push($('<div>').text(customer.email).html());
}
if (contactInfo.length > 0) {
parts.push('<span class="text-muted small"> (' + contactInfo.join(', ') + ')</span>');
}
return $('<span>' + parts.join('') + '</span>');
}
/**
* Форматирование выбранного клиента в поле Select2
* Показывает только имя
*/
function formatCustomerSelection(customer) {
return customer.name || customer.text;
}
/**
* Открывает модальное окно создания нового клиента
* @param {string} prefillName - Предзаполненное имя (из поиска)
*/
function openCreateCustomerModal(prefillName = '') {
const modal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
// Очищаем форму
document.getElementById('newCustomerName').value = prefillName || '';
document.getElementById('newCustomerPhone').value = '';
document.getElementById('newCustomerEmail').value = '';
document.getElementById('createCustomerError').classList.add('d-none');
modal.show();
}
/**
* Создаёт нового клиента через API
*/
async function createNewCustomer() {
const name = document.getElementById('newCustomerName').value.trim();
const phone = document.getElementById('newCustomerPhone').value.trim();
const email = document.getElementById('newCustomerEmail').value.trim();
const errorBlock = document.getElementById('createCustomerError');
// Валидация
if (!name) {
errorBlock.textContent = 'Укажите имя клиента';
errorBlock.classList.remove('d-none');
return;
}
// Скрываем ошибку
errorBlock.classList.add('d-none');
try {
const response = await fetch('/customers/api/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
name: name,
phone: phone || null,
email: email || null
})
});
const data = await response.json();
if (data.success) {
// Выбираем созданного клиента
selectCustomer(data.id, data.name);
// Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
modal.hide();
// Показываем уведомление
alert(`Клиент "${data.name}" успешно создан!`);
} else {
// Показываем ошибку
errorBlock.textContent = data.error || 'Ошибка при создании клиента';
errorBlock.classList.remove('d-none');
}
} catch (error) {
console.error('Error creating customer:', error);
errorBlock.textContent = 'Ошибка сети при создании клиента';
errorBlock.classList.remove('d-none');
}
}
function renderCategories() {
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
// Кнопка "Витрина" - первая в ряду
const showcaseCol = document.createElement('div');
showcaseCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
const showcaseCard = document.createElement('div');
showcaseCard.className = 'card category-card showcase-card' + (isShowcaseView ? ' active' : '');
showcaseCard.style.backgroundColor = '#fff3cd';
showcaseCard.style.borderColor = '#ffc107';
showcaseCard.onclick = async () => {
isShowcaseView = true;
currentCategoryId = null;
await refreshShowcaseKits(); // Загружаем свежие данные
renderCategories();
renderProducts();
};
const showcaseBody = document.createElement('div');
showcaseBody.className = 'card-body';
const showcaseName = document.createElement('div');
showcaseName.className = 'category-name';
showcaseName.innerHTML = '<i class="bi bi-flower1"></i> <strong>ВИТРИНА</strong>';
showcaseBody.appendChild(showcaseName);
showcaseCard.appendChild(showcaseBody);
showcaseCol.appendChild(showcaseCard);
grid.appendChild(showcaseCol);
// Кнопка "Все"
const allCol = document.createElement('div');
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 = async () => {
currentCategoryId = null;
isShowcaseView = false;
currentSearchQuery = ''; // Сбрасываем поиск
document.getElementById('searchInput').value = ''; // Очищаем поле поиска
renderCategories();
await loadItems(); // Загрузка через API
};
const allBody = document.createElement('div');
allBody.className = 'card-body';
const allName = document.createElement('div');
allName.className = 'category-name';
allName.textContent = 'Все товары';
allBody.appendChild(allName);
allCard.appendChild(allBody);
allCol.appendChild(allCard);
grid.appendChild(allCol);
// Категории
CATEGORIES.forEach(cat => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
const card = document.createElement('div');
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
card.onclick = async () => {
currentCategoryId = cat.id;
isShowcaseView = false;
currentSearchQuery = ''; // Сбрасываем поиск
document.getElementById('searchInput').value = ''; // Очищаем поле поиска
renderCategories();
await loadItems(); // Загрузка через API
};
const body = document.createElement('div');
body.className = 'card-body';
const name = document.createElement('div');
name.className = 'category-name';
name.textContent = cat.name;
body.appendChild(name);
card.appendChild(body);
col.appendChild(card);
grid.appendChild(col);
});
}
function renderProducts() {
const grid = document.getElementById('productGrid');
grid.innerHTML = '';
let filtered;
// Если выбран режим витрины - показываем витринные комплекты
if (isShowcaseView) {
filtered = showcaseKits;
// Для витрины — клиентская фильтрация по поиску
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
if (searchTerm) {
filtered = filtered.filter(item => {
const name = (item.name || '').toLowerCase();
const sku = (item.sku || '').toLowerCase();
return name.includes(searchTerm) || sku.includes(searchTerm);
});
}
} else {
// Обычный режим - ITEMS уже отфильтрованы на сервере (категория + поиск)
filtered = ITEMS;
}
filtered.forEach(item => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
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 = '<i class="bi bi-pencil"></i>';
editBtn.onclick = (e) => {
e.stopPropagation();
openEditKitModal(item.id);
};
card.appendChild(editBtn);
}
const body = document.createElement('div');
body.className = 'card-body';
// Изображение товара/комплекта
const imageDiv = document.createElement('div');
imageDiv.className = 'product-image';
if (item.image) {
const img = document.createElement('img');
img.src = item.image;
img.alt = item.name;
img.loading = 'lazy'; // Lazy loading
imageDiv.appendChild(img);
} else {
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
}
// Информация о товаре/комплекте
const info = document.createElement('div');
info.className = 'product-info';
const name = document.createElement('div');
name.className = 'product-name';
name.textContent = item.name;
const stock = document.createElement('div');
stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины
if (item.type === 'showcase_kit') {
stock.textContent = `🌺 ${item.showcase_name}`;
stock.style.color = '#856404';
stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
// Для обычных товаров показываем остатки: FREE(-RESERVED)
// FREE = доступно для продажи (available - reserved)
const available = parseFloat(item.available_qty) || 0;
const reserved = parseFloat(item.reserved_qty) || 0;
const free = available - reserved;
// Создаём элементы для стилизации разных размеров
const freeSpan = document.createElement('span');
freeSpan.textContent = free;
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
freeSpan.style.fontStyle = 'normal';
// Отображаем резерв только если он есть
if (reserved > 0) {
const reservedSpan = document.createElement('span');
reservedSpan.textContent = `(${reserved})`;
reservedSpan.style.fontSize = '0.85em';
reservedSpan.style.marginLeft = '3px';
reservedSpan.style.fontStyle = 'normal';
stock.appendChild(freeSpan);
stock.appendChild(reservedSpan);
} else {
stock.appendChild(freeSpan);
}
// Цветовая индикация: красный если свободных остатков нет или отрицательные
if (free <= 0) {
stock.style.color = '#dc3545'; // Красный
} else if (free < 5) {
stock.style.color = '#ffc107'; // Жёлтый (мало остатков)
} else {
stock.style.color = '#28a745'; // Зелёный (достаточно)
}
} else {
// Fallback для старых данных или комплектов
stock.textContent = item.in_stock ? 'В наличии' : 'Под заказ';
if (!item.in_stock) {
stock.style.color = '#dc3545';
}
}
const sku = document.createElement('div');
sku.className = 'product-sku';
const skuText = document.createElement('span');
skuText.textContent = item.sku || 'н/д';
const priceSpan = document.createElement('span');
priceSpan.className = 'product-price';
priceSpan.textContent = `${formatMoney(item.price)}`;
sku.appendChild(skuText);
sku.appendChild(priceSpan);
info.appendChild(name);
info.appendChild(stock);
info.appendChild(sku);
body.appendChild(imageDiv);
body.appendChild(info);
card.appendChild(body);
col.appendChild(card);
grid.appendChild(col);
});
}
// Загрузка товаров через 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);
}
// Добавляем поисковый запрос, если есть
if (currentSearchQuery) {
params.append('query', currentSearchQuery);
}
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"
if (!cart.has(cartKey)) {
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
} else {
cart.get(cartKey).qty += 1;
}
renderCart();
saveCartToRedis(); // Сохраняем в Redis
// Автоматический фокус на поле количества
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select(); // Выделяем весь текст
}
}, 50);
}
function renderCart() {
const list = document.getElementById('cartList');
list.innerHTML = '';
let total = 0;
if (cart.size === 0) {
list.innerHTML = '<p class="text-muted text-center py-4 small">Корзина пуста</p>';
document.getElementById('cartTotal').textContent = '0.00';
return;
}
cart.forEach((item, cartKey) => {
const row = document.createElement('div');
row.className = 'cart-item mb-2';
// Левая часть: Название и цена единицы
const namePrice = document.createElement('div');
namePrice.className = 'item-name-price';
// Иконка только для комплектов
let typeIcon = '';
if (item.type === 'kit' || item.type === 'showcase_kit') {
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> ';
}
namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div>
`;
// Знак умножения
const multiplySign = document.createElement('span');
multiplySign.className = 'multiply-sign';
multiplySign.textContent = 'x';
// Контейнер для кнопок количества
const qtyControl = document.createElement('div');
qtyControl.className = 'd-flex align-items-center';
qtyControl.style.gap = '2px';
// Кнопка минус
const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-outline-secondary btn-sm';
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
minusBtn.onclick = (e) => {
e.preventDefault();
const currentQty = cart.get(cartKey).qty;
if (currentQty <= 1) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = currentQty - 1;
renderCart();
saveCartToRedis();
}
};
// Поле ввода количества
const qtyInput = document.createElement('input');
qtyInput.type = 'number';
qtyInput.className = 'qty-input form-control form-control-sm';
qtyInput.style.width = '60px';
qtyInput.style.textAlign = 'center';
qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty;
qtyInput.min = 1;
qtyInput.onchange = (e) => {
const newQty = parseInt(e.target.value) || 1;
if (newQty <= 0) {
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = newQty;
renderCart();
saveCartToRedis(); // Сохраняем в Redis при изменении количества
}
};
// Кнопка плюс
const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-outline-secondary btn-sm';
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
plusBtn.onclick = (e) => {
e.preventDefault();
cart.get(cartKey).qty += 1;
renderCart();
saveCartToRedis();
};
// Собираем контейнер
qtyControl.appendChild(minusBtn);
qtyControl.appendChild(qtyInput);
qtyControl.appendChild(plusBtn);
// Сумма за позицию
const itemTotal = document.createElement('div');
itemTotal.className = 'item-total';
itemTotal.textContent = formatMoney(item.price * item.qty);
// Кнопка удаления
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-link text-danger p-0';
deleteBtn.innerHTML = '<i class="bi bi-x"></i>';
deleteBtn.onclick = () => removeFromCart(cartKey);
row.appendChild(namePrice);
row.appendChild(multiplySign);
row.appendChild(qtyControl);
row.appendChild(itemTotal);
row.appendChild(deleteBtn);
list.appendChild(row);
total += item.qty * item.price;
});
document.getElementById('cartTotal').textContent = formatMoney(total);
}
function removeFromCart(cartKey) {
cart.delete(cartKey);
renderCart();
saveCartToRedis(); // Сохраняем в Redis
}
function clearCart() {
cart.clear();
renderCart();
saveCartToRedis(); // Сохраняем пустую корзину в Redis
}
document.getElementById('clearCart').onclick = clearCart;
// Кнопка "На витрину" - функционал будет добавлен позже
document.getElementById('addToShowcaseBtn').onclick = () => {
openCreateTempKitModal();
};
// Функция открытия модального окна для создания временного комплекта
async function openCreateTempKitModal() {
// Проверяем что корзина не пуста
if (cart.size === 0) {
alert('Корзина пуста. Добавьте товары перед созданием комплекта.');
return;
}
// Проверяем что в корзине только товары (не комплекты)
let hasKits = false;
for (const [cartKey, item] of cart) {
if (item.type === 'kit') {
hasKits = true;
break;
}
}
if (hasKits) {
alert('В корзине есть комплекты. Для витрины добавляйте только отдельные товары.');
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'})}`;
document.getElementById('tempKitName').value = defaultName;
// Загружаем список витрин
await loadShowcases();
// Заполняем список товаров из tempCart
renderTempKitItems();
// Открываем модальное окно
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
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 {
const response = await fetch('/pos/api/showcase-kits/');
const data = await response.json();
if (data.success) {
showcaseKits = data.items;
} else {
console.error('Failed to refresh showcase kits:', data);
}
} catch (error) {
console.error('Error refreshing showcase kits:', error);
}
}
// Загрузка списка витрин
async function loadShowcases() {
try {
const response = await fetch('/pos/api/get-showcases/');
const data = await response.json();
const select = document.getElementById('showcaseSelect');
select.innerHTML = '<option value="">Выберите витрину...</option>';
if (data.success && data.showcases.length > 0) {
let defaultShowcaseId = null;
data.showcases.forEach(showcase => {
const option = document.createElement('option');
option.value = showcase.id;
option.textContent = `${showcase.name} (${showcase.warehouse_name})`;
select.appendChild(option);
// Запоминаем витрину склада по умолчанию
if (showcase.is_default_warehouse) {
defaultShowcaseId = showcase.id;
}
});
// Автовыбор витрины склада по умолчанию
if (defaultShowcaseId) {
select.value = defaultShowcaseId;
}
} else {
select.innerHTML = '<option value="">Нет доступных витрин</option>';
}
} catch (error) {
console.error('Error loading showcases:', error);
alert('Ошибка загрузки витрин');
}
}
// Отображение товаров из tempCart в модальном окне
function renderTempKitItems() {
const container = document.getElementById('tempKitItemsList');
container.innerHTML = '';
let estimatedTotal = 0;
tempCart.forEach((item, cartKey) => {
// Только товары (не комплекты)
if (item.type !== 'product') return;
const itemDiv = document.createElement('div');
itemDiv.className = 'd-flex justify-content-between align-items-center mb-1 pb-1 border-bottom';
itemDiv.innerHTML = `
<div>
<strong class="small">${item.name}</strong>
<br>
<small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small>
</div>
<div class="text-end">
<strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>
</div>
`;
container.appendChild(itemDiv);
estimatedTotal += item.qty * item.price;
});
// Обновляем все расчеты цен
updatePriceCalculations(estimatedTotal);
}
// Расчет и обновление всех цен
function updatePriceCalculations(basePrice = null) {
// Если basePrice не передан, пересчитываем из tempCart
if (basePrice === null) {
basePrice = 0;
tempCart.forEach((item, cartKey) => {
if (item.type === 'product') {
basePrice += item.qty * item.price;
}
});
}
// Базовая цена
document.getElementById('tempKitBasePrice').textContent = formatMoney(basePrice) + ' руб.';
// Корректировка
const adjustmentType = document.getElementById('priceAdjustmentType').value;
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0;
let calculatedPrice = basePrice;
if (adjustmentType !== 'none' && adjustmentValue > 0) {
if (adjustmentType === 'increase_percent') {
calculatedPrice = basePrice + (basePrice * adjustmentValue / 100);
} else if (adjustmentType === 'increase_amount') {
calculatedPrice = basePrice + adjustmentValue;
} else if (adjustmentType === 'decrease_percent') {
calculatedPrice = Math.max(0, basePrice - (basePrice * adjustmentValue / 100));
} else if (adjustmentType === 'decrease_amount') {
calculatedPrice = Math.max(0, basePrice - adjustmentValue);
}
}
document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.';
// Финальная цена (с учетом sale_price если задана)
const useSalePrice = document.getElementById('useSalePrice').checked;
const salePrice = parseFloat(document.getElementById('salePrice').value) || 0;
let finalPrice = calculatedPrice;
if (useSalePrice && salePrice > 0) {
finalPrice = salePrice;
}
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
}
// Обработчики для полей цены
document.getElementById('priceAdjustmentType').addEventListener('change', function() {
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
if (this.value === 'none') {
adjustmentBlock.style.display = 'none';
document.getElementById('priceAdjustmentValue').value = '0';
} else {
adjustmentBlock.style.display = 'block';
}
updatePriceCalculations();
});
document.getElementById('priceAdjustmentValue').addEventListener('input', function() {
updatePriceCalculations();
});
document.getElementById('useSalePrice').addEventListener('change', function() {
const salePriceBlock = document.getElementById('salePriceBlock');
if (this.checked) {
salePriceBlock.style.display = 'block';
} else {
salePriceBlock.style.display = 'none';
document.getElementById('salePrice').value = '';
}
updatePriceCalculations();
});
document.getElementById('salePrice').addEventListener('input', function() {
updatePriceCalculations();
});
// Обработчик загрузки фото
document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Пожалуйста, выберите файл изображения');
this.value = '';
return;
}
// Превью
const reader = new FileReader();
reader.onload = function(event) {
document.getElementById('photoPreviewImg').src = event.target.result;
document.getElementById('photoPreview').style.display = 'block';
};
reader.readAsDataURL(file);
}
});
// Удаление фото
document.getElementById('removePhoto').addEventListener('click', function() {
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('photoPreviewImg').src = '';
});
// Подтверждение создания/редактирования временного комплекта
document.getElementById('confirmCreateTempKit').onclick = async () => {
const kitName = document.getElementById('tempKitName').value.trim();
const showcaseId = document.getElementById('showcaseSelect').value;
const description = document.getElementById('tempKitDescription').value.trim();
const photoFile = document.getElementById('tempKitPhoto').files[0];
// Валидация
if (!kitName) {
alert('Введите название комплекта');
return;
}
if (!showcaseId && !isEditMode) {
alert('Выберите витрину');
return;
}
// Собираем товары из tempCart (изолированное состояние модалки)
const items = [];
tempCart.forEach((item, cartKey) => {
if (item.type === 'product') {
items.push({
product_id: item.id,
quantity: item.qty
});
}
});
if (items.length === 0) {
alert('Нет товаров для создания комплекта');
return;
}
// Получаем данные о ценах
const priceAdjustmentType = document.getElementById('priceAdjustmentType').value;
const priceAdjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0;
const useSalePrice = document.getElementById('useSalePrice').checked;
const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0;
// Формируем FormData для отправки с файлом
const formData = new FormData();
formData.append('kit_name', kitName);
if (showcaseId) {
formData.append('showcase_id', showcaseId);
}
formData.append('description', description);
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
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;
const url = isEditMode
? `/pos/api/product-kits/${editingKitId}/update/`
: '/pos/api/create-temp-kit/';
const actionText = isEditMode ? 'Сохранение...' : 'Создание...';
confirmBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${actionText}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken')
// Не указываем Content-Type - браузер сам установит multipart/form-data
},
body: formData
});
const data = await response.json();
if (data.success) {
// Успех!
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}`;
alert(successMessage);
// Очищаем tempCart (изолированное состояние модалки)
tempCart.clear();
// Сбрасываем поля формы
document.getElementById('tempKitDescription').value = '';
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('priceAdjustmentType').value = 'none';
document.getElementById('priceAdjustmentValue').value = '0';
document.getElementById('adjustmentValueBlock').style.display = 'none';
document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none';
// Сбрасываем режим редактирования
isEditMode = false;
editingKitId = null;
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
modal.hide();
// Обновляем витринные комплекты и переключаемся на вид витрины
isShowcaseView = true;
currentCategoryId = null;
await refreshShowcaseKits();
renderCategories();
renderProducts();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (error) {
console.error('Error saving kit:', error);
alert('Ошибка при сохранении комплекта');
} finally {
confirmBtn.disabled = false;
const btnText = isEditMode
? '<i class="bi bi-check-circle"></i> Сохранить изменения'
: '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
confirmBtn.innerHTML = btnText;
}
};
// Вспомогательная функция для получения CSRF токена
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
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 = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
}
});
// Открытие модалки "Продажа" и рендер сводки корзины
document.getElementById('checkoutNow').onclick = () => {
if (cart.size === 0) {
alert('Корзина пуста. Добавьте товары перед продажей.');
return;
}
renderCheckoutModal();
const modal = new bootstrap.Modal(document.getElementById('checkoutModal'));
modal.show();
};
// Рендер позиций корзины и итога в модалке продажи
function renderCheckoutModal() {
const container = document.getElementById('checkoutItems');
container.innerHTML = '';
let total = 0;
cart.forEach((item) => {
const row = document.createElement('div');
row.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom';
// Иконка для комплектов
let typeIcon = '';
if (item.type === 'kit' || item.type === 'showcase_kit') {
typeIcon = '<i class="bi bi-box-seam text-info me-1" title="Комплект"></i>';
} else {
typeIcon = '<i class="bi bi-box text-success me-1" title="Товар"></i>';
}
row.innerHTML = `
<div>
<div class="fw-semibold">${typeIcon}${item.name}</div>
<small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small>
</div>
<div class="fw-bold text-end">${formatMoney(item.qty * item.price)} руб.</div>
`;
container.appendChild(row);
total += item.qty * item.price;
});
// Обновляем информацию о клиенте
updateCustomerDisplay();
// Обновляем базовую цену и пересчитываем
updateCheckoutPricing(total);
}
// Пересчёт цен в модалке продажи
function updateCheckoutPricing(basePrice = null) {
// Если basePrice не передан, пересчитываем из корзины
if (basePrice === null) {
basePrice = 0;
cart.forEach((item) => {
basePrice += item.qty * item.price;
});
}
// Базовая сумма
document.getElementById('checkoutBasePrice').textContent = formatMoney(basePrice) + ' руб.';
// Скидка
const discountType = document.getElementById('discountType').value;
const discountValue = parseFloat(document.getElementById('discountValue').value) || 0;
let discountedPrice = basePrice;
if (discountType !== 'none' && discountValue > 0) {
if (discountType === 'percent') {
discountedPrice = basePrice - (basePrice * discountValue / 100);
} else if (discountType === 'amount') {
discountedPrice = Math.max(0, basePrice - discountValue);
}
}
document.getElementById('checkoutDiscountedPrice').textContent = formatMoney(discountedPrice) + ' руб.';
// Финальная цена (с учётом ручной суммы если задана)
const useManualPrice = document.getElementById('useManualPrice').checked;
const manualPrice = parseFloat(document.getElementById('manualPrice').value) || 0;
let finalPrice = discountedPrice;
if (useManualPrice && manualPrice > 0) {
finalPrice = manualPrice;
}
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalPrice);
}
// Обработчики для полей скидки и цены
document.getElementById('discountType').addEventListener('change', function() {
const discountBlock = document.getElementById('discountValueBlock');
if (this.value === 'none') {
discountBlock.style.display = 'none';
document.getElementById('discountValue').value = '0';
} else {
discountBlock.style.display = 'block';
}
updateCheckoutPricing();
});
document.getElementById('discountValue').addEventListener('input', function() {
updateCheckoutPricing();
});
document.getElementById('useManualPrice').addEventListener('change', function() {
const manualPriceBlock = document.getElementById('manualPriceBlock');
if (this.checked) {
manualPriceBlock.style.display = 'block';
} else {
manualPriceBlock.style.display = 'none';
document.getElementById('manualPrice').value = '';
}
updateCheckoutPricing();
});
document.getElementById('manualPrice').addEventListener('input', function() {
updateCheckoutPricing();
});
// Подтверждение продажи (пока заглушка)
document.getElementById('confirmCheckoutBtn').onclick = () => {
const payment = document.getElementById('paymentMethod').value;
const note = document.getElementById('orderNote').value.trim();
const paymentText = {
'cash': 'Наличные',
'card': 'Карта',
'mixed': 'Смешанная оплата'
}[payment] || payment;
// Получаем данные о ценах и скидке
const basePrice = document.getElementById('checkoutBasePrice').textContent;
const discountType = document.getElementById('discountType').value;
const discountValue = document.getElementById('discountValue').value;
const finalPrice = document.getElementById('checkoutFinalPrice').textContent;
let discountInfo = 'нет скидки';
if (discountType === 'percent' && discountValue > 0) {
discountInfo = `скидка ${discountValue}%`;
} else if (discountType === 'amount' && discountValue > 0) {
discountInfo = `скидка ${discountValue} руб.`;
}
const useManual = document.getElementById('useManualPrice').checked;
if (useManual) {
discountInfo += ' (установлена своя сумма)';
}
alert(`Функция проведения продажи будет подключена позже.
Базовая сумма: ${basePrice}
Скидка: ${discountInfo}
Итого к оплате: ${finalPrice} руб.
Оплата: ${paymentText}
Комментарий: ${note || '—'}`);
const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal'));
modal.hide();
};
document.getElementById('scheduleLater').onclick = async () => {
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
};
// ===== ОБРАБОТЧИКИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
// Кнопка "Выбрать клиента" в корзине
document.getElementById('customerSelectBtn').addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('selectCustomerModal'));
modal.show();
});
// Кнопка сброса клиента на системного
document.getElementById('resetCustomerBtn').addEventListener('click', () => {
selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name);
});
// Кнопка "Создать нового клиента" в модалке выбора
document.getElementById('createNewCustomerBtn').addEventListener('click', () => {
// Закрываем модалку выбора
const selectModal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
selectModal.hide();
// Открываем модалку создания
openCreateCustomerModal();
});
// Кнопка "Выбрать системного клиента"
document.getElementById('selectSystemCustomerBtn').addEventListener('click', () => {
selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name);
// Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
modal.hide();
});
// Кнопка подтверждения создания клиента
document.getElementById('confirmCreateCustomerBtn').addEventListener('click', () => {
createNewCustomer();
});
// Инициализация Select2 при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
initCustomerSelect2();
updateCustomerDisplay(); // Обновляем UI с системным клиентом
// Восстанавливаем корзину из Redis (если есть сохраненные данные)
const savedCartData = JSON.parse(document.getElementById('cartData').textContent);
if (savedCartData && Object.keys(savedCartData).length > 0) {
// Конвертируем обычный объект обратно в Map
Object.entries(savedCartData).forEach(([key, value]) => {
cart.set(key, value);
});
renderCart(); // Отрисовываем восстановленную корзину
}
});
// Смена склада
const changeWarehouseBtn = document.getElementById('changeWarehouseBtn');
if (changeWarehouseBtn) {
changeWarehouseBtn.addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('selectWarehouseModal'));
modal.show();
});
}
// Обработка выбора склада из списка
document.addEventListener('click', async (e) => {
const warehouseItem = e.target.closest('.warehouse-item');
if (!warehouseItem) return;
const warehouseId = warehouseItem.dataset.warehouseId;
const warehouseName = warehouseItem.dataset.warehouseName;
// Проверяем, есть ли товары в корзине
if (cart.size > 0) {
const confirmed = confirm(`При смене склада корзина будет очищена.\n\nПереключиться на склад "${warehouseName}"?`);
if (!confirmed) return;
}
try {
// Отправляем запрос на смену склада
const response = await fetch(`/pos/api/set-warehouse/${warehouseId}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Перезагружаем страницу для обновления данных
location.reload();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (error) {
console.error('Ошибка при смене склада:', error);
alert('Произошла ошибка при смене склада');
}
});
// Вспомогательная функция для получения CSRF токена
function getCsrfToken() {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Обработчик поиска с debounce
const searchInput = document.getElementById('searchInput');
const clearSearchBtn = document.getElementById('clearSearchBtn');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Показываем/скрываем кнопку очистки
if (e.target.value.length > 0) {
clearSearchBtn.style.display = 'block';
} else {
clearSearchBtn.style.display = 'none';
}
// Отменяем предыдущий таймер
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
// Если поле пустое — очищаем экран
if (query === '') {
currentSearchQuery = '';
ITEMS = []; // Очистка
renderProducts(); // Пустой экран
return;
}
// Минимальная длина поиска — 3 символа
if (query.length < 3) {
// Не реагируем на ввод менее 3 символов
return;
}
// Для витрины — мгновенная клиентская фильтрация
if (isShowcaseView) {
renderProducts();
return;
}
// Для обычных товаров/комплектов — серверный поиск с debounce 300мс
searchDebounceTimer = setTimeout(async () => {
currentSearchQuery = query;
await loadItems(); // Перезагрузка с серверным поиском
}, 300);
});
// Обработчик кнопки очистки поиска
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
currentSearchQuery = '';
ITEMS = [];
renderProducts(); // Пустой экран
});
// Инициализация
renderCategories();
renderProducts(); // Сначала пустая сетка
renderCart();
setupInfiniteScroll(); // Установка infinite scroll
// Установить фокус на строку поиска
document.getElementById('searchInput').focus();