Files
octopus/myproject/pos/static/pos/js/terminal.js
Andrey Smakotin 2778796118 feat(pos): фиксировать цены товаров в витринных комплектах
- Добавлено поле KitItem.unit_price для хранения зафиксированной цены
- Витринные комплекты больше не обновляются при изменении цен товаров
- Добавлен красный индикатор на карточке если цена неактуальна
- Добавлен warning в модалке редактирования с кнопкой "Актуализировать"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:59:44 +03:00

3766 lines
141 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/**
* Округляет число до N знаков после запятой для корректного отображения.
* Решает проблему погрешности float arithmetic в JavaScript.
* @param {number} value - Число для округления
* @param {number} decimals - Количество знаков после запятой (по умолчанию 3)
* @returns {number} Округлённое число
*/
function roundQuantity(value, decimals = 3) {
if (value === null || value === undefined || isNaN(value)) return 0;
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
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();
// Экспорт корзины для использования в других модулях
window.cart = cart;
// Переменные для пагинации
let currentPage = 1;
let hasMoreItems = false;
let isLoadingItems = false;
let currentSearchQuery = ''; // Текущий поисковый запрос
let searchDebounceTimer = null;
// Переменные для режима редактирования
let isEditMode = false;
let editingKitId = null;
// Временная корзина для модального окна создания/редактирования комплекта
const tempCart = new Map();
// ===== ПЕРЕМЕННЫЕ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
let unitModalProduct = null; // Текущий товар для модального окна
let unitModalSalesUnits = []; // Список единиц продажи
let selectedSalesUnit = null; // Выбранная единица продажи
let unitModalInstance = null; // Bootstrap Modal instance
// ===== СОХРАНЕНИЕ КОРЗИНЫ В 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);
// Текущий склад
const currentWarehouse = JSON.parse(document.getElementById('currentWarehouseData').textContent);
function formatMoney(v) {
return (Number(v)).toFixed(2);
}
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
/**
* Обновляет отображение выбранного клиента в UI
* Обновляет:
* - Кнопку "Выбрать клиента" в корзине (показывает имя клиента)
* - Кнопку "Выбрать клиента" в модалке продажи (показывает имя клиента)
* - Видимость кнопок сброса в обоих местах (показываем только для не-системного клиента)
* - Ссылку на анкету клиента (показываем только для не-системного клиента)
* - Баланс кошелька в модальном окне продажи (если оно открыто)
*/
function updateCustomerDisplay() {
// Определяем, системный ли это клиент
const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id);
const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name;
// Обновляем текст кнопки в корзине
const btnText = document.getElementById('customerSelectBtnText');
if (btnText) {
btnText.textContent = displayName;
}
// Обновляем текст кнопки в модалке продажи
const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText');
if (checkoutBtnText) {
checkoutBtnText.textContent = displayName;
}
// Обновляем видимость кнопок сброса (в корзине и в модалке продажи)
[document.getElementById('resetCustomerBtn'),
document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => {
if (resetBtn) {
resetBtn.style.display = isSystemCustomer ? 'none' : 'block';
}
});
// Обновляем ссылку на анкету клиента
const profileLink = document.getElementById('customerProfileLink');
if (profileLink) {
if (isSystemCustomer) {
profileLink.style.display = 'none';
} else {
profileLink.href = `/customers/${selectedCustomer.id}/`;
profileLink.style.display = 'block';
}
}
// Обновляем баланс кошелька в модальном окне продажи (если оно открыто)
updateCheckoutWalletBalance();
}
/**
* Обновляет баланс кошелька клиента в модальном окне продажи
*/
function updateCheckoutWalletBalance() {
const walletDiv = document.getElementById('checkoutWalletBalance');
if (!walletDiv) return; // Модалка еще не инициализирована
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const walletBalance = customer.wallet_balance || 0;
const isSystemCustomer = Number(customer.id) === Number(SYSTEM_CUSTOMER.id);
if (!isSystemCustomer) {
document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2);
walletDiv.style.display = 'block';
} else {
walletDiv.style.display = 'none';
}
// Переинициализируем виджет оплаты, если модалка открыта
updatePaymentWidgetCustomer();
}
/**
* Обновляет данные клиента в виджете оплаты (если он инициализирован)
*/
function updatePaymentWidgetCustomer() {
// Проверяем, открыта ли модалка checkout и инициализирован ли виджет
const checkoutModal = document.getElementById('checkoutModal');
if (!checkoutModal || !checkoutModal.classList.contains('show')) return;
if (!paymentWidget) return;
// Получаем текущий режим оплаты
const isMixedMode = document.getElementById('mixedPaymentMode').classList.contains('active');
const mode = isMixedMode ? 'mixed' : 'single';
// Переинициализируем виджет с новыми данными клиента
reinitPaymentWidget(mode);
}
/**
* Устанавливает нового клиента и сохраняет в Redis
* @param {number} customerId - ID клиента
* @param {string} customerName - Имя клиента
* @param {number} walletBalance - Баланс кошелька клиента (опционально)
*/
function selectCustomer(customerId, customerName, walletBalance = 0) {
selectedCustomer = {
id: customerId,
name: customerName,
wallet_balance: walletBalance
};
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);
} else {
// Обновляем баланс из ответа сервера
selectedCustomer.wallet_balance = data.wallet_balance || 0;
}
})
.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, data.wallet_balance || 0);
// Закрываем модалку
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, data.wallet_balance || 0);
// Закрываем модалку
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');
}
}
// ===== ФУНКЦИИ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
/**
* Открывает модальное окно выбора единицы продажи
* @param {object} product - Объект товара с информацией о единицах продажи
*/
async function openProductUnitModal(product) {
unitModalProduct = product;
// Устанавливаем название товара
document.getElementById('unitModalProductName').textContent =
`${product.name}${product.sku ? ' (' + product.sku + ')' : ''}`;
// Загружаем единицы продажи
try {
const response = await fetch(
`/products/api/products/${product.id}/sales-units/?warehouse=${currentWarehouse.id}`
);
const data = await response.json();
if (!data.success || !data.sales_units || data.sales_units.length === 0) {
alert('Не удалось загрузить единицы продажи');
return;
}
unitModalSalesUnits = data.sales_units;
// Отрисовываем список единиц
renderUnitSelectionList();
// Выбираем единицу по умолчанию или первую
const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0];
if (defaultUnit) {
selectUnit(defaultUnit);
}
// Открываем модальное окно
if (!unitModalInstance) {
unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal'));
}
unitModalInstance.show();
} catch (error) {
console.error('Ошибка загрузки единиц продажи:', error);
alert('Ошибка загрузки данных. Попробуйте ещё раз.');
}
}
/**
* Отрисовывает список единиц продажи
*/
function renderUnitSelectionList() {
const listContainer = document.getElementById('unitSelectionList');
listContainer.innerHTML = '';
unitModalSalesUnits.forEach(unit => {
const card = document.createElement('div');
card.className = 'unit-selection-card';
card.dataset.unitId = unit.id;
card.onclick = () => selectUnit(unit);
// Доступное количество
const availableQty = parseFloat(unit.available_quantity || 0);
let stockBadgeClass = 'stock-badge-none';
let stockText = 'Нет на складе';
if (availableQty > 10) {
stockBadgeClass = 'stock-badge-good';
stockText = `${availableQty} шт доступно`;
} else if (availableQty > 0) {
stockBadgeClass = 'stock-badge-low';
stockText = `${availableQty} шт доступно`;
}
// Бейдж "По умолчанию"
const defaultBadge = unit.is_default ?
'<span class="badge bg-primary ms-2">По умолчанию</span>' : '';
card.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="unit-name">${unit.name}${defaultBadge}</div>
</div>
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
</div>
<div class="mt-2">
<span class="badge ${stockBadgeClass}">${stockText}</span>
</div>
`;
listContainer.appendChild(card);
});
}
/**
* Выбирает единицу продажи
* @param {object} unit - Объект единицы продажи
*/
function selectUnit(unit) {
selectedSalesUnit = unit;
// Обновляем визуальное выделение
document.querySelectorAll('.unit-selection-card').forEach(card => {
if (card.dataset.unitId === String(unit.id)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
// Обновляем отображение выбранной единицы
document.getElementById('selectedUnitDisplay').textContent =
unit.name;
// Устанавливаем минимальное количество и шаг
const qtyInput = document.getElementById('unitModalQuantity');
qtyInput.value = roundQuantity(unit.min_quantity, 3);
qtyInput.min = unit.min_quantity;
qtyInput.step = unit.quantity_step;
// Устанавливаем цену
document.getElementById('unitModalPrice').value = unit.actual_price;
// Обновляем подсказку
const hintEl = document.getElementById('unitQtyHint');
hintEl.textContent = `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`;
// Сбрасываем индикатор изменения цены
document.getElementById('priceOverrideIndicator').style.display = 'none';
// Пересчитываем итого
calculateUnitModalSubtotal();
// Валидируем количество
validateUnitQuantity();
}
/**
* Проверяет количество на соответствие ограничениям
* @returns {boolean} - true если валидно
*/
function validateUnitQuantity() {
if (!selectedSalesUnit) return false;
const qtyInput = document.getElementById('unitModalQuantity');
const qty = parseFloat(qtyInput.value);
const errorEl = document.getElementById('unitQtyError');
const confirmBtn = document.getElementById('confirmAddUnitToCart');
// Проверка минимального количества
if (qty < parseFloat(selectedSalesUnit.min_quantity)) {
errorEl.textContent = `Минимальное количество: ${selectedSalesUnit.min_quantity}`;
errorEl.style.display = 'block';
confirmBtn.disabled = true;
return false;
}
// Проверка шага (с учётом погрешности)
const step = parseFloat(selectedSalesUnit.quantity_step);
const minQty = parseFloat(selectedSalesUnit.min_quantity);
const diff = qty - minQty;
const remainder = diff % step;
const epsilon = 0.0001;
if (remainder > epsilon && (step - remainder) > epsilon) {
errorEl.textContent = `Количество должно быть кратно ${step}`;
errorEl.style.display = 'block';
confirmBtn.disabled = true;
return false;
}
// Всё ок, скрываем ошибку
errorEl.style.display = 'none';
confirmBtn.disabled = false;
return true;
}
/**
* Рассчитывает итоговую сумму
*/
function calculateUnitModalSubtotal() {
const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value) || 0;
const qty = roundQuantity(qtyRaw, 3); // Округляем количество
const price = parseFloat(document.getElementById('unitModalPrice').value) || 0;
// Округляем до 2 знаков после запятой для корректного отображения
const subtotal = Math.round(qty * price * 100) / 100;
document.getElementById('unitModalSubtotal').textContent = `${formatMoney(subtotal)} руб`;
// Проверяем изменение цены
if (selectedSalesUnit && Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01) {
document.getElementById('priceOverrideIndicator').style.display = 'block';
} else {
document.getElementById('priceOverrideIndicator').style.display = 'none';
}
}
/**
* Добавляет товар с выбранной единицей в корзину
*/
function addToCartFromModal() {
if (!validateUnitQuantity()) {
return;
}
const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value);
const qty = roundQuantity(qtyRaw, 3); // Округляем количество
const price = parseFloat(document.getElementById('unitModalPrice').value);
const priceOverridden = Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01;
// Формируем ключ корзины: product-{id}-{sales_unit_id}
const cartKey = `product-${unitModalProduct.id}-${selectedSalesUnit.id}`;
// Добавляем или обновляем в корзине
if (cart.has(cartKey)) {
const existing = cart.get(cartKey);
existing.qty = roundQuantity(existing.qty + qty, 3); // Округляем сумму
existing.price = price; // Обновляем цену
existing.quantity_step = parseFloat(selectedSalesUnit.quantity_step) || 1; // Обновляем шаг
existing.price_overridden = priceOverridden;
} else {
cart.set(cartKey, {
id: unitModalProduct.id,
name: unitModalProduct.name,
price: price,
qty: qty,
type: 'product',
sales_unit_id: selectedSalesUnit.id,
unit_name: selectedSalesUnit.name,
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
price_overridden: priceOverridden
});
}
// Обновляем корзину
renderCart();
saveCartToRedis();
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView) {
renderProducts();
}
// Закрываем модальное окно
unitModalInstance.hide();
}
/**
* Добавляет товар с единицей продажи напрямую в корзину (без модального окна)
* Используется для быстрого добавления когда у товара только одна единица продажи
* @param {object} product - Товар из ITEMS
* @param {object} salesUnit - Единица продажи (default_sales_unit)
* @param {number} qty - Количество для добавления
*/
async function addProductWithUnitToCart(product, salesUnit, qty = 1) {
const cartKey = `product-${product.id}-${salesUnit.id}`;
if (cart.has(cartKey)) {
const existing = cart.get(cartKey);
existing.qty = roundQuantity(existing.qty + qty, 3);
} else {
cart.set(cartKey, {
id: product.id,
name: product.name,
price: Number(salesUnit.price),
qty: qty,
type: 'product',
sales_unit_id: salesUnit.id,
unit_name: salesUnit.name,
quantity_step: parseFloat(salesUnit.quantity_step) || 1
});
}
renderCart();
saveCartToRedis();
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView) {
renderProducts();
}
// Фокус на поле количества
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
if (!isMobileDevice()) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select();
}
}
}, 50);
}
function renderCategories() {
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
// Кнопка "Витрина" - первая в ряду
const showcaseCol = document.createElement('div');
showcaseCol.className = 'col-6 col-custom-3 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-custom-3 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-custom-3 col-md-3 col-lg-custom-5';
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) {
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
filtered = filtered.filter(item => {
const name = (item.name || '').toLowerCase();
const sku = (item.sku || '').toLowerCase();
// Каждый токен должен совпадать хотя бы с одним словом в name или sku
return tokens.every(token => name.includes(token) || sku.includes(token));
});
}
} else {
// Обычный режим - ITEMS уже отфильтрованы на сервере (категория + поиск)
filtered = ITEMS;
}
filtered.forEach(item => {
const col = document.createElement('div');
col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5';
const card = document.createElement('div');
card.className = 'card product-card';
card.style.position = 'relative';
card.onclick = () => addToCart(item);
// Если это витринный комплект - добавляем кнопку редактирования
if (item.type === 'showcase_kit') {
// ИНДИКАЦИЯ БЛОКИРОВКИ
if (item.is_locked) {
// Создаем бейдж блокировки
const lockBadge = document.createElement('div');
lockBadge.style.position = 'absolute';
lockBadge.style.top = '5px';
lockBadge.style.left = '5px';
lockBadge.style.zIndex = '10';
if (item.locked_by_me) {
// Заблокирован мной - зеленый бейдж
lockBadge.className = 'badge bg-success';
lockBadge.innerHTML = '<i class="bi bi-cart-check"></i> В корзине';
lockBadge.title = 'Добавлен в вашу корзину';
} else {
// Заблокирован другим кассиром - красный бейдж + блокируем карточку
lockBadge.className = 'badge bg-danger';
lockBadge.innerHTML = '<i class="bi bi-lock-fill"></i> Занят';
lockBadge.title = `В корзине ${item.locked_by_user}`;
// Затемняем карточку и блокируем клики
card.style.opacity = '0.5';
card.style.cursor = 'not-allowed';
card.onclick = (e) => {
e.stopPropagation();
alert(`Этот букет уже в корзине кассира "${item.locked_by_user}".\nДождитесь освобождения блокировки.`);
};
}
card.appendChild(lockBadge);
}
// Кнопка редактирования (только если НЕ заблокирован другим)
if (!item.is_locked || item.locked_by_me) {
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);
// Индикатор неактуальной цены (красный кружок)
if (item.price_outdated) {
const outdatedBadge = document.createElement('div');
outdatedBadge.className = 'badge bg-danger';
outdatedBadge.style.position = 'absolute';
outdatedBadge.style.top = '5px';
outdatedBadge.style.right = '45px';
outdatedBadge.style.zIndex = '10';
outdatedBadge.style.width = '18px';
outdatedBadge.style.height = '18px';
outdatedBadge.style.padding = '0';
outdatedBadge.style.borderRadius = '50%';
outdatedBadge.style.display = 'flex';
outdatedBadge.style.alignItems = 'center';
outdatedBadge.style.justifyContent = 'center';
outdatedBadge.style.fontSize = '10px';
outdatedBadge.style.minWidth = '18px';
outdatedBadge.title = 'Цена неактуальна';
outdatedBadge.innerHTML = '!';
card.appendChild(outdatedBadge);
}
}
}
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') {
const availableCount = item.available_count || 0;
const totalCount = item.total_count || availableCount;
const inCart = totalCount - availableCount;
// Показываем: доступно / всего (и сколько в корзине)
let badgeClass = availableCount > 0 ? 'bg-success' : 'bg-secondary';
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge ${badgeClass} ms-1">${badgeText}</span>${cartInfo}`;
stock.style.color = '#856404';
stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
// Для обычных товаров показываем остатки
// Если у товара есть единицы продажи - отображаем в единицах продажи
const available = parseFloat(item.available_qty) || 0;
const reserved = parseFloat(item.reserved_qty) || 0;
// Используем единицу продажи если есть
if (item.default_sales_unit) {
const unit = item.default_sales_unit;
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
// Вычисляем количество в корзине в единицах продажи
const cartKey = `product-${item.id}-${unit.id}`;
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
// Свободное количество в единицах продажи
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
const reservedInUnit = reserved * conversionFactor;
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения
// Создаём элементы для стилизации
const freeSpan = document.createElement('span');
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
const qtyText = document.createElement('span');
qtyText.textContent = freeRounded;
freeSpan.appendChild(qtyText);
const unitBadge = document.createElement('span');
unitBadge.className = 'badge bg-secondary ms-1';
unitBadge.style.fontSize = '0.7rem';
unitBadge.textContent = unit.name;
freeSpan.appendChild(unitBadge);
// Отображаем корзину если есть
if (inCartBaseQty > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = ` (${roundQuantity(inCartBaseQty, 1)}🛒)`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
stock.appendChild(freeSpan);
stock.appendChild(suffixSpan);
} else {
stock.appendChild(freeSpan);
}
// Цветовая индикация
if (freeInUnit <= 0) {
stock.style.color = '#dc3545'; // Красный
} else if (freeInUnit < 5) {
stock.style.color = '#ffc107'; // Жёлтый
} else {
stock.style.color = '#28a745'; // Зелёный
}
} else {
// Отображение в базовых единицах (старая логика)
const cartKey = `product-${item.id}`;
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
const free = available - reserved - inCart;
const freeRounded = roundQuantity(free, 3);
const freeSpan = document.createElement('span');
freeSpan.textContent = freeRounded;
freeSpan.style.fontSize = '1.1em';
freeSpan.style.fontWeight = 'bold';
const suffixParts = [];
if (reserved > 0) {
suffixParts.push(`${roundQuantity(reserved, 3)}`);
}
if (inCart > 0) {
suffixParts.push(`${roundQuantity(inCart, 3)}🛒`);
}
if (suffixParts.length > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
stock.appendChild(freeSpan);
stock.appendChild(suffixSpan);
} 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 {
// Комплекты: показываем доступное количество
if (item.type === 'kit' && item.free_qty !== undefined) {
const availableKits = parseFloat(item.free_qty) || 0;
if (availableKits > 0) {
stock.textContent = `В наличии: ${Math.floor(availableKits)} компл.`;
stock.style.color = '#28a745'; // Зелёный
} else {
stock.textContent = 'Под заказ';
stock.style.color = '#dc3545'; // Красный
}
} 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';
// Используем цену из единицы продажи если есть, иначе базовую цену
const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price;
priceSpan.textContent = `${formatMoney(itemPrice)}`;
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);
}
async function addToCart(item) {
// ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ
// Если у товара одна единица продажи - добавляем сразу
// Если несколько - показываем модальное окно выбора
console.log('addToCart:', item.name, 'has_sales_units:', item.has_sales_units, 'sales_units_count:', item.sales_units_count);
if (item.type === 'product' && item.has_sales_units) {
// Если одна единица продажи - добавляем сразу
if (item.sales_units_count === 1 && item.default_sales_unit) {
await addProductWithUnitToCart(item, item.default_sales_unit, 1);
return;
}
// Иначе открываем модальное окно выбора единицы
await openProductUnitModal(item);
return;
}
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
if (item.type === 'showcase_kit') {
// Пытаемся заблокировать 1 экземпляр через API
// API сам проверит доступность и вернёт ошибку если нет свободных
try {
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: 1 })
});
const data = await response.json();
if (!response.ok || !data.success) {
// Нет доступных экземпляров или другая ошибка
alert(data.error || 'Не удалось добавить букет в корзину');
// Обновляем витрину чтобы показать актуальное состояние
if (isShowcaseView) {
await loadShowcaseKits();
}
return;
}
// Успешно заблокировали - добавляем/обновляем в корзине
const lockedItemIds = data.locked_item_ids || [];
if (cart.has(cartKey)) {
// Добавляем к существующим
const existing = cart.get(cartKey);
existing.qty += lockedItemIds.length;
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
} else {
// Создаём новую запись
cart.set(cartKey, {
id: item.id,
name: item.name,
price: Number(item.price),
qty: lockedItemIds.length,
type: item.type,
showcase_item_ids: lockedItemIds,
lock_expires_at: data.lock_expires_at
});
}
// Обновляем список витрины (чтобы показать актуальные available_count)
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при добавлении витринного комплекта:', error);
alert('Ошибка сервера. Попробуйте еще раз.');
return;
}
} else {
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
if (!cart.has(cartKey)) {
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
} else {
const cartItem = cart.get(cartKey);
cartItem.qty = roundQuantity(cartItem.qty + 1, 3);
}
}
renderCart();
saveCartToRedis(); // Сохраняем в Redis
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item.type === 'product') {
renderProducts();
}
// Автоматический фокус на поле количества (только для обычных товаров)
if (item.type !== 'showcase_kit') {
setTimeout(() => {
const qtyInputs = document.querySelectorAll('.qty-input');
const itemIndex = Array.from(cart.keys()).indexOf(cartKey);
if (itemIndex !== -1 && qtyInputs[itemIndex]) {
// Устанавливаем фокус только на десктопных устройствах
if (!isMobileDevice()) {
qtyInputs[itemIndex].focus();
qtyInputs[itemIndex].select(); // Выделяем весь текст
}
}
}, 50);
}
}
// Вспомогательная функция для обновления количества товара в корзине
async function updateCartItemQty(cartKey, newQty) {
const item = cart.get(cartKey);
if (!item) return;
// Округляем новое количество
const roundedQty = roundQuantity(newQty, 3);
if (roundedQty <= 0) {
await removeFromCart(cartKey);
} else {
item.qty = roundedQty;
renderCart();
saveCartToRedis();
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item.type === 'product') {
renderProducts();
}
}
}
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';
updateShowcaseButtonState(); // Обновляем состояние кнопки
updateMobileCartBar(); // Обновляем мобильный бар даже когда корзина пуста
return;
}
cart.forEach((item, cartKey) => {
const row = document.createElement('div');
row.className = 'cart-item mb-2';
row.style.cursor = 'pointer';
row.title = 'Нажмите для редактирования';
// Индикатор изменённой цены
if (item.price_overridden) {
row.classList.add('price-overridden');
}
// СПЕЦИАЛЬНАЯ СТИЛИЗАЦИЯ для витринных комплектов
const isShowcaseKit = item.type === 'showcase_kit';
if (isShowcaseKit) {
row.style.backgroundColor = '#fff3cd'; // Желтый фон
row.style.border = '1px solid #ffc107';
row.style.borderRadius = '4px';
row.style.padding = '8px';
}
// Левая часть: Название и цена единицы
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> ';
}
// Единица продажи (если есть)
let unitInfo = '';
if (item.sales_unit_id && item.unit_name) {
unitInfo = ` <span class="badge bg-secondary" style="font-size: 0.7rem;">${item.unit_name}</span>`;
}
namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}</div>
<div class="price-unit-row">
<span class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)}</span>${unitInfo}
</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';
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов
if (isShowcaseKit) {
// Кнопка минус
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 = async (e) => {
e.preventDefault();
await decreaseShowcaseKitQty(cartKey);
};
// Поле количества (только для отображения, readonly)
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 = roundQuantity(item.qty, 3);
qtyInput.min = 1;
qtyInput.readOnly = true; // Только чтение - изменяем только через +/-
qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных
// Кнопка плюс
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 = async (e) => {
e.preventDefault();
await increaseShowcaseKitQty(cartKey);
};
// Собираем контейнер
qtyControl.appendChild(minusBtn);
qtyControl.appendChild(qtyInput);
qtyControl.appendChild(plusBtn);
} else {
// ОБЫЧНАЯ ЛОГИКА для товаров и комплектов
// Кнопка минус
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 = async (e) => {
e.preventDefault();
const currentQty = cart.get(cartKey).qty;
const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию
await updateCartItemQty(cartKey, roundQuantity(currentQty - step, 3));
};
// Поле ввода количества
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 = roundQuantity(item.qty, 3);
qtyInput.min = 1;
qtyInput.step = item.quantity_step || 0.001; // Устанавливаем шаг единицы продажи
qtyInput.onchange = async (e) => {
const newQty = parseFloat(e.target.value) || 1;
await updateCartItemQty(cartKey, newQty);
};
// Округление при потере фокуса
qtyInput.onblur = (e) => {
const rawValue = parseFloat(e.target.value) || 1;
e.target.value = roundQuantity(rawValue, 3);
};
// Кнопка плюс
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 = async (e) => {
e.preventDefault();
const currentQty = cart.get(cartKey).qty;
const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию
await updateCartItemQty(cartKey, roundQuantity(currentQty + step, 3));
};
// Собираем контейнер
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);
// Обработчик клика для редактирования товара
row.addEventListener('click', function(e) {
// Игнорируем клики на кнопки управления количеством и удаления
if (e.target.closest('button') || e.target.closest('input')) {
return;
}
console.log('Cart row clicked, cartKey:', cartKey, 'CartItemEditor:', typeof window.CartItemEditor);
if (window.CartItemEditor) {
window.CartItemEditor.openModal(cartKey);
} else {
console.error('CartItemEditor not available!');
}
});
list.appendChild(row);
total += item.qty * item.price;
});
document.getElementById('cartTotal').textContent = formatMoney(total);
// Обновляем состояние кнопки "НА ВИТРИНУ"
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) {
const item = cart.get(cartKey);
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
if (item && item.type === 'showcase_kit') {
try {
// Передаём конкретные showcase_item_ids для снятия блокировки
const body = {};
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
body.showcase_item_ids = item.showcase_item_ids;
}
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
console.error('Ошибка при снятии блокировки:', data.error);
// Продолжаем удаление из корзины даже при ошибке
}
// Обновляем список витрины (чтобы убрать индикацию блокировки)
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
// Продолжаем удаление из корзины
}
}
cart.delete(cartKey);
renderCart();
saveCartToRedis(); // Сохраняем в Redis
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item && item.type === 'product') {
renderProducts();
}
}
/**
* Увеличивает количество витринного комплекта в корзине
* Добавляет еще один экземпляр через API (если есть доступные)
*/
async function increaseShowcaseKitQty(cartKey) {
const item = cart.get(cartKey);
if (!item || item.type !== 'showcase_kit') return;
try {
// Пытаемся заблокировать еще 1 экземпляр
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: 1 })
});
const data = await response.json();
if (!response.ok || !data.success) {
// Нет доступных экземпляров
alert(data.error || 'Нет доступных экземпляров этого букета на витрине');
return;
}
// Успешно заблокировали - обновляем корзину
const lockedItemIds = data.locked_item_ids || [];
item.qty += lockedItemIds.length;
item.showcase_item_ids = [...(item.showcase_item_ids || []), ...lockedItemIds];
renderCart();
saveCartToRedis();
// Обновляем список витрины
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при увеличении количества витринного комплекта:', error);
alert('Ошибка сервера. Попробуйте еще раз.');
}
}
/**
* Уменьшает количество витринного комплекта в корзине
* Снимает блокировку с одного экземпляра (последнего в списке)
*/
async function decreaseShowcaseKitQty(cartKey) {
const item = cart.get(cartKey);
if (!item || item.type !== 'showcase_kit') return;
// Если количество = 1, удаляем полностью
if (item.qty <= 1) {
await removeFromCart(cartKey);
return;
}
try {
// Снимаем блокировку с последнего экземпляра
const showcaseItemIds = item.showcase_item_ids || [];
if (showcaseItemIds.length === 0) {
// Нет ID - просто удаляем
await removeFromCart(cartKey);
return;
}
// Берем последний ID из списка
const itemIdToRelease = showcaseItemIds[showcaseItemIds.length - 1];
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ showcase_item_ids: [itemIdToRelease] })
});
const data = await response.json();
if (!response.ok) {
console.error('Ошибка при снятии блокировки:', data.error);
}
// Обновляем корзину
item.qty -= 1;
item.showcase_item_ids = showcaseItemIds.filter(id => id !== itemIdToRelease);
renderCart();
saveCartToRedis();
// Обновляем список витрины
if (isShowcaseView) {
await loadShowcaseKits();
}
} catch (error) {
console.error('Ошибка при уменьшении количества витринного комплекта:', error);
alert('Ошибка сервера. Попробуйте еще раз.');
}
}
async function clearCart() {
// Сбрасываем все свои блокировки витринных букетов
try {
await fetch('/pos/api/showcase-kits/release-all-my-locks/', {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() }
});
} catch (e) {
console.error('Ошибка сброса блокировок:', e);
}
// Очищаем корзину
cart.clear();
renderCart();
saveCartToRedis(); // Сохраняем пустую корзину в Redis
// Обновляем отображение товаров/витрины чтобы показать актуальные остатки
if (isShowcaseView) {
await loadShowcaseKits();
} else {
renderProducts(); // Перерисовать карточки товаров с актуальными остатками
}
}
document.getElementById('clearCart').onclick = clearCart;
/**
* Обновляет состояние кнопки "НА ВИТРИНУ"
* Блокирует кнопку если в корзине есть витринный комплект
*/
function updateShowcaseButtonState() {
const showcaseBtn = document.getElementById('addToShowcaseBtn');
if (!showcaseBtn) return;
// Проверяем наличие витринных комплектов в корзине
let hasShowcaseKit = false;
for (const [cartKey, item] of cart) {
if (item.type === 'showcase_kit') {
hasShowcaseKit = true;
break;
}
}
if (hasShowcaseKit) {
// Блокируем кнопку
showcaseBtn.disabled = true;
showcaseBtn.classList.add('disabled');
showcaseBtn.style.opacity = '0.5';
showcaseBtn.style.cursor = 'not-allowed';
showcaseBtn.title = '⚠️ В корзине уже есть витринный комплект. Удалите его перед созданием нового';
} else {
// Разблокируем кнопку
showcaseBtn.disabled = false;
showcaseBtn.classList.remove('disabled');
showcaseBtn.style.opacity = '1';
showcaseBtn.style.cursor = 'pointer';
showcaseBtn.title = 'Создать букет на витрину из текущей корзины';
}
}
// Кнопка "На витрину" - функционал будет добавлен позже
document.getElementById('addToShowcaseBtn').onclick = () => {
openCreateTempKitModal();
};
// Функция открытия модального окна для создания временного комплекта
async function openCreateTempKitModal() {
// Проверяем что корзина не пуста
if (cart.size === 0) {
alert('Корзина пуста. Добавьте товары перед созданием комплекта.');
return;
}
// Проверяем что в корзине НЕТ витринных комплектов
let hasShowcaseKit = false;
for (const [cartKey, item] of cart) {
if (item.type === 'showcase_kit') {
hasShowcaseKit = true;
break;
}
}
if (hasShowcaseKit) {
alert('⚠️ В корзине уже есть витринный комплект!\n\nНельзя создать новый букет на витрину, пока в корзине находится другой витринный букет.\n\nУдалите витринный букет из корзины или завершите текущую продажу.');
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),
actual_catalog_price: item.actual_catalog_price ? Number(item.actual_catalog_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 = 'Сохранить изменения';
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
document.getElementById('disassembleKitBtn').style.display = 'block';
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
document.getElementById('addProductBlock').style.display = 'block';
// Инициализируем компонент поиска товаров
setTimeout(() => {
if (window.ProductSearchPicker) {
const picker = ProductSearchPicker.init('#temp-kit-product-picker', {
onAddSelected: function(product, instance) {
if (product) {
// Добавляем товар в tempCart
const cartKey = `product-${product.id}`;
if (tempCart.has(cartKey)) {
// Увеличиваем количество
const existing = tempCart.get(cartKey);
existing.qty += 1;
} else {
// Добавляем новый товар
tempCart.set(cartKey, {
id: product.id,
name: product.text || product.name,
price: Number(product.price || 0),
qty: 1,
type: 'product'
});
}
// Обновляем отображение
renderTempKitItems();
// Очищаем выбор в пикере
instance.clearSelection();
}
}
});
}
}, 100);
// Открываем модальное окно
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
modal.show();
// Проверяем актуальность цен (сразу после открытия)
checkPricesActual();
} catch (error) {
console.error('Error loading kit for edit:', error);
alert('Ошибка при загрузке комплекта');
}
}
// Проверка актуальности цен в витринном комплекте
function checkPricesActual() {
// Удаляем старый warning если есть
const existingWarning = document.getElementById('priceOutdatedWarning');
if (existingWarning) existingWarning.remove();
// Проверяем цены используя actual_catalog_price из tempCart (уже загружен с бэкенда)
const outdatedItems = [];
let oldTotalPrice = 0;
let newTotalPrice = 0;
tempCart.forEach((item, cartKey) => {
if (item.type === 'product' && item.actual_catalog_price !== undefined) {
const savedPrice = parseFloat(item.price);
const actualPrice = parseFloat(item.actual_catalog_price);
const qty = parseFloat(item.qty) || 1;
if (Math.abs(savedPrice - actualPrice) > 0.01) {
oldTotalPrice += savedPrice * qty;
newTotalPrice += actualPrice * qty;
outdatedItems.push({
name: item.name,
old: savedPrice,
new: actualPrice,
qty: qty
});
}
}
});
if (outdatedItems.length > 0) {
showPriceOutdatedWarning(oldTotalPrice, newTotalPrice);
}
}
// Показать warning о неактуальных ценах
function showPriceOutdatedWarning(oldTotalPrice, newTotalPrice) {
const modalBody = document.querySelector('#createTempKitModal .modal-body');
const warning = document.createElement('div');
warning.id = 'priceOutdatedWarning';
warning.className = 'alert alert-warning alert-dismissible fade show d-flex align-items-start';
warning.innerHTML = `
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Цена неактуальна!</strong><br>
<small class="text-muted">При сохранении комплекта было: <strong>${formatMoney(oldTotalPrice)} руб.</strong></small><br>
<small class="text-muted">Актуальная цена сейчас: <strong>${formatMoney(newTotalPrice)} руб.</strong></small>
<button type="button" class="btn btn-sm btn-warning mt-2" onclick="actualizeKitPrices()">
<i class="bi bi-arrow-clockwise"></i> Пересчитать по актуальным ценам
</button>
</div>
<button type="button" class="btn-close flex-shrink-0" data-bs-dismiss="alert"></button>
`;
modalBody.insertBefore(warning, modalBody.firstChild);
}
// Актуализировать цены в комплекте
function actualizeKitPrices() {
tempCart.forEach((item) => {
if (item.type === 'product' && item.actual_catalog_price !== undefined) {
item.price = item.actual_catalog_price;
// Удаляем actual_catalog_price чтобы не показывался warning снова
delete item.actual_catalog_price;
}
});
// Перерисовываем товары и пересчитываем цену
renderTempKitItems();
updatePriceCalculations();
// Убираем warning
const warning = document.getElementById('priceOutdatedWarning');
if (warning) warning.remove();
}
// Обновление списка витринных комплектов
async function loadShowcaseKits() {
try {
const response = await fetch('/pos/api/showcase-kits/');
const data = await response.json();
if (data.success) {
showcaseKits = data.items;
// Перерисовываем грид если мы в режиме витрины
if (isShowcaseView) {
renderProducts();
}
} else {
console.error('Failed to refresh showcase kits:', data);
}
} catch (error) {
console.error('Error refreshing showcase kits:', error);
}
}
// Алиас для совместимости
const refreshShowcaseKits = loadShowcaseKits;
// Загрузка списка витрин
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) {
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-2 pb-2 border-bottom';
// Левая часть: название и цена
const leftDiv = document.createElement('div');
leftDiv.className = 'flex-grow-1';
leftDiv.innerHTML = `
<strong class="small">${item.name}</strong>
<br>
<small class="text-muted">${formatMoney(item.price)} руб. / шт.</small>
`;
// Правая часть: контролы количества и удаление
const rightDiv = document.createElement('div');
rightDiv.className = 'd-flex align-items-center gap-2';
// Кнопка минус
const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-sm btn-outline-secondary';
minusBtn.innerHTML = '<i class="bi bi-dash"></i>';
minusBtn.onclick = (e) => {
e.preventDefault();
if (item.qty > 1) {
item.qty--;
} else {
tempCart.delete(cartKey);
}
renderTempKitItems();
};
// Поле количества
const qtyInput = document.createElement('input');
qtyInput.type = 'number';
qtyInput.className = 'form-control form-control-sm text-center';
qtyInput.style.width = '60px';
qtyInput.value = item.qty;
qtyInput.min = 1;
qtyInput.onchange = (e) => {
const newQty = parseInt(e.target.value) || 1;
item.qty = Math.max(1, newQty);
renderTempKitItems();
};
// Кнопка плюс
const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-sm btn-outline-secondary';
plusBtn.innerHTML = '<i class="bi bi-plus"></i>';
plusBtn.onclick = (e) => {
e.preventDefault();
item.qty++;
renderTempKitItems();
};
// Сумма за товар
const totalDiv = document.createElement('div');
totalDiv.className = 'text-end ms-2';
totalDiv.style.minWidth = '80px';
totalDiv.innerHTML = `<strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>`;
// Кнопка удаления
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-outline-danger';
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
deleteBtn.onclick = (e) => {
e.preventDefault();
tempCart.delete(cartKey);
renderTempKitItems();
};
rightDiv.appendChild(minusBtn);
rightDiv.appendChild(qtyInput);
rightDiv.appendChild(plusBtn);
rightDiv.appendChild(totalDiv);
rightDiv.appendChild(deleteBtn);
itemDiv.appendChild(leftDiv);
itemDiv.appendChild(rightDiv);
container.appendChild(itemDiv);
estimatedTotal += item.qty * item.price;
});
// Если корзина пуста
if (tempCart.size === 0) {
container.innerHTML = '<p class="text-muted text-center py-3"><i class="bi bi-inbox"></i> Нет товаров</p>';
}
// Обновляем все расчеты цен
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;
// Получаем количество букетов для создания
const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1;
// Вычисляем итоговую цену комплекта на основе изменённых цен в корзине
let calculatedPrice = 0;
tempCart.forEach((item) => {
if (item.type === 'product') {
calculatedPrice += item.qty * item.price;
}
});
// Формируем FormData для отправки с файлом
const formData = new FormData();
formData.append('kit_name', kitName);
if (showcaseId) {
formData.append('showcase_id', showcaseId);
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
}
formData.append('description', description);
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
// Если пользователь не задал свою цену, используем вычисленную
const finalSalePrice = useSalePrice ? salePrice : calculatedPrice;
if (finalSalePrice > 0) {
formData.append('sale_price', finalSalePrice);
}
// Фото: для редактирования проверяем, удалено ли оно
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': getCsrfToken()
// Не указываем Content-Type - браузер сам установит multipart/form-data
},
body: formData
});
const data = await response.json();
if (data.success) {
// Успех!
const createdCount = data.available_count || 1;
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
let successMessage = isEditMode
? `${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
: `${data.message}
Комплект: ${data.kit_name}
Цена: ${data.kit_price} руб.${qtyInfo}
Зарезервировано компонентов: ${data.reservations_count}`;
// Если есть предупреждение о нехватке товара - добавляем его
if (data.warnings && data.warnings.length > 0) {
successMessage += '\n\n⚠ ВНИМАНИЕ: Нехватка товара на складе!\n';
data.warnings.forEach(warning => {
successMessage += `\n${warning}`;
});
successMessage += '\n\nПроверьте остатки и пополните склад.';
}
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';
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
// Запоминаем, был ли режим редактирования до сброса
const wasEditMode = isEditMode;
// Сбрасываем режим редактирования
isEditMode = false;
editingKitId = null;
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
modal.hide();
// Если это было СОЗДАНИЕ витринного комплекта из корзины,
// очищаем основную корзину POS
if (!wasEditMode) {
await clearCart();
}
// Обновляем витринные комплекты и переключаемся на вид витрины
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;
}
};
// Обработчик кнопки "Разобрать букет"
document.getElementById('disassembleKitBtn').addEventListener('click', async () => {
if (!isEditMode || !editingKitId) {
alert('Ошибка: режим редактирования не активен');
return;
}
// Запрос подтверждения
const confirmed = confirm(
'Вы уверены?\n\n' +
'Букет будет разобран:\n' +
'• Все резервы компонентов будут освобождены\n' +
'• Комплект будет помечен как "Снят"\n\n' +
'Это действие нельзя отменить!'
);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/pos/api/product-kits/${editingKitId}/disassemble/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
alert(`${data.message}\n\nОсвобождено резервов: ${data.released_count}`);
// Закрываем модальное окно
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 disassembling kit:', error);
alert('Произошла ошибка при разборе букета');
}
});
// Вспомогательная функция для определения мобильного устройства
function isMobileDevice() {
// Проверяем по юзер-агенту и размеру экрана
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
// Проверка по юзер-агенту
const mobileKeywords = ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Windows Phone'];
const isMobileUA = mobileKeywords.some(keyword =>
userAgent.indexOf(keyword) > -1
);
// Проверка по размеру экрана (ширина меньше 768px часто указывает на мобильные устройства)
const isSmallScreen = window.innerWidth < 768;
return isMobileUA || isSmallScreen;
}
// Вспомогательная функция для получения 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;
}
// Алиас для обратной совместимости
// ВАЖНО: При CSRF_USE_SESSIONS=True токен хранится в сессии, а не в cookie
// Извлекаем его из скрытого поля в HTML ({% csrf_token %})
const getCsrfToken = () => {
// Пытаемся найти токен в DOM (из {% csrf_token %})
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfInput) {
return csrfInput.value;
}
// Fallback: пытаемся прочитать из cookie (если CSRF_USE_SESSIONS=False)
return getCookie('csrftoken');
};
// Сброс режима редактирования при закрытии модального окна
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('disassembleKitBtn').style.display = 'none';
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
document.getElementById('addProductBlock').style.display = 'none';
}
});
// Открытие модалки "Продажа" и рендер сводки корзины
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();
}
// ===== CHECKOUT: ПОДТВЕРЖДЕНИЕ ПРОДАЖИ =====
let paymentWidget = null;
// Переменные состояния скидок
let appliedPromoCode = null; // примененный промокод
let appliedManualDiscount = null; // выбранная вручную скидка (из списка)
let appliedCustomDiscount = null; // произвольная скидка {value: number, isPercent: boolean}
let availableDiscounts = []; // список доступных скидок
let skipAutoDiscount = false; // флаг отмены авто-скидки
let cartDiscounts = {
orderDiscounts: [], // скидки на заказ (теперь массив)
itemDiscounts: [], // скидки на позиции
totalDiscount: 0, // общая сумма скидки
excludedBy: null // исключающая скидка
};
// При открытии модалки checkout
document.getElementById('checkoutModal').addEventListener('show.bs.modal', async () => {
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const walletBalance = customer.wallet_balance || 0;
// Сбрасываем скидки
resetDiscounts();
// Показываем баланс кошелька
const walletDiv = document.getElementById('checkoutWalletBalance');
if (customer.id !== SYSTEM_CUSTOMER.id) {
document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2);
walletDiv.style.display = 'block';
} else {
walletDiv.style.display = 'none';
}
// Вычисляем итоговую сумму
let totalAmount = 0;
cart.forEach((item) => {
totalAmount += item.qty * item.price;
});
// Проверяем автоматические скидки
await checkAutoDiscounts();
// Загружаем доступные скидки для ручного выбора
await loadAvailableDiscounts();
// Применяем скидки к итоговой сумме
const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount);
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
// Инициализируем виджет в single mode
initPaymentWidget('single', {
order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 },
customer: { id: customer.id, name: customer.name, wallet_balance: walletBalance }
});
});
// Переключение режима оплаты
document.getElementById('singlePaymentMode').addEventListener('click', function() {
document.getElementById('singlePaymentMode').classList.add('active');
document.getElementById('mixedPaymentMode').classList.remove('active');
reinitPaymentWidget('single');
});
document.getElementById('mixedPaymentMode').addEventListener('click', function() {
document.getElementById('mixedPaymentMode').classList.add('active');
document.getElementById('singlePaymentMode').classList.remove('active');
reinitPaymentWidget('mixed');
});
function reinitPaymentWidget(mode) {
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const totalAmountText = document.getElementById('checkoutFinalPrice').textContent;
const totalAmount = parseFloat(totalAmountText.replace(/[^\d.]/g, ''));
initPaymentWidget(mode, {
order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 },
customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 }
});
}
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ СО СКИДКАМИ =====
// Сброс скидок
function resetDiscounts() {
appliedPromoCode = null;
appliedManualDiscount = null;
appliedCustomDiscount = null;
availableDiscounts = [];
skipAutoDiscount = false;
cartDiscounts = {
orderDiscount: null,
itemDiscounts: [],
totalDiscount: 0
};
// Сбрасываем UI
document.getElementById('promoCodeInput').value = '';
document.getElementById('promoCodeError').style.display = 'none';
document.getElementById('promoCodeError').textContent = '';
document.getElementById('promoCodeSuccess').style.display = 'none';
document.getElementById('promoCodeSuccess').textContent = '';
document.getElementById('removePromoBtn').style.display = 'none';
// Новые элементы UI
document.getElementById('autoDiscountsContainer').style.display = 'none';
document.getElementById('autoDiscountsList').innerHTML = '';
document.getElementById('manualDiscountContainer').style.display = 'none';
document.getElementById('discountsSummary').style.display = 'none';
document.getElementById('itemDiscountsBreakdown').innerHTML = '';
// Сбрасываем произвольную скидку
document.getElementById('customDiscountInput').value = '';
document.getElementById('customDiscountIsPercent').checked = true;
document.getElementById('customDiscountError').style.display = 'none';
document.getElementById('customDiscountError').textContent = '';
document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('removeCustomDiscountBtn').style.display = 'none';
}
// Проверить автоматические скидки
async function checkAutoDiscounts() {
try {
const items = Array.from(cart.values()).map(item => ({
type: item.type,
id: item.id,
quantity: item.qty,
price: item.price
}));
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const response = await fetch('/pos/api/discounts/calculate/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
items: items,
customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null,
manual_discount_id: appliedManualDiscount?.id || null,
skip_auto_discount: skipAutoDiscount
})
});
const result = await response.json();
if (result.success) {
cartDiscounts.totalDiscount = result.total_discount || 0;
cartDiscounts.orderDiscounts = result.order_discounts || [];
cartDiscounts.itemDiscounts = result.item_discounts || [];
cartDiscounts.excludedBy = result.excluded_by || null;
updateDiscountsUI(result);
}
} catch (error) {
console.error('Ошибка при проверке автоматических скидок:', error);
}
}
// Получить иконку для режима объединения
function getCombineModeIcon(mode) {
const icons = {
'stack': '<i class="bi bi-layers" title="Складывать (суммировать)"></i>',
'max_only': '<i class="bi bi-trophy" title="Только максимум"></i>',
'exclusive': '<i class="bi bi-x-circle" title="Исключающая (отменяет остальные)"></i>'
};
return icons[mode] || '';
}
// Получить описание режима объединения
function getCombineModeTitle(mode) {
const titles = {
'stack': 'Складывается с другими скидками',
'max_only': 'Применяется только максимальная из этого типа',
'exclusive': 'Отменяет все другие скидки'
};
return titles[mode] || mode;
}
// Обновить UI скидок
function updateDiscountsUI(result) {
const autoContainer = document.getElementById('autoDiscountsContainer');
const autoList = document.getElementById('autoDiscountsList');
const summaryDiv = document.getElementById('discountsSummary');
const itemBreakdown = document.getElementById('itemDiscountsBreakdown');
// Очистка
autoList.innerHTML = '';
itemBreakdown.innerHTML = '';
let hasDiscounts = false;
// 1. Скидки на заказ (теперь может быть несколько)
const orderDiscounts = result.order_discounts || [];
if (orderDiscounts.length > 0) {
hasDiscounts = true;
autoContainer.style.display = 'block';
orderDiscounts.forEach(disc => {
const div = document.createElement('div');
div.className = 'd-flex justify-content-between align-items-center w-100';
const modeIcon = getCombineModeIcon(disc.combine_mode);
div.innerHTML = `
<span>${modeIcon} ${disc.discount_name}</span>
<span class="text-success">-${disc.discount_amount.toFixed(2)} руб.</span>
`;
autoList.appendChild(div);
});
// Показываем информацию о комбинировании
if (orderDiscounts.length > 1) {
const infoDiv = document.createElement('div');
infoDiv.className = 'text-muted small mt-1';
infoDiv.innerHTML = '<i class="bi bi-info-circle"></i> Скидки скомбинированы';
autoList.appendChild(infoDiv);
}
// Показываем кнопку отмены (только если еще не пропущена)
document.getElementById('skipAutoDiscountBtn').style.display = 'block';
} else {
autoContainer.style.display = 'none';
document.getElementById('skipAutoDiscountBtn').style.display = 'none';
}
// 2. Скидки на позиции (новый формат с массивом discounts)
const itemDiscounts = result.item_discounts || [];
if (itemDiscounts.length > 0) {
hasDiscounts = true;
itemDiscounts.forEach(item => {
if (item.discounts && item.discounts.length > 0) {
const discNames = item.discounts.map(d => {
const modeIcon = getCombineModeIcon(d.combine_mode);
return `${modeIcon} ${d.discount_name}`;
}).join(', ');
const div = document.createElement('div');
div.className = 'text-muted small';
div.innerHTML = `${discNames}: <span class="text-success">-${item.total_discount.toFixed(2)} руб.</span>`;
itemBreakdown.appendChild(div);
}
});
}
// 3. Ручная скидка (из списка)
if (appliedManualDiscount) {
hasDiscounts = true;
document.getElementById('manualDiscountContainer').style.display = 'block';
document.getElementById('manualDiscountName').textContent = appliedManualDiscount.name;
document.getElementById('manualDiscountAmount').textContent =
`-${appliedManualDiscount.amount.toFixed(2)} руб.`;
} else {
document.getElementById('manualDiscountContainer').style.display = 'none';
}
// 4. Произвольная скидка
if (appliedCustomDiscount) {
hasDiscounts = true;
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
const discountText = appliedCustomDiscount.isPercent
? `-${appliedCustomDiscount.value}% (-${customDiscountAmount.toFixed(2)} руб.)`
: `-${customDiscountAmount.toFixed(2)} руб.`;
// Показываем в summary или добавляем как отдельную строку
const customDiv = document.createElement('div');
customDiv.className = 'd-flex justify-content-between align-items-center mt-1';
customDiv.innerHTML = `
<span class="badge bg-primary">Произвольная скидка ${discountText}</span>
`;
itemBreakdown.appendChild(customDiv);
}
// Показываем/скрываем summary
if (hasDiscounts) {
summaryDiv.style.display = 'block';
document.getElementById('discountsSubtotal').textContent =
(result.cart_subtotal || 0).toFixed(2) + ' руб.';
// Рассчитываем итоговую скидку с учетом произвольной
let totalDiscount = result.total_discount || 0;
if (appliedCustomDiscount) {
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
totalDiscount += customDiscountAmount;
}
document.getElementById('discountsTotalDiscount').textContent =
'-' + totalDiscount.toFixed(2) + ' руб.';
} else {
summaryDiv.style.display = 'none';
}
// Обновляем итоговую цену
let totalDiscount = result.total_discount || 0;
if (appliedCustomDiscount) {
const customDiscountAmount = appliedCustomDiscount.isPercent
? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100
: appliedCustomDiscount.value;
totalDiscount += customDiscountAmount;
}
const finalTotal = Math.max(0, (result.cart_subtotal || 0) - totalDiscount);
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
// Пересчитываем виджет оплаты
reinitPaymentWidget(document.getElementById('singlePaymentMode').classList.contains('active') ? 'single' : 'mixed');
}
// Загрузить доступные скидки
async function loadAvailableDiscounts() {
try {
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
const response = await fetch(`/pos/api/discounts/available/?cart_total=${cartTotal}`);
const result = await response.json();
if (result.success) {
availableDiscounts = result.order_discounts;
renderDiscountsDropdown(result.order_discounts);
}
} catch (error) {
console.error('Ошибка загрузки скидок:', error);
}
}
// Отобразить список скидок в dropdown
function renderDiscountsDropdown(discounts) {
const list = document.getElementById('discountsDropdownList');
list.innerHTML = '';
if (discounts.length === 0) {
list.innerHTML = '<li><span class="dropdown-item-text small text-muted">Нет доступных скидок</span></li>';
return;
}
discounts.forEach(d => {
const li = document.createElement('li');
const valueText = d.discount_type === 'percentage' ? `${d.value}%` : `${d.value} руб.`;
const minText = d.min_order_amount ? `(от ${d.min_order_amount} руб.)` : '';
const modeIcon = getCombineModeIcon(d.combine_mode);
const modeTitle = getCombineModeTitle(d.combine_mode);
const a = document.createElement('a');
a.href = '#';
a.className = 'dropdown-item d-flex justify-content-between';
a.title = modeTitle;
a.innerHTML = `
<span>${modeIcon} ${d.name}</span>
<span class="text-success">${valueText} ${minText}</span>
`;
a.onclick = (e) => {
e.preventDefault();
applyManualDiscount(d);
};
li.appendChild(a);
list.appendChild(li);
});
}
// Применить скидку вручную
async function applyManualDiscount(discount) {
// Рассчитываем сумму скидки на клиенте (для отображения до ответа сервера)
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
let discountAmount = discount.discount_type === 'percentage'
? cartTotal * (discount.value / 100)
: discount.value;
appliedManualDiscount = { ...discount, amount: discountAmount };
await checkAutoDiscounts();
await loadAvailableDiscounts();
}
// Удалить ручную скидку
document.getElementById('removeManualDiscountBtn').addEventListener('click', async () => {
appliedManualDiscount = null;
await checkAutoDiscounts();
await loadAvailableDiscounts();
});
// Отменить автоматическую скидку
document.getElementById('skipAutoDiscountBtn').addEventListener('click', async () => {
skipAutoDiscount = true;
await checkAutoDiscounts();
});
// Применить промокод
async function applyPromoCode() {
const code = document.getElementById('promoCodeInput').value.trim().toUpperCase();
if (!code) return;
// Вычисляем сумму корзины
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
try {
const response = await fetch('/pos/api/discounts/validate-promo/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
promo_code: code,
cart_total: cartTotal
})
});
const result = await response.json();
if (result.success) {
appliedPromoCode = result.promo_code;
// Пересчитываем скидки с промокодом (updateDiscountsUI обновит UI)
await recalculateDiscountsWithPromo(code);
// Показываем успех
document.getElementById('promoCodeSuccess').textContent =
`Скидка: ${result.promo_code.discount_name} (${result.promo_code.discount_type === 'percentage' ? result.promo_code.discount_value + '%' : result.promo_code.discount_value + ' руб.'})`;
document.getElementById('promoCodeSuccess').style.display = 'block';
document.getElementById('promoCodeError').style.display = 'none';
document.getElementById('removePromoBtn').style.display = 'inline-block';
} else {
// Показываем ошибку
document.getElementById('promoCodeError').textContent = result.error || 'Неверный промокод';
document.getElementById('promoCodeError').style.display = 'block';
document.getElementById('promoCodeSuccess').style.display = 'none';
}
} catch (error) {
console.error('Ошибка при применении промокода:', error);
document.getElementById('promoCodeError').textContent = 'Ошибка при проверке промокода';
document.getElementById('promoCodeError').style.display = 'block';
}
}
// Пересчитать скидки с промокодом
async function recalculateDiscountsWithPromo(promoCode) {
try {
const items = Array.from(cart.values()).map(item => ({
type: item.type,
id: item.id,
quantity: item.qty,
price: item.price
}));
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const response = await fetch('/pos/api/discounts/calculate/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
items: items,
promo_code: promoCode,
customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null
})
});
const result = await response.json();
if (result.success) {
cartDiscounts.totalDiscount = result.total_discount || 0;
cartDiscounts.orderDiscounts = result.order_discounts || [];
cartDiscounts.itemDiscounts = result.item_discounts || [];
cartDiscounts.excludedBy = result.excluded_by || null;
updateDiscountsUI(result);
}
} catch (error) {
console.error('Ошибка при пересчёте скидок:', error);
}
}
// Обновить итоговую сумму с учётом скидок
function updateCheckoutTotalWithDiscounts() {
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
const finalTotal = Math.max(0, cartTotal - cartDiscounts.totalDiscount);
document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.';
// Пересоздаём платёжный виджет с новой суммой
const customer = selectedCustomer || SYSTEM_CUSTOMER;
initPaymentWidget('single', {
order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 },
customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 }
});
}
// Удалить промокод
function removePromoCode() {
appliedPromoCode = null;
// Пересчитываем без промокода (updateDiscountsUI обновит UI)
recalculateDiscountsWithPromo(null).then(() => {
document.getElementById('promoCodeInput').value = '';
document.getElementById('removePromoBtn').style.display = 'none';
document.getElementById('promoCodeSuccess').style.display = 'none';
});
}
// Обработчики кнопок промокода
document.getElementById('applyPromoBtn').addEventListener('click', applyPromoCode);
document.getElementById('removePromoBtn').addEventListener('click', removePromoCode);
document.getElementById('promoCodeInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyPromoCode();
}
});
// ===== ПРОИЗВОЛЬНАЯ СКИДКА =====
// Применить произвольную скидку
async function applyCustomDiscount() {
const input = document.getElementById('customDiscountInput');
const isPercent = document.getElementById('customDiscountIsPercent').checked;
const errorDiv = document.getElementById('customDiscountError');
const value = parseFloat(input.value);
// Валидация
if (isNaN(value) || value <= 0) {
errorDiv.textContent = 'Введите корректное значение скидки';
errorDiv.style.display = 'block';
return;
}
if (isPercent && value > 100) {
errorDiv.textContent = 'Процент не может превышать 100%';
errorDiv.style.display = 'block';
return;
}
// Проверяем сумму корзины
let cartTotal = 0;
cart.forEach((item) => {
cartTotal += item.qty * item.price;
});
if (!isPercent && value > cartTotal) {
errorDiv.textContent = `Скидка не может превышать сумму заказа (${cartTotal.toFixed(2)} руб.)`;
errorDiv.style.display = 'block';
return;
}
// Сохраняем произвольную скидку
appliedCustomDiscount = { value, isPercent };
// Сбрасываем другие типы скидок (взаимоисключающие)
appliedPromoCode = null;
appliedManualDiscount = null;
// Обновляем UI
errorDiv.style.display = 'none';
document.getElementById('applyCustomDiscountBtn').style.display = 'none';
document.getElementById('removeCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('promoCodeInput').value = '';
document.getElementById('promoCodeSuccess').style.display = 'none';
document.getElementById('promoCodeError').style.display = 'none';
// Пересчитываем скидки
await checkAutoDiscounts();
}
// Удалить произвольную скидку
async function removeCustomDiscount() {
appliedCustomDiscount = null;
// Обновляем UI
document.getElementById('customDiscountInput').value = '';
document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block';
document.getElementById('removeCustomDiscountBtn').style.display = 'none';
document.getElementById('customDiscountError').style.display = 'none';
// Пересчитываем скидки
await checkAutoDiscounts();
}
// Обработчики кнопок произвольной скидки
document.getElementById('applyCustomDiscountBtn').addEventListener('click', applyCustomDiscount);
document.getElementById('removeCustomDiscountBtn').addEventListener('click', removeCustomDiscount);
document.getElementById('customDiscountInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyCustomDiscount();
}
});
document.getElementById('customDiscountInput').addEventListener('input', () => {
document.getElementById('customDiscountError').style.display = 'none';
});
async function initPaymentWidget(mode, data) {
const paymentMethods = [
{ id: 1, code: 'account_balance', name: 'С баланса счёта' },
{ id: 2, code: 'cash', name: 'Наличными' },
{ id: 3, code: 'card', name: 'Картой' },
{ id: 4, code: 'online', name: 'Онлайн' }
];
// Динамически загружаем PaymentWidget если еще не загружен
if (!window.PaymentWidget) {
try {
const module = await import('/static/orders/js/payment_widget.js');
window.PaymentWidget = module.PaymentWidget;
} catch (error) {
console.error('Ошибка загрузки PaymentWidget:', error);
alert('Ошибка загрузки модуля оплаты. Перезагрузите страницу.');
return;
}
}
paymentWidget = new window.PaymentWidget({
containerId: 'paymentWidgetContainer',
mode: mode,
order: data.order,
customer: data.customer,
paymentMethods: paymentMethods,
onSubmit: (paymentsData) => handleCheckoutSubmit(paymentsData)
});
}
// Обработчик кнопки "Подтвердить продажу"
document.getElementById('confirmCheckoutBtn').onclick = () => {
if (paymentWidget) {
paymentWidget.submit();
}
};
// Отправка заказа на сервер
async function handleCheckoutSubmit(paymentsData) {
try {
// Блокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
// Собираем данные
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const orderData = {
customer_id: customer.id,
warehouse_id: currentWarehouse.id,
items: Array.from(cart.values()).map(item => {
const itemData = {
type: item.type,
id: item.id,
quantity: item.qty,
price: item.price
};
// Для витринных букетов передаём ID конкретных экземпляров
if (item.type === 'showcase_kit' && item.showcase_item_ids) {
itemData.showcase_item_ids = item.showcase_item_ids;
}
// Для товаров с единицами продажи
if (item.sales_unit_id) {
itemData.sales_unit_id = item.sales_unit_id;
}
return itemData;
}),
payments: paymentsData,
notes: document.getElementById('orderNote').value.trim(),
promo_code: appliedPromoCode?.code || null,
manual_discount_id: appliedManualDiscount?.id || null,
custom_discount: appliedCustomDiscount ? {
value: appliedCustomDiscount.value,
is_percent: appliedCustomDiscount.isPercent
} : null
};
// Отправляем на сервер
const response = await fetch('/pos/api/checkout/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(orderData)
});
const result = await response.json();
if (result.success) {
console.log('✅ Заказ успешно создан:', result);
// Успех
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`);
// Очищаем корзину
cart.clear();
renderCart();
console.log('🧹 Корзина очищена');
// Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal'));
if (modal) {
modal.hide();
console.log('❌ Модалка закрыта');
}
// Перезагружаем страницу для обновления остатков товаров
console.log('🔄 Перезагрузка страницы...');
setTimeout(() => {
window.location.reload();
}, 500);
} else {
alert('Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка checkout:', error);
alert('Ошибка при проведении продажи: ' + error.message);
} finally {
// Разблокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle"></i> Подтвердить продажу';
}
}
// ===== ОБРАБОТЧИКИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
// Кнопка "Выбрать клиента" в корзине
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, SYSTEM_CUSTOMER.wallet_balance || 0);
});
// Кнопка "Выбрать клиента" в модалке продажи
document.getElementById('checkoutCustomerSelectBtn').addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('selectCustomerModal'));
modal.show();
});
// Кнопка сброса клиента на системного (в модалке продажи)
document.getElementById('checkoutResetCustomerBtn').addEventListener('click', () => {
selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name, SYSTEM_CUSTOMER.wallet_balance || 0);
});
// Кнопка "Создать нового клиента" в модалке выбора
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, SYSTEM_CUSTOMER.wallet_balance || 0);
// Закрываем модалку
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(); // Отрисовываем восстановленную корзину
}
// ===== ОБРАБОТЧИКИ ДЛЯ МОДАЛКИ ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
// Кнопки изменения количества
document.getElementById('unitQtyDecrement').addEventListener('click', () => {
const input = document.getElementById('unitModalQuantity');
const step = parseFloat(input.step) || 1;
const newValue = Math.max(parseFloat(input.min), parseFloat(input.value) - step);
input.value = roundQuantity(newValue, 3);
calculateUnitModalSubtotal();
validateUnitQuantity();
});
document.getElementById('unitQtyIncrement').addEventListener('click', () => {
const input = document.getElementById('unitModalQuantity');
const step = parseFloat(input.step) || 1;
const newValue = parseFloat(input.value) + step;
input.value = roundQuantity(newValue, 3);
calculateUnitModalSubtotal();
validateUnitQuantity();
});
// Изменение количества вручную
document.getElementById('unitModalQuantity').addEventListener('input', () => {
calculateUnitModalSubtotal();
validateUnitQuantity();
});
// Округление количества при потере фокуса
document.getElementById('unitModalQuantity').addEventListener('blur', (e) => {
const rawValue = parseFloat(e.target.value) || 0;
e.target.value = roundQuantity(rawValue, 3);
calculateUnitModalSubtotal();
validateUnitQuantity();
});
// Изменение цены
document.getElementById('unitModalPrice').addEventListener('input', () => {
calculateUnitModalSubtotal();
});
// Кнопка подтверждения добавления в корзину
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');
}
}
// ===== МОБИЛЬНЫЙ DROPDOWN "ЕЩЁ" =====
// Мобильная кнопка "Отложенный заказ"
const mobileScheduleLaterBtn = document.getElementById('mobileScheduleLaterBtn');
if (mobileScheduleLaterBtn) {
mobileScheduleLaterBtn.addEventListener('click', () => {
const scheduleBtn = document.getElementById('scheduleLater');
if (scheduleBtn) {
scheduleBtn.click();
}
});
}
// Мобильная кнопка "На витрину"
const mobileAddToShowcaseBtn = document.getElementById('mobileAddToShowcaseBtn');
if (mobileAddToShowcaseBtn) {
mobileAddToShowcaseBtn.addEventListener('click', () => {
const showcaseBtn = document.getElementById('addToShowcaseBtn');
if (showcaseBtn) {
showcaseBtn.click();
}
});
}
});
// Смена склада
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('Произошла ошибка при смене склада');
}
});
// Обработчик поиска с 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);
});
// При нажатии Enter на searchInput - скрываем виртуальную клавиатуру
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchInput.blur(); // Скрывает виртуальную клавиатуру на мобильных
}
});
// Обработчик кнопки очистки поиска
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
currentSearchQuery = '';
ITEMS = [];
renderProducts(); // Пустой экран
});
// Инициализация
renderCategories();
renderProducts(); // Сначала пустая сетка
renderCart();
setupInfiniteScroll(); // Установка infinite scroll
// Установить фокус на строку поиска только на десктопе
if (!isMobileDevice()) {
document.getElementById('searchInput').focus();
}
// ===== ОТЛОЖЕННЫЙ ЗАКАЗ =====
/**
* Создаёт отложенный заказ (черновик) и резервирует витринные букеты
*
* FLOW:
* 1. Создаём Order (статус 'draft') через API
* 2. ShowcaseItem резервируются в той же транзакции (in_cart → reserved)
* 3. Очищаем корзину POS
* 4. Открываем форму редактирования заказа
*/
async function createDeferredOrder() {
// Проверяем, что корзина не пуста
if (cart.size === 0) {
alert('Корзина пуста! Добавьте товары в корзину.');
return;
}
try {
// Собираем данные для черновика
const items = Array.from(cart.values()).map(item => {
const itemData = {
type: item.type,
id: item.id,
quantity: item.qty,
price: item.price
};
// Для товаров с единицами продажи передаём sales_unit_id
if (item.sales_unit_id) {
itemData.sales_unit_id = item.sales_unit_id;
}
// Для витринных букетов передаём showcase_item_ids
if (item.type === 'showcase_kit' && item.showcase_item_ids) {
itemData.showcase_item_ids = item.showcase_item_ids;
}
return itemData;
});
const customer = selectedCustomer || SYSTEM_CUSTOMER;
const orderData = {
customer_id: customer.id,
items: items
};
// Создаём заказ через новый endpoint
const response = await fetch('/orders/api/create-from-pos/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(orderData)
});
const result = await response.json();
if (result.success) {
console.log(`✅ Заказ #${result.order_number} создан (черновик). ShowcaseItem зарезервированы.`);
// КРИТИЧНО: Очищаем корзину POS (включая витринные <20><>укеты)
cart.clear();
renderCart();
saveCartToRedis(); // Сохраняем пустую корзину в Redis
// Перезагружаем витрину (чтобы зарезервированные букеты исчезли)
if (isShowcaseView) {
await refreshShowcaseKits();
renderProducts();
}
// Открываем форму редактирования в новой вкладке
window.open(`/orders/${result.order_number}/edit/`, '_blank');
} else {
alert(`Ошибка: ${result.error}`);
}
} catch (error) {
console.error('Ошибка при создании отложенного заказа:', error);
alert('Произошла ошибка при создании черновика заказа');
}
}
// Обработчик кнопки "ОТЛОЖЕННЫЙ заказ"
const scheduleLaterBtn = document.getElementById('scheduleLater');
if (scheduleLaterBtn) {
scheduleLaterBtn.addEventListener('click', createDeferredOrder);
}