Рефакторинг POS терминала с использованием ES6 модулей
This commit is contained in:
@@ -17,12 +17,35 @@
|
|||||||
return (Number(value)).toFixed(2);
|
return (Number(value)).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Округление количества
|
||||||
|
*/
|
||||||
|
function roundQty(value, decimals = 3) {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||||
|
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирование денег
|
||||||
|
*/
|
||||||
|
function fmtMoney(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return '0.00';
|
||||||
|
return num.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Открытие модалки редактирования
|
* Открытие модалки редактирования
|
||||||
* @param {string} cartKey - ключ товара в корзине
|
* @param {string} cartKey - ключ товара в корзине
|
||||||
*/
|
*/
|
||||||
function openModal(cartKey) {
|
function openModal(cartKey) {
|
||||||
const item = window.cart?.get(cartKey);
|
const cart = window.cart;
|
||||||
|
if (!cart) {
|
||||||
|
console.error('CartItemEditor: window.cart not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = cart.get(cartKey);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
console.error('CartItemEditor: Item not found for key:', cartKey);
|
console.error('CartItemEditor: Item not found for key:', cartKey);
|
||||||
return;
|
return;
|
||||||
@@ -42,33 +65,35 @@
|
|||||||
const isShowcaseKit = item.type === 'showcase_kit';
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
// Заполнение полей
|
// Заполнение полей
|
||||||
document.getElementById('editModalProductName').textContent = item.name || '—';
|
const nameEl = document.getElementById('editModalProductName');
|
||||||
|
const basePriceEl = document.getElementById('editModalBasePrice');
|
||||||
|
const priceInput = document.getElementById('editModalPrice');
|
||||||
|
const qtyInput = document.getElementById('editModalQuantity');
|
||||||
|
|
||||||
// Используем formatMoney из terminal.js
|
if (nameEl) nameEl.textContent = item.name || '—';
|
||||||
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
|
if (basePriceEl) basePriceEl.textContent = fmtMoney(basePrice) + ' руб.';
|
||||||
document.getElementById('editModalBasePrice').textContent = fmtMoney(basePrice) + ' руб.';
|
if (priceInput) priceInput.value = roundPrice(basePrice);
|
||||||
|
if (qtyInput) qtyInput.value = item.qty || 1;
|
||||||
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
|
||||||
document.getElementById('editModalQuantity').value = item.qty || 1;
|
|
||||||
|
|
||||||
// Для витринных комплектов блокируем изменение количества
|
// Для витринных комплектов блокируем изменение количества
|
||||||
const qtyInput = document.getElementById('editModalQuantity');
|
|
||||||
const qtyHint = document.getElementById('editModalQtyHint');
|
const qtyHint = document.getElementById('editModalQtyHint');
|
||||||
if (isShowcaseKit) {
|
if (isShowcaseKit) {
|
||||||
qtyInput.disabled = true;
|
if (qtyInput) qtyInput.disabled = true;
|
||||||
qtyHint.style.display = 'block';
|
if (qtyHint) qtyHint.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
qtyInput.disabled = false;
|
if (qtyInput) qtyInput.disabled = false;
|
||||||
qtyHint.style.display = 'none';
|
if (qtyHint) qtyHint.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Бейдж единицы измерения
|
// Бейдж единицы измерения
|
||||||
const unitBadge = document.getElementById('editModalUnitBadge');
|
const unitBadge = document.getElementById('editModalUnitBadge');
|
||||||
if (item.unit_name) {
|
if (unitBadge) {
|
||||||
unitBadge.textContent = item.unit_name;
|
if (item.unit_name) {
|
||||||
unitBadge.style.display = 'inline-block';
|
unitBadge.textContent = item.unit_name;
|
||||||
} else {
|
unitBadge.style.display = 'inline-block';
|
||||||
unitBadge.style.display = 'none';
|
} else {
|
||||||
|
unitBadge.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTotal();
|
updateTotal();
|
||||||
@@ -84,18 +109,27 @@
|
|||||||
* Обновление суммы в модалке
|
* Обновление суммы в модалке
|
||||||
*/
|
*/
|
||||||
function updateTotal() {
|
function updateTotal() {
|
||||||
const price = parseFloat(document.getElementById('editModalPrice').value) || 0;
|
const priceInput = document.getElementById('editModalPrice');
|
||||||
const qty = parseFloat(document.getElementById('editModalQuantity').value) || 0;
|
const qtyInput = document.getElementById('editModalQuantity');
|
||||||
|
const totalEl = document.getElementById('editModalTotal');
|
||||||
|
const warningEl = document.getElementById('editModalPriceWarning');
|
||||||
|
|
||||||
|
if (!priceInput || !qtyInput) return;
|
||||||
|
|
||||||
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
|
const price = parseFloat(priceInput.value) || 0;
|
||||||
document.getElementById('editModalTotal').textContent = fmtMoney(price * qty) + ' руб.';
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
|
||||||
|
if (totalEl) {
|
||||||
|
totalEl.textContent = fmtMoney(price * qty) + ' руб.';
|
||||||
|
}
|
||||||
|
|
||||||
// Индикатор изменения цены
|
// Индикатор изменения цены
|
||||||
const warning = document.getElementById('editModalPriceWarning');
|
if (warningEl) {
|
||||||
if (Math.abs(price - basePrice) > 0.01) {
|
if (Math.abs(price - basePrice) > 0.01) {
|
||||||
warning.style.display = 'block';
|
warningEl.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
warning.style.display = 'none';
|
warningEl.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,33 +139,43 @@
|
|||||||
function saveChanges() {
|
function saveChanges() {
|
||||||
if (!editingCartKey) return;
|
if (!editingCartKey) return;
|
||||||
|
|
||||||
const newPrice = parseFloat(document.getElementById('editModalPrice').value) || 0;
|
const priceInput = document.getElementById('editModalPrice');
|
||||||
const newQty = parseFloat(document.getElementById('editModalQuantity').value) || 1;
|
const qtyInput = document.getElementById('editModalQuantity');
|
||||||
|
|
||||||
|
if (!priceInput) return;
|
||||||
|
|
||||||
const item = window.cart?.get(editingCartKey);
|
const newPrice = parseFloat(priceInput.value) || 0;
|
||||||
|
const newQty = parseFloat(qtyInput?.value) || 1;
|
||||||
|
|
||||||
|
const cart = window.cart;
|
||||||
|
if (!cart) {
|
||||||
|
console.error('CartItemEditor: window.cart not found during save!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = cart.get(editingCartKey);
|
||||||
if (item) {
|
if (item) {
|
||||||
// Используем roundQuantity из terminal.js
|
|
||||||
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
|
||||||
|
|
||||||
const isShowcaseKit = item.type === 'showcase_kit';
|
const isShowcaseKit = item.type === 'showcase_kit';
|
||||||
|
|
||||||
item.price = newPrice;
|
item.price = newPrice;
|
||||||
// Для витринных комплектов не меняем количество
|
// Для витринных комплектов не меняем количество
|
||||||
if (!isShowcaseKit) {
|
if (!isShowcaseKit) {
|
||||||
item.qty = rndQty(newQty, 3);
|
item.qty = roundQty(newQty, 3);
|
||||||
}
|
}
|
||||||
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
||||||
|
|
||||||
window.cart.set(editingCartKey, item);
|
// Обновляем в корзине
|
||||||
|
cart.items.set(editingCartKey, item);
|
||||||
|
|
||||||
|
// Уведомляем слушателей
|
||||||
|
cart._notify();
|
||||||
|
|
||||||
|
// Планируем сохранение
|
||||||
|
cart.scheduleSave();
|
||||||
|
|
||||||
// Перерисовка корзины
|
// Перерисовка корзины
|
||||||
if (typeof renderCart === 'function') {
|
if (typeof window.renderCart === 'function') {
|
||||||
renderCart();
|
window.renderCart();
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранение на сервере
|
|
||||||
if (typeof saveCartToServer === 'function') {
|
|
||||||
saveCartToServer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('CartItemEditor: Changes saved for', item.name);
|
console.log('CartItemEditor: Changes saved for', item.name);
|
||||||
@@ -170,30 +214,36 @@
|
|||||||
|
|
||||||
// Обновление суммы при изменении полей
|
// Обновление суммы при изменении полей
|
||||||
priceInput.addEventListener('input', updateTotal);
|
priceInput.addEventListener('input', updateTotal);
|
||||||
qtyInput.addEventListener('input', updateTotal);
|
if (qtyInput) qtyInput.addEventListener('input', updateTotal);
|
||||||
|
|
||||||
// Авто-выделение всего текста при фокусе
|
// Авто-выделение всего текста при фокусе
|
||||||
priceInput.addEventListener('focus', function() {
|
priceInput.addEventListener('focus', function() {
|
||||||
this.select();
|
this.select();
|
||||||
});
|
});
|
||||||
qtyInput.addEventListener('focus', function() {
|
if (qtyInput) {
|
||||||
this.select();
|
qtyInput.addEventListener('focus', function() {
|
||||||
});
|
this.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка сохранения
|
// Кнопка сохранения
|
||||||
confirmBtn.addEventListener('click', saveChanges);
|
confirmBtn.addEventListener('click', saveChanges);
|
||||||
|
|
||||||
// Сброс при закрытии модалки
|
// Сброс при закрытии модалки
|
||||||
const modalEl = document.getElementById('editCartItemModal');
|
const modalEl = document.getElementById('editCartItemModal');
|
||||||
modalEl.addEventListener('hidden.bs.modal', reset);
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('hidden.bs.modal', reset);
|
||||||
|
}
|
||||||
|
|
||||||
// Enter для сохранения
|
// Enter для сохранения
|
||||||
priceInput.addEventListener('keypress', function(e) {
|
priceInput.addEventListener('keypress', function(e) {
|
||||||
if (e.key === 'Enter') saveChanges();
|
if (e.key === 'Enter') saveChanges();
|
||||||
});
|
});
|
||||||
qtyInput.addEventListener('keypress', function(e) {
|
if (qtyInput) {
|
||||||
if (e.key === 'Enter') saveChanges();
|
qtyInput.addEventListener('keypress', function(e) {
|
||||||
});
|
if (e.key === 'Enter') saveChanges();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Экспорт функций для использования из terminal.js
|
// Экспорт функций для использования из terminal.js
|
||||||
|
|||||||
541
myproject/pos/static/pos/js/cart.js
Normal file
541
myproject/pos/static/pos/js/cart.js
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
/**
|
||||||
|
* Модуль управления корзиной POS Terminal
|
||||||
|
* Класс Cart инкапсулирует всю логику работы с корзиной
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
import {
|
||||||
|
roundQuantity,
|
||||||
|
getCsrfToken,
|
||||||
|
showToast,
|
||||||
|
safeFetch,
|
||||||
|
formatMoney
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CartItem
|
||||||
|
* @property {number} id - ID товара/комплекта
|
||||||
|
* @property {string} name - Название
|
||||||
|
* @property {number} price - Цена
|
||||||
|
* @property {number} qty - Количество
|
||||||
|
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
|
||||||
|
* @property {number} [sales_unit_id] - ID единицы продажи
|
||||||
|
* @property {string} [unit_name] - Название единицы продажи
|
||||||
|
* @property {number} [quantity_step] - Шаг количества
|
||||||
|
* @property {boolean} [price_overridden] - Цена изменена вручную
|
||||||
|
* @property {number[]} [showcase_item_ids] - ID витринных экземпляров
|
||||||
|
* @property {string} [lock_expires_at] - Время истечения блокировки
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Cart {
|
||||||
|
constructor() {
|
||||||
|
/** @type {Map<string, CartItem>} */
|
||||||
|
this.items = new Map();
|
||||||
|
|
||||||
|
/** @type {number|null} */
|
||||||
|
this.saveTimeout = null;
|
||||||
|
|
||||||
|
/** @type {Function[]} */
|
||||||
|
this.listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет слушатель изменений корзины
|
||||||
|
* @param {Function} callback - Функция обратного вызова
|
||||||
|
*/
|
||||||
|
addListener(callback) {
|
||||||
|
this.listeners.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет слушатель изменений корзины
|
||||||
|
* @param {Function} callback - Функция обратного вызова
|
||||||
|
*/
|
||||||
|
removeListener(callback) {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомляет всех слушателей об изменении
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_notify() {
|
||||||
|
this.listeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(this.items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка в слушателе корзины:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует ключ для элемента корзины
|
||||||
|
* @param {string} type - Тип элемента
|
||||||
|
* @param {number} id - ID элемента
|
||||||
|
* @param {number} [salesUnitId] - ID единицы продажи
|
||||||
|
* @returns {string} Ключ корзины
|
||||||
|
*/
|
||||||
|
static generateKey(type, id, salesUnitId = null) {
|
||||||
|
if (salesUnitId) {
|
||||||
|
return `${type}-${id}-${salesUnitId}`;
|
||||||
|
}
|
||||||
|
return `${type}-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет товар в корзину
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @param {CartItem} item - Элемент для добавления
|
||||||
|
* @param {boolean} [merge=true] - Объединять ли с существующим
|
||||||
|
*/
|
||||||
|
add(key, item, merge = true) {
|
||||||
|
if (this.items.has(key) && merge) {
|
||||||
|
const existing = this.items.get(key);
|
||||||
|
existing.qty = roundQuantity(existing.qty + item.qty);
|
||||||
|
// Обновляем цену если передана новая
|
||||||
|
if (item.price !== undefined) {
|
||||||
|
existing.price = item.price;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.items.set(key, { ...item });
|
||||||
|
}
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет количество элемента
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @param {number} qty - Новое количество
|
||||||
|
* @returns {boolean} Успешно ли обновление
|
||||||
|
*/
|
||||||
|
updateQuantity(key, qty) {
|
||||||
|
const item = this.items.get(key);
|
||||||
|
if (!item) return false;
|
||||||
|
|
||||||
|
const roundedQty = roundQuantity(qty);
|
||||||
|
|
||||||
|
if (roundedQty <= 0) {
|
||||||
|
this.remove(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.qty = roundedQty;
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет элемент из корзины
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {boolean} Успешно ли удаление
|
||||||
|
*/
|
||||||
|
remove(key) {
|
||||||
|
const existed = this.items.has(key);
|
||||||
|
this.items.delete(key);
|
||||||
|
if (existed) {
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
return existed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает корзину
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.items.clear();
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает элемент корзины
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {CartItem|undefined} Элемент корзины
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
return this.items.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет наличие элемента
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {boolean} Есть ли элемент
|
||||||
|
*/
|
||||||
|
has(key) {
|
||||||
|
return this.items.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает размер корзины
|
||||||
|
* @returns {number} Количество элементов
|
||||||
|
*/
|
||||||
|
get size() {
|
||||||
|
return this.items.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, пуста ли корзина
|
||||||
|
* @returns {boolean} Пуста ли корзина
|
||||||
|
*/
|
||||||
|
get isEmpty() {
|
||||||
|
return this.items.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет общую сумму корзины
|
||||||
|
* @returns {number} Общая сумма
|
||||||
|
*/
|
||||||
|
get total() {
|
||||||
|
let total = 0;
|
||||||
|
this.items.forEach(item => {
|
||||||
|
total += item.qty * item.price;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет общее количество товаров
|
||||||
|
* @returns {number} Общее количество
|
||||||
|
*/
|
||||||
|
get totalQuantity() {
|
||||||
|
let count = 0;
|
||||||
|
this.items.forEach(item => {
|
||||||
|
count += item.qty;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет наличие витринных комплектов
|
||||||
|
* @returns {boolean} Есть ли витринные комплекты
|
||||||
|
*/
|
||||||
|
hasShowcaseKits() {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.type === 'showcase_kit') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет наличие обычных комплектов
|
||||||
|
* @returns {boolean} Есть ли комплекты
|
||||||
|
*/
|
||||||
|
hasKits() {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.type === 'kit') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает все элементы как массив
|
||||||
|
* @returns {Array<{key: string, item: CartItem}>} Массив элементов
|
||||||
|
*/
|
||||||
|
toArray() {
|
||||||
|
const result = [];
|
||||||
|
this.items.forEach((item, key) => {
|
||||||
|
result.push({ key, item });
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует корзину в обычный объект для JSON
|
||||||
|
* @returns {Object} Объект корзины
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
const obj = {};
|
||||||
|
this.items.forEach((value, key) => {
|
||||||
|
obj[key] = value;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает корзину из объекта
|
||||||
|
* @param {Object} data - Данные корзины
|
||||||
|
*/
|
||||||
|
fromJSON(data) {
|
||||||
|
this.items.clear();
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
this.items.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует сохранение корзины на сервер
|
||||||
|
*/
|
||||||
|
scheduleSave() {
|
||||||
|
// Отменяем предыдущий таймер
|
||||||
|
if (this.saveTimeout) {
|
||||||
|
clearTimeout(this.saveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем новый таймер
|
||||||
|
this.saveTimeout = setTimeout(() => {
|
||||||
|
this.saveToServer();
|
||||||
|
}, CONFIG.TIMEOUTS.DEBOUNCE_CART_SAVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет корзину на сервер
|
||||||
|
* @returns {Promise<boolean>} Успешно ли сохранение
|
||||||
|
*/
|
||||||
|
async saveToServer() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(CONFIG.API.SAVE_CART, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ cart: this.toJSON() })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
console.error('Ошибка сохранения корзины:', data.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при сохранении корзины:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет витринный комплект с блокировкой на сервере
|
||||||
|
* @param {Object} item - Данные комплекта
|
||||||
|
* @param {number} quantity - Количество
|
||||||
|
* @returns {Promise<{success: boolean, error?: string, data?: Object}>}
|
||||||
|
*/
|
||||||
|
async addShowcaseKit(item, quantity = 1) {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ quantity })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.error || 'Не удалось добавить букет в корзину'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успешно заблокировали - добавляем/обновляем в корзине
|
||||||
|
const cartKey = Cart.generateKey('showcase_kit', item.id);
|
||||||
|
const lockedItemIds = data.locked_item_ids || [];
|
||||||
|
|
||||||
|
if (this.has(cartKey)) {
|
||||||
|
const existing = this.get(cartKey);
|
||||||
|
existing.qty = roundQuantity(existing.qty + lockedItemIds.length);
|
||||||
|
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
|
||||||
|
} else {
|
||||||
|
this.add(cartKey, {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
price: Number(item.price),
|
||||||
|
qty: lockedItemIds.length,
|
||||||
|
type: 'showcase_kit',
|
||||||
|
showcase_item_ids: lockedItemIds,
|
||||||
|
lock_expires_at: data.lock_expires_at
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при добавлении витринного комплекта:', error);
|
||||||
|
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет витринный комплект со снятием блокировки
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {Promise<{success: boolean, error?: string}>}
|
||||||
|
*/
|
||||||
|
async removeShowcaseKit(key) {
|
||||||
|
const item = this.get(key);
|
||||||
|
if (!item || item.type !== 'showcase_kit') {
|
||||||
|
this.remove(key);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
|
||||||
|
body.showcase_item_ids = item.showcase_item_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем из корзины даже при ошибке
|
||||||
|
this.remove(key);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
|
||||||
|
// Удаляем из корзины даже при ошибке
|
||||||
|
this.remove(key);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Увеличивает количество витринного комплекта
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {Promise<{success: boolean, error?: string}>}
|
||||||
|
*/
|
||||||
|
async increaseShowcaseKitQty(key) {
|
||||||
|
const item = this.get(key);
|
||||||
|
if (!item || item.type !== 'showcase_kit') {
|
||||||
|
return { success: false, error: 'Элемент не найден' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
|
||||||
|
{
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.error || 'Нет доступных экземпляров этого букета на витрине'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успешно заблокировали - обновляем корзину
|
||||||
|
const lockedItemIds = data.locked_item_ids || [];
|
||||||
|
item.qty = roundQuantity(item.qty + lockedItemIds.length);
|
||||||
|
item.showcase_item_ids = [...(item.showcase_item_ids || []), ...lockedItemIds];
|
||||||
|
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при увеличении количества витринного комплекта:', error);
|
||||||
|
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уменьшает количество витринного комплекта
|
||||||
|
* @param {string} key - Ключ корзины
|
||||||
|
* @returns {Promise<{success: boolean, error?: string}>}
|
||||||
|
*/
|
||||||
|
async decreaseShowcaseKitQty(key) {
|
||||||
|
const item = this.get(key);
|
||||||
|
if (!item || item.type !== 'showcase_kit') {
|
||||||
|
return { success: false, error: 'Элемент не найден' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если количество = 1, удаляем полностью
|
||||||
|
if (item.qty <= 1) {
|
||||||
|
return this.removeShowcaseKit(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Снимаем блокировку с последнего экземпляра
|
||||||
|
const showcaseItemIds = item.showcase_item_ids || [];
|
||||||
|
if (showcaseItemIds.length === 0) {
|
||||||
|
return this.removeShowcaseKit(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берем последний ID из списка
|
||||||
|
const itemIdToRelease = showcaseItemIds[showcaseItemIds.length - 1];
|
||||||
|
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
|
||||||
|
{
|
||||||
|
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 = roundQuantity(item.qty - 1);
|
||||||
|
item.showcase_item_ids = showcaseItemIds.filter(id => id !== itemIdToRelease);
|
||||||
|
|
||||||
|
this._notify();
|
||||||
|
this.scheduleSave();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при уменьшении количества витринного комплекта:', error);
|
||||||
|
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает все блокировки витринных комплектов
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async releaseAllLocks() {
|
||||||
|
try {
|
||||||
|
await safeFetch(
|
||||||
|
CONFIG.API.SHOWCASE_KIT_RELEASE_ALL,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': getCsrfToken() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сброса блокировок:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
852
myproject/pos/static/pos/js/checkout.js
Normal file
852
myproject/pos/static/pos/js/checkout.js
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
/**
|
||||||
|
* Модуль оформления заказа (checkout) POS Terminal
|
||||||
|
* Управление оплатой, скидками, промокодами
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
import {
|
||||||
|
safeJSONParse,
|
||||||
|
roundQuantity,
|
||||||
|
formatMoney,
|
||||||
|
getCsrfToken,
|
||||||
|
showToast,
|
||||||
|
safeFetch,
|
||||||
|
getCombineModeIcon,
|
||||||
|
getCombineModeTitle
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} PaymentMethod
|
||||||
|
* @property {string} id - ID метода оплаты
|
||||||
|
* @property {string} name - Название
|
||||||
|
* @property {number} amount - Сумма
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Discount
|
||||||
|
* @property {number} id - ID скидки
|
||||||
|
* @property {string} name - Название
|
||||||
|
* @property {string} type - Тип: percent, fixed
|
||||||
|
* @property {number} value - Значение
|
||||||
|
* @property {string} combine_mode - Режим объединения
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CheckoutManager {
|
||||||
|
/**
|
||||||
|
* @param {Object} options - Опции
|
||||||
|
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||||
|
* @param {import('./customer.js').CustomerManager} options.customerManager - Менеджер клиентов
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.cart = options.cart;
|
||||||
|
this.customerManager = options.customerManager;
|
||||||
|
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||||
|
|
||||||
|
// Состояние checkout
|
||||||
|
this.appliedPromoCode = null;
|
||||||
|
this.appliedManualDiscount = null;
|
||||||
|
this.appliedCustomDiscount = null;
|
||||||
|
this.availableDiscounts = [];
|
||||||
|
this.skipAutoDiscount = false;
|
||||||
|
|
||||||
|
this.cartDiscounts = {
|
||||||
|
orderDiscounts: [],
|
||||||
|
itemDiscounts: [],
|
||||||
|
totalDiscount: 0,
|
||||||
|
excludedBy: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Оплата
|
||||||
|
this.paymentMode = 'single'; // 'single' | 'mixed'
|
||||||
|
this.paymentMethods = [];
|
||||||
|
this.walletPaymentAmount = 0;
|
||||||
|
|
||||||
|
// Модальное окно
|
||||||
|
this.modalInstance = null;
|
||||||
|
|
||||||
|
// Callback'и
|
||||||
|
this.onCheckoutComplete = options.onCheckoutComplete || null;
|
||||||
|
this.onCheckoutError = options.onCheckoutError || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует checkout модальное окно
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
this.initEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модальное окно checkout
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
if (this.cart.isEmpty) {
|
||||||
|
showToast('error', 'Корзина пуста. Добавьте товары перед продажей.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCheckoutModal();
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('checkoutModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||||
|
this.modalInstance.show();
|
||||||
|
|
||||||
|
// Загружаем доступные скидки
|
||||||
|
this.loadAvailableDiscounts();
|
||||||
|
|
||||||
|
// Рассчитываем скидки
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрывает модальное окно checkout
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.modalInstance) {
|
||||||
|
this.modalInstance.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит содержимое модального окна checkout
|
||||||
|
*/
|
||||||
|
renderCheckoutModal() {
|
||||||
|
this.renderOrderItems();
|
||||||
|
this.renderPaymentWidget();
|
||||||
|
this.updateFinalPrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит список товаров в заказе
|
||||||
|
*/
|
||||||
|
renderOrderItems() {
|
||||||
|
const container = document.getElementById('checkoutItems');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
this.cart.toArray().forEach(({ key, item }) => {
|
||||||
|
const itemTotal = item.price * item.qty;
|
||||||
|
const typeIcon = (item.type === 'kit' || item.type === 'showcase_kit')
|
||||||
|
? '<i class="bi bi-box-seam text-info"></i> '
|
||||||
|
: '';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="small">${typeIcon}${item.name}</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.8rem;">
|
||||||
|
${formatMoney(item.price)} × ${roundQuantity(item.qty)}
|
||||||
|
${item.unit_name ? ' ' + item.unit_name : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fw-semibold">${formatMoney(itemTotal)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html || '<p class="text-muted text-center py-2">Корзина пуста</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит виджет оплаты
|
||||||
|
*/
|
||||||
|
renderPaymentWidget() {
|
||||||
|
const container = document.getElementById('paymentWidgetContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const total = this.getFinalTotal();
|
||||||
|
const customer = this.customerManager.getCurrentCustomer();
|
||||||
|
const walletBalance = customer?.wallet_balance || 0;
|
||||||
|
|
||||||
|
if (this.paymentMode === 'single') {
|
||||||
|
container.innerHTML = this.renderSinglePaymentWidget(total, walletBalance);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = this.renderMixedPaymentWidget(total, walletBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initPaymentEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит виджет одиночной оплаты
|
||||||
|
*/
|
||||||
|
renderSinglePaymentWidget(total, walletBalance) {
|
||||||
|
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small mb-1">Способ оплаты</label>
|
||||||
|
<select class="form-select form-select-sm" id="singlePaymentMethod">
|
||||||
|
<option value="cash">Наличные</option>
|
||||||
|
<option value="card">Карта</option>
|
||||||
|
<option value="bank_transfer">Банковский перевод</option>
|
||||||
|
${!isSystemCustomer && walletBalance > 0 ? `<option value="wallet">Кошелёк клиента (${formatMoney(walletBalance)} руб.)</option>` : ''}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="cashChangeBlock" style="display: none;">
|
||||||
|
<label class="form-label small mb-1">Получено наличных</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="cashReceived"
|
||||||
|
placeholder="0.00" step="0.01" min="0">
|
||||||
|
<div class="mt-1 small">
|
||||||
|
<span class="text-muted">Сдача:</span>
|
||||||
|
<span class="fw-semibold" id="cashChange">0.00 руб.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит виджет смешанной оплаты
|
||||||
|
*/
|
||||||
|
renderMixedPaymentWidget(total, walletBalance) {
|
||||||
|
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||||
|
let html = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small mb-1">Распределение оплаты</label>
|
||||||
|
<div class="border rounded p-2">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Наличные
|
||||||
|
html += `
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label class="small mb-0">Наличные</label>
|
||||||
|
</div>
|
||||||
|
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||||
|
data-method="cash" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Карта
|
||||||
|
html += `
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label class="small mb-0">Карта</label>
|
||||||
|
</div>
|
||||||
|
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||||
|
data-method="card" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Кошелёк (если не системный клиент)
|
||||||
|
if (!isSystemCustomer && walletBalance > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label class="small mb-0">Кошелёк</label>
|
||||||
|
<small class="text-muted d-block">Доступно: ${formatMoney(walletBalance)}</small>
|
||||||
|
</div>
|
||||||
|
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||||
|
data-method="wallet" placeholder="0.00" step="0.01" min="0" max="${walletBalance}" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex justify-content-between small">
|
||||||
|
<span class="text-muted">Введено:</span>
|
||||||
|
<span id="mixedPaymentTotal">0.00 руб.</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between small">
|
||||||
|
<span class="text-muted">Осталось:</span>
|
||||||
|
<span id="mixedPaymentRemaining" class="fw-semibold">${formatMoney(total)} руб.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует обработчики событий оплаты
|
||||||
|
*/
|
||||||
|
initPaymentEventListeners() {
|
||||||
|
// Переключение режима оплаты
|
||||||
|
document.getElementById('singlePaymentMode')?.addEventListener('click', () => {
|
||||||
|
this.paymentMode = 'single';
|
||||||
|
document.getElementById('singlePaymentMode')?.classList.add('active');
|
||||||
|
document.getElementById('mixedPaymentMode')?.classList.remove('active');
|
||||||
|
this.renderPaymentWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mixedPaymentMode')?.addEventListener('click', () => {
|
||||||
|
this.paymentMode = 'mixed';
|
||||||
|
document.getElementById('mixedPaymentMode')?.classList.add('active');
|
||||||
|
document.getElementById('singlePaymentMode')?.classList.remove('active');
|
||||||
|
this.renderPaymentWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показ/скрытие блока сдачи
|
||||||
|
document.getElementById('singlePaymentMethod')?.addEventListener('change', (e) => {
|
||||||
|
const cashBlock = document.getElementById('cashChangeBlock');
|
||||||
|
if (cashBlock) {
|
||||||
|
cashBlock.style.display = e.target.value === 'cash' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Расчёт сдачи
|
||||||
|
document.getElementById('cashReceived')?.addEventListener('input', (e) => {
|
||||||
|
const received = parseFloat(e.target.value) || 0;
|
||||||
|
const total = this.getFinalTotal();
|
||||||
|
const change = Math.max(0, received - total);
|
||||||
|
document.getElementById('cashChange').textContent = formatMoney(change) + ' руб.';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Смешанная оплата - пересчёт сумм
|
||||||
|
document.querySelectorAll('.mixed-payment-input')?.forEach(input => {
|
||||||
|
input.addEventListener('input', () => this.updateMixedPaymentTotals());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет суммы при смешанной оплате
|
||||||
|
*/
|
||||||
|
updateMixedPaymentTotals() {
|
||||||
|
const total = this.getFinalTotal();
|
||||||
|
let entered = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||||
|
entered += parseFloat(input.value) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = Math.max(0, total - entered);
|
||||||
|
|
||||||
|
const totalEl = document.getElementById('mixedPaymentTotal');
|
||||||
|
const remainingEl = document.getElementById('mixedPaymentRemaining');
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = formatMoney(entered) + ' руб.';
|
||||||
|
if (remainingEl) {
|
||||||
|
remainingEl.textContent = formatMoney(remaining) + ' руб.';
|
||||||
|
remainingEl.className = remaining > 0 ? 'fw-semibold text-danger' : 'fw-semibold text-success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует обработчики событий
|
||||||
|
*/
|
||||||
|
initEventListeners() {
|
||||||
|
// Промокод
|
||||||
|
document.getElementById('applyPromoBtn')?.addEventListener('click', () => this.applyPromoCode());
|
||||||
|
document.getElementById('removePromoBtn')?.addEventListener('click', () => this.removePromoCode());
|
||||||
|
document.getElementById('promoCodeInput')?.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') this.applyPromoCode();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Произвольная скидка
|
||||||
|
document.getElementById('applyCustomDiscountBtn')?.addEventListener('click', () => this.applyCustomDiscount());
|
||||||
|
document.getElementById('removeCustomDiscountBtn')?.addEventListener('click', () => this.removeCustomDiscount());
|
||||||
|
document.getElementById('customDiscountInput')?.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') this.applyCustomDiscount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отмена автоматической скидки
|
||||||
|
document.getElementById('skipAutoDiscountBtn')?.addEventListener('click', () => this.skipAutoDiscounts());
|
||||||
|
|
||||||
|
// Удаление ручной скидки
|
||||||
|
document.getElementById('removeManualDiscountBtn')?.addEventListener('click', () => this.removeManualDiscount());
|
||||||
|
|
||||||
|
// Подтверждение продажи
|
||||||
|
document.getElementById('confirmCheckoutBtn')?.addEventListener('click', () => this.confirmCheckout());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает доступные скидки
|
||||||
|
*/
|
||||||
|
async loadAvailableDiscounts() {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.DISCOUNTS_AVAILABLE);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.availableDiscounts = data.discounts || [];
|
||||||
|
this.renderDiscountsDropdown();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки скидок:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит dropdown со скидками
|
||||||
|
*/
|
||||||
|
renderDiscountsDropdown() {
|
||||||
|
const list = document.getElementById('discountsDropdownList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (this.availableDiscounts.length === 0) {
|
||||||
|
list.innerHTML = '<li><span class="dropdown-item-text small text-muted">Нет доступных скидок</span></li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
this.availableDiscounts.forEach(discount => {
|
||||||
|
const icon = getCombineModeIcon(discount.combine_mode);
|
||||||
|
const valueText = discount.type === 'percent'
|
||||||
|
? `${discount.value}%`
|
||||||
|
: `${formatMoney(discount.value)} руб.`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item small" type="button" data-discount-id="${discount.id}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span>${discount.name}</span>
|
||||||
|
<span class="badge bg-primary ms-2">${valueText}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">${icon} ${getCombineModeTitle(discount.combine_mode)}</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
list.innerHTML = html;
|
||||||
|
|
||||||
|
// Добавляем обработчики
|
||||||
|
list.querySelectorAll('[data-discount-id]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const discountId = parseInt(e.currentTarget.dataset.discountId);
|
||||||
|
this.applyManualDiscount(discountId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рассчитывает скидки
|
||||||
|
*/
|
||||||
|
async calculateDiscounts() {
|
||||||
|
try {
|
||||||
|
const items = this.cart.toArray().map(({ item }) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
price: item.price,
|
||||||
|
quantity: item.qty
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
items: items,
|
||||||
|
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||||
|
skip_auto: this.skipAutoDiscount,
|
||||||
|
manual_discount_id: this.appliedManualDiscount?.id || null,
|
||||||
|
custom_discount: this.appliedCustomDiscount,
|
||||||
|
promo_code: this.appliedPromoCode
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await safeFetch(CONFIG.API.DISCOUNTS_CALCULATE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.cartDiscounts = {
|
||||||
|
orderDiscounts: data.order_discounts || [],
|
||||||
|
itemDiscounts: data.item_discounts || [],
|
||||||
|
totalDiscount: data.total_discount || 0,
|
||||||
|
excludedBy: data.excluded_by || null
|
||||||
|
};
|
||||||
|
this.renderDiscounts();
|
||||||
|
this.updateFinalPrice();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка расчёта скидок:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит информацию о скидках
|
||||||
|
*/
|
||||||
|
renderDiscounts() {
|
||||||
|
// Автоматические скидки
|
||||||
|
const autoContainer = document.getElementById('autoDiscountsContainer');
|
||||||
|
const autoList = document.getElementById('autoDiscountsList');
|
||||||
|
const skipBtn = document.getElementById('skipAutoDiscountBtn');
|
||||||
|
|
||||||
|
if (this.cartDiscounts.orderDiscounts.length > 0 && !this.skipAutoDiscount) {
|
||||||
|
if (autoContainer) autoContainer.style.display = 'block';
|
||||||
|
if (skipBtn) skipBtn.style.display = 'block';
|
||||||
|
|
||||||
|
if (autoList) {
|
||||||
|
autoList.innerHTML = this.cartDiscounts.orderDiscounts.map(d => {
|
||||||
|
const valueText = d.type === 'percent' ? `${d.value}%` : `${formatMoney(d.value)} руб.`;
|
||||||
|
return `<div>${d.name}: ${valueText}</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (autoContainer) autoContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ручная скидка
|
||||||
|
const manualContainer = document.getElementById('manualDiscountContainer');
|
||||||
|
if (this.appliedManualDiscount && manualContainer) {
|
||||||
|
manualContainer.style.display = 'block';
|
||||||
|
document.getElementById('manualDiscountName').textContent = this.appliedManualDiscount.name;
|
||||||
|
const valueText = this.appliedManualDiscount.type === 'percent'
|
||||||
|
? `${this.appliedManualDiscount.value}%`
|
||||||
|
: `${formatMoney(this.appliedManualDiscount.value)} руб.`;
|
||||||
|
document.getElementById('manualDiscountAmount').textContent = valueText;
|
||||||
|
} else if (manualContainer) {
|
||||||
|
manualContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Промокод
|
||||||
|
const promoSuccess = document.getElementById('promoCodeSuccess');
|
||||||
|
const removePromoBtn = document.getElementById('removePromoBtn');
|
||||||
|
const applyPromoBtn = document.getElementById('applyPromoBtn');
|
||||||
|
|
||||||
|
if (this.appliedPromoCode) {
|
||||||
|
if (promoSuccess) {
|
||||||
|
promoSuccess.style.display = 'block';
|
||||||
|
promoSuccess.textContent = `Промокод "${this.appliedPromoCode.code}" применён: -${formatMoney(this.appliedPromoCode.discount)} руб.`;
|
||||||
|
}
|
||||||
|
if (removePromoBtn) removePromoBtn.style.display = 'block';
|
||||||
|
if (applyPromoBtn) applyPromoBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (promoSuccess) promoSuccess.style.display = 'none';
|
||||||
|
if (removePromoBtn) removePromoBtn.style.display = 'none';
|
||||||
|
if (applyPromoBtn) applyPromoBtn.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Произвольная скидка
|
||||||
|
const removeCustomBtn = document.getElementById('removeCustomDiscountBtn');
|
||||||
|
const applyCustomBtn = document.getElementById('applyCustomDiscountBtn');
|
||||||
|
|
||||||
|
if (this.appliedCustomDiscount) {
|
||||||
|
if (removeCustomBtn) removeCustomBtn.style.display = 'block';
|
||||||
|
if (applyCustomBtn) applyCustomBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (removeCustomBtn) removeCustomBtn.style.display = 'none';
|
||||||
|
if (applyCustomBtn) applyCustomBtn.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Итоговая информация
|
||||||
|
const summary = document.getElementById('discountsSummary');
|
||||||
|
if (this.cartDiscounts.totalDiscount > 0 && summary) {
|
||||||
|
summary.style.display = 'block';
|
||||||
|
document.getElementById('discountsSubtotal').textContent = formatMoney(this.cart.total) + ' руб.';
|
||||||
|
document.getElementById('discountsTotalDiscount').textContent = '-' + formatMoney(this.cartDiscounts.totalDiscount) + ' руб.';
|
||||||
|
} else if (summary) {
|
||||||
|
summary.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет промокод
|
||||||
|
*/
|
||||||
|
async applyPromoCode() {
|
||||||
|
const input = document.getElementById('promoCodeInput');
|
||||||
|
const code = input?.value.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
this.showPromoError('Введите промокод');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.VALIDATE_PROMO, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code,
|
||||||
|
cart_total: this.cart.total,
|
||||||
|
customer_id: this.customerManager.getCurrentCustomer()?.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.appliedPromoCode = {
|
||||||
|
code: code,
|
||||||
|
discount: data.discount_amount,
|
||||||
|
promoCodeId: data.promo_code_id
|
||||||
|
};
|
||||||
|
this.hidePromoError();
|
||||||
|
this.calculateDiscounts();
|
||||||
|
} else {
|
||||||
|
this.showPromoError(data.error || 'Недействительный промокод');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showPromoError('Ошибка при проверке промокода');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет промокод
|
||||||
|
*/
|
||||||
|
removePromoCode() {
|
||||||
|
this.appliedPromoCode = null;
|
||||||
|
const input = document.getElementById('promoCodeInput');
|
||||||
|
if (input) input.value = '';
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает ошибку промокода
|
||||||
|
*/
|
||||||
|
showPromoError(message) {
|
||||||
|
const errorEl = document.getElementById('promoCodeError');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрывает ошибку промокода
|
||||||
|
*/
|
||||||
|
hidePromoError() {
|
||||||
|
const errorEl = document.getElementById('promoCodeError');
|
||||||
|
if (errorEl) errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет ручную скидку
|
||||||
|
*/
|
||||||
|
applyManualDiscount(discountId) {
|
||||||
|
const discount = this.availableDiscounts.find(d => d.id === discountId);
|
||||||
|
if (discount) {
|
||||||
|
this.appliedManualDiscount = discount;
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет ручную скидку
|
||||||
|
*/
|
||||||
|
removeManualDiscount() {
|
||||||
|
this.appliedManualDiscount = null;
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет произвольную скидку
|
||||||
|
*/
|
||||||
|
applyCustomDiscount() {
|
||||||
|
const input = document.getElementById('customDiscountInput');
|
||||||
|
const isPercentCheckbox = document.getElementById('customDiscountIsPercent');
|
||||||
|
|
||||||
|
const value = parseFloat(input?.value);
|
||||||
|
if (!value || value <= 0) {
|
||||||
|
this.showCustomDiscountError('Введите положительное значение');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPercent = isPercentCheckbox?.checked;
|
||||||
|
|
||||||
|
if (isPercent && value > 100) {
|
||||||
|
this.showCustomDiscountError('Процент не может превышать 100%');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appliedCustomDiscount = {
|
||||||
|
type: isPercent ? 'percent' : 'fixed',
|
||||||
|
value: value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.hideCustomDiscountError();
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет произвольную скидку
|
||||||
|
*/
|
||||||
|
removeCustomDiscount() {
|
||||||
|
this.appliedCustomDiscount = null;
|
||||||
|
const input = document.getElementById('customDiscountInput');
|
||||||
|
if (input) input.value = '';
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает ошибку произвольной скидки
|
||||||
|
*/
|
||||||
|
showCustomDiscountError(message) {
|
||||||
|
const errorEl = document.getElementById('customDiscountError');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрывает ошибку произвольной скидки
|
||||||
|
*/
|
||||||
|
hideCustomDiscountError() {
|
||||||
|
const errorEl = document.getElementById('customDiscountError');
|
||||||
|
if (errorEl) errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пропускает автоматические скидки
|
||||||
|
*/
|
||||||
|
skipAutoDiscounts() {
|
||||||
|
this.skipAutoDiscount = true;
|
||||||
|
this.calculateDiscounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает итоговую сумму к оплате
|
||||||
|
*/
|
||||||
|
getFinalTotal() {
|
||||||
|
return Math.max(0, this.cart.total - this.cartDiscounts.totalDiscount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет отображение итоговой цены
|
||||||
|
*/
|
||||||
|
updateFinalPrice() {
|
||||||
|
const finalPriceEl = document.getElementById('checkoutFinalPrice');
|
||||||
|
if (finalPriceEl) {
|
||||||
|
finalPriceEl.textContent = formatMoney(this.getFinalTotal()) + ' руб.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает данные об оплате
|
||||||
|
*/
|
||||||
|
getPaymentData() {
|
||||||
|
if (this.paymentMode === 'single') {
|
||||||
|
const method = document.getElementById('singlePaymentMethod')?.value || 'cash';
|
||||||
|
const amount = this.getFinalTotal();
|
||||||
|
|
||||||
|
const payments = [{ payment_method: method, amount }];
|
||||||
|
|
||||||
|
// Если оплата наличными, добавляем информацию о сдаче
|
||||||
|
if (method === 'cash') {
|
||||||
|
const received = parseFloat(document.getElementById('cashReceived')?.value) || amount;
|
||||||
|
return {
|
||||||
|
payments,
|
||||||
|
cash_received: received,
|
||||||
|
change: Math.max(0, received - amount)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payments };
|
||||||
|
} else {
|
||||||
|
const payments = [];
|
||||||
|
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||||
|
const amount = parseFloat(input.value) || 0;
|
||||||
|
if (amount > 0) {
|
||||||
|
payments.push({
|
||||||
|
payment_method: input.dataset.method,
|
||||||
|
amount: amount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { payments };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтверждает продажу
|
||||||
|
*/
|
||||||
|
async confirmCheckout() {
|
||||||
|
const paymentData = this.getPaymentData();
|
||||||
|
const total = this.getFinalTotal();
|
||||||
|
|
||||||
|
// Проверяем сумму оплаты
|
||||||
|
const totalPaid = paymentData.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
|
if (this.paymentMode === 'mixed' && Math.abs(totalPaid - total) > 0.01) {
|
||||||
|
showToast('error', `Сумма оплаты (${formatMoney(totalPaid)}) не совпадает с итогом (${formatMoney(total)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем данные заказа
|
||||||
|
const orderData = {
|
||||||
|
items: this.cart.toArray().map(({ item }) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
quantity: item.qty,
|
||||||
|
price: item.price,
|
||||||
|
sales_unit_id: item.sales_unit_id || null
|
||||||
|
})),
|
||||||
|
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||||
|
warehouse_id: this.currentWarehouse?.id,
|
||||||
|
payments: paymentData.payments,
|
||||||
|
discounts: {
|
||||||
|
order_discounts: this.cartDiscounts.orderDiscounts,
|
||||||
|
total_discount: this.cartDiscounts.totalDiscount,
|
||||||
|
promo_code: this.appliedPromoCode,
|
||||||
|
manual_discount: this.appliedManualDiscount,
|
||||||
|
custom_discount: this.appliedCustomDiscount
|
||||||
|
},
|
||||||
|
note: document.getElementById('orderNote')?.value || '',
|
||||||
|
...paymentData
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.CHECKOUT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(orderData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast('success', `Продажа оформлена! Заказ #${data.order_id}`);
|
||||||
|
|
||||||
|
// Очищаем корзину
|
||||||
|
this.cart.clear();
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Вызываем callback
|
||||||
|
if (this.onCheckoutComplete) {
|
||||||
|
this.onCheckoutComplete(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переход к чеку или обновление страницы
|
||||||
|
if (data.receipt_url) {
|
||||||
|
window.open(data.receipt_url, '_blank');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', data.error || 'Ошибка при оформлении продажи');
|
||||||
|
if (this.onCheckoutError) {
|
||||||
|
this.onCheckoutError(data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при оформлении продажи:', error);
|
||||||
|
showToast('error', 'Ошибка сети при оформлении продажи');
|
||||||
|
if (this.onCheckoutError) {
|
||||||
|
this.onCheckoutError('Network error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает состояние checkout
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.appliedPromoCode = null;
|
||||||
|
this.appliedManualDiscount = null;
|
||||||
|
this.appliedCustomDiscount = null;
|
||||||
|
this.skipAutoDiscount = false;
|
||||||
|
this.paymentMode = 'single';
|
||||||
|
this.walletPaymentAmount = 0;
|
||||||
|
this.cartDiscounts = {
|
||||||
|
orderDiscounts: [],
|
||||||
|
itemDiscounts: [],
|
||||||
|
totalDiscount: 0,
|
||||||
|
excludedBy: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
85
myproject/pos/static/pos/js/config.js
Normal file
85
myproject/pos/static/pos/js/config.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Конфигурация POS Terminal
|
||||||
|
* Централизованное хранение констант и настроек
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
// API Endpoints
|
||||||
|
API: {
|
||||||
|
SAVE_CART: '/pos/api/save-cart/',
|
||||||
|
SET_CUSTOMER: '/pos/api/set-customer/',
|
||||||
|
ITEMS: '/pos/api/items/',
|
||||||
|
SHOWCASE_KITS: '/pos/api/showcase-kits/',
|
||||||
|
SHOWCASE_KIT_ADD: (id) => `/pos/api/showcase-kits/${id}/add-to-cart/`,
|
||||||
|
SHOWCASE_KIT_REMOVE: (id) => `/pos/api/showcase-kits/${id}/remove-from-cart/`,
|
||||||
|
SHOWCASE_KIT_RELEASE_ALL: '/pos/api/showcase-kits/release-all-my-locks/',
|
||||||
|
PRODUCT_KIT: (id) => `/pos/api/product-kits/${id}/`,
|
||||||
|
PRODUCT_KIT_UPDATE: (id) => `/pos/api/product-kits/${id}/update/`,
|
||||||
|
PRODUCT_KIT_DISASSEMBLE: (id) => `/pos/api/product-kits/${id}/disassemble/`,
|
||||||
|
PRODUCT_KIT_WRITE_OFF: (id) => `/pos/api/product-kits/${id}/write-off/`,
|
||||||
|
CREATE_TEMP_KIT: '/pos/api/create-temp-kit/',
|
||||||
|
GET_SHOWCASES: '/pos/api/get-showcases/',
|
||||||
|
SALES_UNITS: (productId) => `/products/api/products/${productId}/sales-units/`,
|
||||||
|
CHECKOUT: '/pos/api/checkout/',
|
||||||
|
SET_WAREHOUSE: (id) => `/pos/api/set-warehouse/${id}/`,
|
||||||
|
DISCOUNTS_CALCULATE: '/pos/api/discounts/calculate/',
|
||||||
|
DISCOUNTS_AVAILABLE: '/pos/api/discounts/available/',
|
||||||
|
VALIDATE_PROMO: '/pos/api/discounts/validate-promo/',
|
||||||
|
CREATE_DEFERRED_ORDER: '/orders/api/create-from-pos/',
|
||||||
|
CUSTOMER_SEARCH: '/customers/api/search/',
|
||||||
|
CUSTOMER_CREATE: '/customers/api/create/',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Таймауты (в миллисекундах)
|
||||||
|
TIMEOUTS: {
|
||||||
|
DEBOUNCE_SEARCH: 300,
|
||||||
|
DEBOUNCE_CART_SAVE: 500,
|
||||||
|
TOAST_DELAY: 5000,
|
||||||
|
SELECT2_DELAY: 300,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
PAGINATION: {
|
||||||
|
PAGE_SIZE: 60,
|
||||||
|
ROOT_MARGIN: '200px',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Форматирование чисел
|
||||||
|
NUMBERS: {
|
||||||
|
DECIMALS_QUANTITY: 3,
|
||||||
|
DECIMALS_MONEY: 2,
|
||||||
|
QUANTITY_STEP: 0.001,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
SEARCH: {
|
||||||
|
MIN_LENGTH: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// CSS классы
|
||||||
|
CSS: {
|
||||||
|
STOCK_GOOD: '#28a745',
|
||||||
|
STOCK_LOW: '#ffc107',
|
||||||
|
STOCK_NONE: '#dc3545',
|
||||||
|
SHOWCASE_BG: '#fff3cd',
|
||||||
|
SHOWCASE_BORDER: '#ffc107',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Мобильные устройства
|
||||||
|
MOBILE: {
|
||||||
|
SCREEN_WIDTH_THRESHOLD: 768,
|
||||||
|
USER_AGENTS: ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Windows Phone'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Заморозить конфигурацию для предотвращения изменений
|
||||||
|
Object.freeze(CONFIG);
|
||||||
|
Object.freeze(CONFIG.API);
|
||||||
|
Object.freeze(CONFIG.TIMEOUTS);
|
||||||
|
Object.freeze(CONFIG.PAGINATION);
|
||||||
|
Object.freeze(CONFIG.NUMBERS);
|
||||||
|
Object.freeze(CONFIG.SEARCH);
|
||||||
|
Object.freeze(CONFIG.CSS);
|
||||||
|
Object.freeze(CONFIG.MOBILE);
|
||||||
|
|
||||||
|
export default CONFIG;
|
||||||
290
myproject/pos/static/pos/js/customer.js
Normal file
290
myproject/pos/static/pos/js/customer.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* Модуль управления клиентами POS Terminal
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
import { getCsrfToken, safeFetch, showToast } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Customer
|
||||||
|
* @property {number} id - ID клиента
|
||||||
|
* @property {string} name - Имя клиента
|
||||||
|
* @property {number} [wallet_balance] - Баланс кошелька
|
||||||
|
* @property {string} [phone] - Телефон
|
||||||
|
* @property {string} [email] - Email
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CustomerManager {
|
||||||
|
/**
|
||||||
|
* @param {Customer} systemCustomer - Системный клиент по умолчанию
|
||||||
|
*/
|
||||||
|
constructor(systemCustomer) {
|
||||||
|
/** @type {Customer} */
|
||||||
|
this.systemCustomer = systemCustomer;
|
||||||
|
|
||||||
|
/** @type {Customer} */
|
||||||
|
this.selectedCustomer = systemCustomer;
|
||||||
|
|
||||||
|
/** @type {Function[]} */
|
||||||
|
this.listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет слушатель изменений
|
||||||
|
* @param {Function} callback - Функция обратного вызова
|
||||||
|
*/
|
||||||
|
addListener(callback) {
|
||||||
|
this.listeners.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет слушатель изменений
|
||||||
|
* @param {Function} callback - Функция обратного вызова
|
||||||
|
*/
|
||||||
|
removeListener(callback) {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уведомляет всех слушателей
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_notify() {
|
||||||
|
this.listeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(this.selectedCustomer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка в слушателе клиента:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, выбран ли системный клиент
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isSystemCustomer() {
|
||||||
|
return Number(this.selectedCustomer.id) === Number(this.systemCustomer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает текущего клиента
|
||||||
|
* @returns {Customer}
|
||||||
|
*/
|
||||||
|
getCurrentCustomer() {
|
||||||
|
return this.selectedCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает отображаемое имя клиента
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getDisplayName() {
|
||||||
|
return this.isSystemCustomer() ? 'Системный клиент' : this.selectedCustomer.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает клиента
|
||||||
|
* @param {Customer} customer - Данные клиента
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async selectCustomer(customer) {
|
||||||
|
this.selectedCustomer = customer;
|
||||||
|
this._notify();
|
||||||
|
|
||||||
|
// Сохраняем выбор на сервере
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${CONFIG.API.SET_CUSTOMER}${customer.id}/`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
console.error('Ошибка сохранения клиента:', data.error);
|
||||||
|
showToast('error', 'Ошибка сохранения выбора клиента');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем баланс из ответа сервера
|
||||||
|
this.selectedCustomer.wallet_balance = data.wallet_balance || 0;
|
||||||
|
this._notify();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при сохранении клиента:', error);
|
||||||
|
showToast('error', 'Ошибка сети при сохранении клиента');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает на системного клиента
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async resetToSystem() {
|
||||||
|
return this.selectCustomer(this.systemCustomer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт нового клиента
|
||||||
|
* @param {Object} data - Данные клиента
|
||||||
|
* @param {string} data.name - Имя
|
||||||
|
* @param {string} [data.phone] - Телефон
|
||||||
|
* @param {string} [data.email] - Email
|
||||||
|
* @returns {Promise<{success: boolean, customer?: Customer, error?: string}>}
|
||||||
|
*/
|
||||||
|
async createCustomer(data) {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.CUSTOMER_CREATE,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone || null,
|
||||||
|
email: data.email || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const customer = {
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
wallet_balance: result.wallet_balance || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Автоматически выбираем созданного клиента
|
||||||
|
await this.selectCustomer(customer);
|
||||||
|
|
||||||
|
showToast('success', `Клиент "${result.name}" успешно создан!`);
|
||||||
|
return { success: true, customer };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: result.error || 'Ошибка при создании клиента' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
return { success: false, error: 'Ошибка сети при создании клиента' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует Select2 для поиска клиента
|
||||||
|
* @param {string} selector - CSS селектор
|
||||||
|
* @param {Object} options - Дополнительные опции
|
||||||
|
*/
|
||||||
|
initSelect2(selector, options = {}) {
|
||||||
|
const $searchInput = $(selector);
|
||||||
|
const modalId = options.modalId || '#selectCustomerModal';
|
||||||
|
|
||||||
|
$searchInput.select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
dropdownParent: $(modalId),
|
||||||
|
placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)',
|
||||||
|
minimumInputLength: 3,
|
||||||
|
allowClear: true,
|
||||||
|
ajax: {
|
||||||
|
url: CONFIG.API.CUSTOMER_SEARCH,
|
||||||
|
dataType: 'json',
|
||||||
|
delay: CONFIG.TIMEOUTS.SELECT2_DELAY,
|
||||||
|
data: function (params) {
|
||||||
|
return { q: params.term };
|
||||||
|
},
|
||||||
|
processResults: function (data) {
|
||||||
|
return { results: data.results };
|
||||||
|
},
|
||||||
|
cache: true
|
||||||
|
},
|
||||||
|
templateResult: this._formatCustomerOption,
|
||||||
|
templateSelection: this._formatCustomerSelection
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка выбора
|
||||||
|
$searchInput.on('select2:select', async (e) => {
|
||||||
|
const data = e.params.data;
|
||||||
|
|
||||||
|
// Проверяем это не опция "Создать нового клиента"
|
||||||
|
if (data.id === 'create_new') {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
if (options.onCreateNew) {
|
||||||
|
options.onCreateNew(data.text);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбираем клиента
|
||||||
|
await this.selectCustomer({
|
||||||
|
id: parseInt(data.id),
|
||||||
|
name: data.name,
|
||||||
|
wallet_balance: data.wallet_balance || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Очищаем Select2
|
||||||
|
$searchInput.val(null).trigger('change');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует опцию клиента в выпадающем списке
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_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>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует выбранного клиента
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_formatCustomerSelection(customer) {
|
||||||
|
return customer.name || customer.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
myproject/pos/static/pos/js/modules.js
Normal file
36
myproject/pos/static/pos/js/modules.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Точка входа для модульной архитектуры POS Terminal
|
||||||
|
* Загружает все модули и инициализирует приложение
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Импорт конфигурации
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
|
||||||
|
// Импорт утилит
|
||||||
|
import * as utils from './utils.js';
|
||||||
|
|
||||||
|
// Импорт менеджеров
|
||||||
|
import { Cart } from './cart.js';
|
||||||
|
import { CustomerManager } from './customer.js';
|
||||||
|
import { ProductManager } from './products.js';
|
||||||
|
|
||||||
|
// Экспорт всего для глобального доступа
|
||||||
|
export {
|
||||||
|
CONFIG,
|
||||||
|
utils,
|
||||||
|
Cart,
|
||||||
|
CustomerManager,
|
||||||
|
ProductManager
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация при загрузке
|
||||||
|
export function initPOS() {
|
||||||
|
// Создаем глобальные ссылки для обратной совместимости
|
||||||
|
window.POS = {
|
||||||
|
CONFIG,
|
||||||
|
utils,
|
||||||
|
Cart,
|
||||||
|
CustomerManager,
|
||||||
|
ProductManager
|
||||||
|
};
|
||||||
|
}
|
||||||
544
myproject/pos/static/pos/js/products.js
Normal file
544
myproject/pos/static/pos/js/products.js
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
/**
|
||||||
|
* Модуль управления товарами и витриной POS Terminal
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
import {
|
||||||
|
safeJSONParse,
|
||||||
|
roundQuantity,
|
||||||
|
formatMoney,
|
||||||
|
formatDaysAgo,
|
||||||
|
safeFetch,
|
||||||
|
escapeHtml
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Product
|
||||||
|
* @property {number} id - ID товара
|
||||||
|
* @property {string} name - Название
|
||||||
|
* @property {string} [sku] - Артикул
|
||||||
|
* @property {number} price - Цена
|
||||||
|
* @property {number} [price_in_unit] - Цена в единице продажи
|
||||||
|
* @property {number} [available_qty] - Доступное количество
|
||||||
|
* @property {number} [reserved_qty] - Зарезервированное количество
|
||||||
|
* @property {boolean} has_sales_units - Есть ли единицы продажи
|
||||||
|
* @property {number} sales_units_count - Количество единиц продажи
|
||||||
|
* @property {Object} [default_sales_unit] - Единица продажи по умолчанию
|
||||||
|
* @property {string} [image] - URL изображения
|
||||||
|
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ShowcaseKit
|
||||||
|
* @property {number} id - ID комплекта
|
||||||
|
* @property {string} name - Название
|
||||||
|
* @property {string} [sku] - Артикул
|
||||||
|
* @property {number} price - Цена
|
||||||
|
* @property {number} available_count - Доступное количество
|
||||||
|
* @property {number} total_count - Общее количество
|
||||||
|
* @property {string} [showcase_created_at] - Дата создания на витрине
|
||||||
|
* @property {boolean} is_locked - Заблокирован ли
|
||||||
|
* @property {boolean} locked_by_me - Заблокирован текущим пользователем
|
||||||
|
* @property {string} [locked_by_user] - Кем заблокирован
|
||||||
|
* @property {boolean} price_outdated - Неактуальная цена
|
||||||
|
* @property {string} [image] - URL изображения
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ProductManager {
|
||||||
|
/**
|
||||||
|
* @param {Object} options - Опции
|
||||||
|
* @param {Object} options.currentWarehouse - Текущий склад
|
||||||
|
* @param {Function} options.onError - Callback при ошибке
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||||
|
this.onError = options.onError || console.error;
|
||||||
|
|
||||||
|
/** @type {Product[]} */
|
||||||
|
this.items = [];
|
||||||
|
|
||||||
|
/** @type {ShowcaseKit[]} */
|
||||||
|
this.showcaseKits = [];
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
|
this.currentPage = 1;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.hasMoreItems = false;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
this.currentSearchQuery = '';
|
||||||
|
|
||||||
|
/** @type {number|null} */
|
||||||
|
this.currentCategoryId = null;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.isShowcaseView = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает товары через API
|
||||||
|
* @param {Object} options - Опции загрузки
|
||||||
|
* @param {boolean} options.append - Добавлять к существующим
|
||||||
|
* @param {number} [options.categoryId] - ID категории
|
||||||
|
* @param {string} [options.searchQuery] - Поисковый запрос
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async loadItems(options = {}) {
|
||||||
|
if (this.isLoading) return false;
|
||||||
|
|
||||||
|
const { append = false, categoryId = null, searchQuery = '' } = options;
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.currentPage,
|
||||||
|
page_size: CONFIG.PAGINATION.PAGE_SIZE
|
||||||
|
});
|
||||||
|
|
||||||
|
// При активном поиске игнорируем категорию
|
||||||
|
if (categoryId && !searchQuery) {
|
||||||
|
params.append('category_id', categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
params.append('query', searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await safeFetch(`${CONFIG.API.ITEMS}?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (append) {
|
||||||
|
this.items = this.items.concat(data.items);
|
||||||
|
} else {
|
||||||
|
this.items = data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasMoreItems = data.has_more;
|
||||||
|
|
||||||
|
if (data.has_more) {
|
||||||
|
this.currentPage = data.next_page;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
this.onError('Ошибка загрузки товаров:', error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает витринные комплекты
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async loadShowcaseKits() {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.SHOWCASE_KITS);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showcaseKits = data.items;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
this.onError('Ошибка загрузки витринных комплектов:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает единицы продажи для товара
|
||||||
|
* @param {number} productId - ID товара
|
||||||
|
* @returns {Promise<Array|null>}
|
||||||
|
*/
|
||||||
|
async loadSalesUnits(productId) {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${CONFIG.API.SALES_UNITS(productId)}?warehouse=${this.currentWarehouse.id}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.sales_units) {
|
||||||
|
return data.sales_units;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
this.onError('Ошибка загрузки единиц продажи:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает infinite scroll
|
||||||
|
* @param {Function} onLoad - Callback при загрузке новых элементов
|
||||||
|
*/
|
||||||
|
setupInfiniteScroll(onLoad) {
|
||||||
|
const grid = document.getElementById('productGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting && this.hasMoreItems && !this.isLoading && !this.isShowcaseView) {
|
||||||
|
this.loadItems({ append: true }).then(() => {
|
||||||
|
if (onLoad) onLoad(this.items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: CONFIG.PAGINATION.ROOT_MARGIN }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Наблюдаем за концом грида
|
||||||
|
const sentinel = document.createElement('div');
|
||||||
|
sentinel.id = 'scroll-sentinel';
|
||||||
|
sentinel.style.height = '1px';
|
||||||
|
grid.parentElement.appendChild(sentinel);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает отфильтрованные элементы для отображения
|
||||||
|
* @param {Object} options - Опции фильтрации
|
||||||
|
* @param {boolean} options.wasShowcaseViewBeforeSearch - Была ли витрина активна
|
||||||
|
* @returns {Array} Отфильтрованные элементы
|
||||||
|
*/
|
||||||
|
getFilteredItems(options = {}) {
|
||||||
|
const { wasShowcaseViewBeforeSearch = false } = options;
|
||||||
|
|
||||||
|
// Если активен поиск (3+ символов)
|
||||||
|
if (this.currentSearchQuery && this.currentSearchQuery.length >= CONFIG.SEARCH.MIN_LENGTH) {
|
||||||
|
let filtered = [...this.items];
|
||||||
|
|
||||||
|
// Если перед началом поиска была активна витрина - добавляем витринные комплекты
|
||||||
|
if (wasShowcaseViewBeforeSearch) {
|
||||||
|
const searchTerm = this.currentSearchQuery.toLowerCase().trim();
|
||||||
|
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
|
||||||
|
|
||||||
|
const filteredShowcaseKits = this.showcaseKits.filter(item => {
|
||||||
|
const name = (item.name || '').toLowerCase();
|
||||||
|
const sku = (item.sku || '').toLowerCase();
|
||||||
|
return tokens.every(token => name.includes(token) || sku.includes(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered = [...filtered, ...filteredShowcaseKits];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
} else if (this.isShowcaseView) {
|
||||||
|
// При отображении витрины возвращаем showcaseKits
|
||||||
|
return this.showcaseKits || [];
|
||||||
|
} else {
|
||||||
|
return this.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит карточку товара
|
||||||
|
* @param {Product|ShowcaseKit} item - Товар или комплект
|
||||||
|
* @param {Object} options - Опции рендера
|
||||||
|
* @param {Map} options.cart - Корзина для проверки наличия
|
||||||
|
* @returns {HTMLElement} DOM элемент карточки
|
||||||
|
*/
|
||||||
|
renderProductCard(item, options = {}) {
|
||||||
|
const { cart = new Map() } = options;
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Обработка витринных комплектов
|
||||||
|
if (item.type === 'showcase_kit') {
|
||||||
|
this._renderShowcaseKitBadges(card, item, cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'card-body';
|
||||||
|
|
||||||
|
// Изображение
|
||||||
|
const imageDiv = this._createImageElement(item.image, item.name);
|
||||||
|
|
||||||
|
// Информация о товаре
|
||||||
|
const info = this._createProductInfo(item, cart);
|
||||||
|
|
||||||
|
body.appendChild(imageDiv);
|
||||||
|
body.appendChild(info);
|
||||||
|
card.appendChild(body);
|
||||||
|
col.appendChild(card);
|
||||||
|
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает элемент изображения
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createImageElement(imageUrl, alt) {
|
||||||
|
const imageDiv = document.createElement('div');
|
||||||
|
imageDiv.className = 'product-image';
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imageUrl;
|
||||||
|
img.alt = alt;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
imageDiv.appendChild(img);
|
||||||
|
} else {
|
||||||
|
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает информацию о товаре
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createProductInfo(item, cart) {
|
||||||
|
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';
|
||||||
|
this._renderStockInfo(stock, item, cart);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит информацию об остатках
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderStockInfo(element, item, cart) {
|
||||||
|
// Для витринных комплектов
|
||||||
|
if (item.type === 'showcase_kit') {
|
||||||
|
this._renderShowcaseKitStock(element, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для обычных товаров с единицами продажи
|
||||||
|
if (item.type === 'product' && item.default_sales_unit) {
|
||||||
|
this._renderProductStockWithUnit(element, item, cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для обычных товаров без единиц продажи
|
||||||
|
if (item.type === 'product') {
|
||||||
|
this._renderProductStock(element, item, cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для комплектов
|
||||||
|
if (item.type === 'kit' && item.free_qty !== undefined) {
|
||||||
|
this._renderKitStock(element, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит остатки витринного комплекта
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderShowcaseKitStock(element, item) {
|
||||||
|
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>` : '';
|
||||||
|
|
||||||
|
const daysAgo = formatDaysAgo(item.showcase_created_at);
|
||||||
|
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
|
||||||
|
|
||||||
|
element.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
|
||||||
|
element.style.display = 'flex';
|
||||||
|
element.style.justifyContent = 'space-between';
|
||||||
|
element.style.alignItems = 'center';
|
||||||
|
element.style.color = '#856404';
|
||||||
|
element.style.fontWeight = 'bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит остатки товара с единицей продажи
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderProductStockWithUnit(element, item, cart) {
|
||||||
|
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 = (parseFloat(item.reserved_qty) || 0) * 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';
|
||||||
|
|
||||||
|
element.appendChild(freeSpan);
|
||||||
|
element.appendChild(suffixSpan);
|
||||||
|
} else {
|
||||||
|
element.appendChild(freeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цветовая индикация
|
||||||
|
if (freeInUnit <= 0) {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||||
|
} else if (freeInUnit < 5) {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||||
|
} else {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит остатки товара без единицы продажи
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderProductStock(element, item, cart) {
|
||||||
|
const available = parseFloat(item.available_qty) || 0;
|
||||||
|
const reserved = parseFloat(item.reserved_qty) || 0;
|
||||||
|
const cartKey = `product-${item.id}`;
|
||||||
|
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||||
|
|
||||||
|
const free = available - reserved - inCart;
|
||||||
|
const freeRounded = roundQuantity(free);
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
if (inCart > 0) {
|
||||||
|
suffixParts.push(`−${roundQuantity(inCart)}🛒`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffixParts.length > 0) {
|
||||||
|
const suffixSpan = document.createElement('span');
|
||||||
|
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
|
||||||
|
suffixSpan.style.fontSize = '0.85em';
|
||||||
|
suffixSpan.style.marginLeft = '3px';
|
||||||
|
|
||||||
|
element.appendChild(freeSpan);
|
||||||
|
element.appendChild(suffixSpan);
|
||||||
|
} else {
|
||||||
|
element.appendChild(freeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (free <= 0) {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||||
|
} else if (free < 5) {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||||
|
} else {
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит остатки комплекта
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderKitStock(element, item) {
|
||||||
|
const availableKits = parseFloat(item.free_qty) || 0;
|
||||||
|
if (availableKits > 0) {
|
||||||
|
element.textContent = `В наличии: ${Math.floor(availableKits)} компл.`;
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||||
|
} else {
|
||||||
|
element.textContent = 'Под заказ';
|
||||||
|
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит бейджи для витринного комплекта
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderShowcaseKitBadges(card, item, cart) {
|
||||||
|
// Индикация блокировки
|
||||||
|
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.appendChild(lockBadge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
672
myproject/pos/static/pos/js/showcase.js
Normal file
672
myproject/pos/static/pos/js/showcase.js
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
/**
|
||||||
|
* Модуль управления витриной POS Terminal
|
||||||
|
* Создание временных комплектов, управление букетами на витрине
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
import {
|
||||||
|
safeJSONParse,
|
||||||
|
roundQuantity,
|
||||||
|
formatMoney,
|
||||||
|
getCsrfToken,
|
||||||
|
showToast,
|
||||||
|
safeFetch,
|
||||||
|
escapeHtml
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Showcase
|
||||||
|
* @property {number} id - ID витрины
|
||||||
|
* @property {string} name - Название
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TempKitData
|
||||||
|
* @property {string} name - Название комплекта
|
||||||
|
* @property {number} showcaseId - ID витрины
|
||||||
|
* @property {number} quantity - Количество букетов
|
||||||
|
* @property {string} [description] - Описание
|
||||||
|
* @property {string} [createdAt] - Дата создания
|
||||||
|
* @property {number} basePrice - Базовая цена
|
||||||
|
* @property {string} adjustmentType - Тип корректировки цены
|
||||||
|
* @property {number} adjustmentValue - Значение корректировки
|
||||||
|
* @property {boolean} useSalePrice - Использовать свою цену
|
||||||
|
* @property {number} [salePrice] - Цена продажи
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ShowcaseManager {
|
||||||
|
/**
|
||||||
|
* @param {Object} options - Опции
|
||||||
|
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.cart = options.cart;
|
||||||
|
|
||||||
|
// Состояние
|
||||||
|
this.isEditMode = false;
|
||||||
|
this.editingKitId = null;
|
||||||
|
this.showcases = [];
|
||||||
|
this.tempCart = new Map(); // Временная корзина для модального окна
|
||||||
|
|
||||||
|
// Модальное окно
|
||||||
|
this.modalInstance = null;
|
||||||
|
|
||||||
|
// Callback'и
|
||||||
|
this.onKitCreated = options.onKitCreated || null;
|
||||||
|
this.onKitUpdated = options.onKitUpdated || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует модуль
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
this.initEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует обработчики событий
|
||||||
|
*/
|
||||||
|
initEventListeners() {
|
||||||
|
// Кнопка "На витрину"
|
||||||
|
document.getElementById('addToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||||
|
document.getElementById('mobileAddToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||||
|
|
||||||
|
// Кнопки модального окна
|
||||||
|
document.getElementById('confirmCreateTempKit')?.addEventListener('click', () => this.confirmCreateKit());
|
||||||
|
document.getElementById('disassembleKitBtn')?.addEventListener('click', () => this.disassembleKit());
|
||||||
|
document.getElementById('writeOffKitBtn')?.addEventListener('click', () => this.writeOffKit());
|
||||||
|
|
||||||
|
// Загрузка фото
|
||||||
|
document.getElementById('tempKitPhoto')?.addEventListener('change', (e) => this.handlePhotoUpload(e));
|
||||||
|
document.getElementById('removePhoto')?.addEventListener('click', () => this.removePhoto());
|
||||||
|
|
||||||
|
// Корректировка цены
|
||||||
|
document.getElementById('priceAdjustmentType')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||||
|
document.getElementById('priceAdjustmentValue')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||||
|
document.getElementById('useSalePrice')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||||
|
document.getElementById('salePrice')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модальное окно создания комплекта
|
||||||
|
*/
|
||||||
|
async openCreateModal() {
|
||||||
|
if (this.cart.isEmpty) {
|
||||||
|
showToast('error', 'Корзина пуста. Добавьте товары для создания комплекта.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isEditMode = false;
|
||||||
|
this.editingKitId = null;
|
||||||
|
this.tempCart = new Map(this.cart.items);
|
||||||
|
|
||||||
|
// Загружаем список витрин
|
||||||
|
await this.loadShowcases();
|
||||||
|
|
||||||
|
// Сбрасываем поля формы
|
||||||
|
this.resetForm();
|
||||||
|
|
||||||
|
// Заполняем список товаров
|
||||||
|
this.renderTempKitItems();
|
||||||
|
|
||||||
|
// Рассчитываем цены
|
||||||
|
this.updatePriceCalculation();
|
||||||
|
|
||||||
|
// Показываем модалку
|
||||||
|
const modalEl = document.getElementById('createTempKitModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||||
|
this.modalInstance.show();
|
||||||
|
|
||||||
|
// Обновляем UI для режима создания
|
||||||
|
this.updateModalUIForMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модальное окно редактирования комплекта
|
||||||
|
* @param {number} kitId - ID комплекта
|
||||||
|
*/
|
||||||
|
async openEditModal(kitId) {
|
||||||
|
this.isEditMode = true;
|
||||||
|
this.editingKitId = kitId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.PRODUCT_KIT(kitId));
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
showToast('error', 'Не удалось загрузить данные комплекта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kit = data.kit;
|
||||||
|
|
||||||
|
// Загружаем список витрин
|
||||||
|
await this.loadShowcases();
|
||||||
|
|
||||||
|
// Заполняем поля формы
|
||||||
|
document.getElementById('tempKitName').value = kit.name;
|
||||||
|
document.getElementById('showcaseSelect').value = kit.showcase_id || '';
|
||||||
|
document.getElementById('tempKitDescription').value = kit.description || '';
|
||||||
|
document.getElementById('showcaseCreatedAt').value = kit.showcase_created_at || '';
|
||||||
|
|
||||||
|
// Скрываем блок количества при редактировании
|
||||||
|
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||||
|
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||||
|
|
||||||
|
// Заполняем временную корзину
|
||||||
|
this.tempCart.clear();
|
||||||
|
if (kit.items) {
|
||||||
|
kit.items.forEach(item => {
|
||||||
|
const key = `product-${item.product_id}-${item.sales_unit_id || 'base'}`;
|
||||||
|
this.tempCart.set(key, {
|
||||||
|
id: item.product_id,
|
||||||
|
name: item.product_name,
|
||||||
|
price: item.price,
|
||||||
|
qty: item.quantity,
|
||||||
|
type: 'product',
|
||||||
|
sales_unit_id: item.sales_unit_id,
|
||||||
|
unit_name: item.unit_name
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем цены
|
||||||
|
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type || 'none';
|
||||||
|
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value || 0;
|
||||||
|
document.getElementById('useSalePrice').checked = kit.use_sale_price || false;
|
||||||
|
document.getElementById('salePrice').value = kit.sale_price || '';
|
||||||
|
|
||||||
|
this.renderTempKitItems();
|
||||||
|
this.updatePriceCalculation();
|
||||||
|
|
||||||
|
// Показываем модалку
|
||||||
|
const modalEl = document.getElementById('createTempKitModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||||
|
this.modalInstance.show();
|
||||||
|
|
||||||
|
// Обновляем UI для режима редактирования
|
||||||
|
this.updateModalUIForMode();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки комплекта:', error);
|
||||||
|
showToast('error', 'Ошибка при загрузке данных комплекта');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет UI модального окна в зависимости от режима
|
||||||
|
*/
|
||||||
|
updateModalUIForMode() {
|
||||||
|
const title = document.getElementById('createTempKitModalLabel');
|
||||||
|
const confirmBtn = document.getElementById('confirmCreateTempKit');
|
||||||
|
const disassembleBtn = document.getElementById('disassembleKitBtn');
|
||||||
|
const writeOffBtn = document.getElementById('writeOffKitBtn');
|
||||||
|
const addProductBlock = document.getElementById('addProductBlock');
|
||||||
|
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||||
|
|
||||||
|
if (this.isEditMode) {
|
||||||
|
if (title) title.innerHTML = '<i class="bi bi-pencil-square"></i> Редактировать комплект на витрине';
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Сохранить изменения';
|
||||||
|
}
|
||||||
|
if (disassembleBtn) disassembleBtn.style.display = 'block';
|
||||||
|
if (writeOffBtn) writeOffBtn.style.display = 'block';
|
||||||
|
if (addProductBlock) addProductBlock.style.display = 'block';
|
||||||
|
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (title) title.innerHTML = '<i class="bi bi-flower1"></i> Создать временный комплект на витрину';
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
||||||
|
}
|
||||||
|
if (disassembleBtn) disassembleBtn.style.display = 'none';
|
||||||
|
if (writeOffBtn) writeOffBtn.style.display = 'none';
|
||||||
|
if (addProductBlock) addProductBlock.style.display = 'none';
|
||||||
|
if (qtyBlock) qtyBlock.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает список витрин
|
||||||
|
*/
|
||||||
|
async loadShowcases() {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(CONFIG.API.GET_SHOWCASES);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showcases = data.showcases || [];
|
||||||
|
this.renderShowcaseSelect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки витрин:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит select с витринами
|
||||||
|
*/
|
||||||
|
renderShowcaseSelect() {
|
||||||
|
const select = document.getElementById('showcaseSelect');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
let html = '<option value="">Выберите витрину...</option>';
|
||||||
|
this.showcases.forEach(showcase => {
|
||||||
|
html += `<option value="${showcase.id}">${escapeHtml(showcase.name)}</option>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
select.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит список товаров во временной корзине
|
||||||
|
*/
|
||||||
|
renderTempKitItems() {
|
||||||
|
const container = document.getElementById('tempKitItemsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (this.tempCart.size === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted text-center mb-0">Нет товаров</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let totalBasePrice = 0;
|
||||||
|
|
||||||
|
this.tempCart.forEach((item, key) => {
|
||||||
|
const itemTotal = item.price * item.qty;
|
||||||
|
totalBasePrice += itemTotal;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-1 border-bottom">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="small">${escapeHtml(item.name)}</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.75rem;">
|
||||||
|
${formatMoney(item.price)} × ${roundQuantity(item.qty)}
|
||||||
|
${item.unit_name ? ' ' + item.unit_name : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fw-semibold small">${formatMoney(itemTotal)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Обновляем базовую цену
|
||||||
|
document.getElementById('tempKitBasePrice').textContent = formatMoney(totalBasePrice) + ' руб.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет расчёт цен
|
||||||
|
*/
|
||||||
|
updatePriceCalculation() {
|
||||||
|
const basePriceEl = document.getElementById('tempKitBasePrice');
|
||||||
|
const basePrice = parseFloat(basePriceEl?.textContent) || 0;
|
||||||
|
|
||||||
|
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||||
|
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||||
|
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||||
|
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||||
|
|
||||||
|
// Показываем/скрываем блок значения корректировки
|
||||||
|
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
|
||||||
|
if (adjustmentBlock) {
|
||||||
|
adjustmentBlock.style.display = adjustmentType === 'none' ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем/скрываем блок цены продажи
|
||||||
|
const salePriceBlock = document.getElementById('salePriceBlock');
|
||||||
|
if (salePriceBlock) {
|
||||||
|
salePriceBlock.style.display = useSalePrice ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем цену с корректировкой
|
||||||
|
let calculatedPrice = basePrice;
|
||||||
|
|
||||||
|
if (adjustmentType !== 'none' && adjustmentValue > 0) {
|
||||||
|
switch (adjustmentType) {
|
||||||
|
case 'increase_percent':
|
||||||
|
calculatedPrice = basePrice * (1 + adjustmentValue / 100);
|
||||||
|
break;
|
||||||
|
case 'increase_amount':
|
||||||
|
calculatedPrice = basePrice + adjustmentValue;
|
||||||
|
break;
|
||||||
|
case 'decrease_percent':
|
||||||
|
calculatedPrice = basePrice * (1 - adjustmentValue / 100);
|
||||||
|
break;
|
||||||
|
case 'decrease_amount':
|
||||||
|
calculatedPrice = basePrice - adjustmentValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.';
|
||||||
|
|
||||||
|
// Итоговая цена
|
||||||
|
const finalPrice = useSalePrice && salePrice > 0 ? salePrice : calculatedPrice;
|
||||||
|
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрабатывает загрузку фото
|
||||||
|
*/
|
||||||
|
handlePhotoUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const preview = document.getElementById('photoPreview');
|
||||||
|
const img = document.getElementById('photoPreviewImg');
|
||||||
|
if (preview && img) {
|
||||||
|
img.src = e.target.result;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет загруженное фото
|
||||||
|
*/
|
||||||
|
removePhoto() {
|
||||||
|
const input = document.getElementById('tempKitPhoto');
|
||||||
|
const preview = document.getElementById('photoPreview');
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (preview) preview.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает форму
|
||||||
|
*/
|
||||||
|
resetForm() {
|
||||||
|
document.getElementById('tempKitName').value = '';
|
||||||
|
document.getElementById('showcaseSelect').value = '';
|
||||||
|
document.getElementById('showcaseKitQuantity').value = '1';
|
||||||
|
document.getElementById('tempKitDescription').value = '';
|
||||||
|
document.getElementById('showcaseCreatedAt').value = '';
|
||||||
|
document.getElementById('priceAdjustmentType').value = 'none';
|
||||||
|
document.getElementById('priceAdjustmentValue').value = '0';
|
||||||
|
document.getElementById('useSalePrice').checked = false;
|
||||||
|
document.getElementById('salePrice').value = '';
|
||||||
|
this.removePhoto();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает данные формы
|
||||||
|
*/
|
||||||
|
getFormData() {
|
||||||
|
const name = document.getElementById('tempKitName')?.value.trim();
|
||||||
|
const showcaseId = parseInt(document.getElementById('showcaseSelect')?.value);
|
||||||
|
const quantity = parseInt(document.getElementById('showcaseKitQuantity')?.value) || 1;
|
||||||
|
const description = document.getElementById('tempKitDescription')?.value.trim();
|
||||||
|
const createdAt = document.getElementById('showcaseCreatedAt')?.value;
|
||||||
|
|
||||||
|
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||||
|
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||||
|
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||||
|
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||||
|
|
||||||
|
// Получаем фото
|
||||||
|
const photoInput = document.getElementById('tempKitPhoto');
|
||||||
|
const photoFile = photoInput?.files[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
showcaseId,
|
||||||
|
quantity,
|
||||||
|
description,
|
||||||
|
createdAt,
|
||||||
|
adjustmentType,
|
||||||
|
adjustmentValue,
|
||||||
|
useSalePrice,
|
||||||
|
salePrice,
|
||||||
|
photoFile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует данные формы
|
||||||
|
*/
|
||||||
|
validateForm(data) {
|
||||||
|
if (!data.name) {
|
||||||
|
showToast('error', 'Укажите название комплекта');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.showcaseId) {
|
||||||
|
showToast('error', 'Выберите витрину');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tempCart.size === 0) {
|
||||||
|
showToast('error', 'Добавьте товары в комплект');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтверждает создание/обновление комплекта
|
||||||
|
*/
|
||||||
|
async confirmCreateKit() {
|
||||||
|
const data = this.getFormData();
|
||||||
|
|
||||||
|
if (!this.validateForm(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем данные для отправки
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', data.name);
|
||||||
|
formData.append('showcase_id', data.showcaseId);
|
||||||
|
formData.append('description', data.description || '');
|
||||||
|
formData.append('showcase_created_at', data.createdAt || '');
|
||||||
|
formData.append('price_adjustment_type', data.adjustmentType);
|
||||||
|
formData.append('price_adjustment_value', data.adjustmentValue);
|
||||||
|
formData.append('use_sale_price', data.useSalePrice);
|
||||||
|
formData.append('sale_price', data.salePrice || 0);
|
||||||
|
|
||||||
|
if (!this.isEditMode) {
|
||||||
|
formData.append('quantity', data.quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем товары
|
||||||
|
const items = [];
|
||||||
|
this.tempCart.forEach((item, key) => {
|
||||||
|
items.push({
|
||||||
|
product_id: item.id,
|
||||||
|
quantity: item.qty,
|
||||||
|
price: item.price,
|
||||||
|
sales_unit_id: item.sales_unit_id || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
formData.append('items', JSON.stringify(items));
|
||||||
|
|
||||||
|
// Добавляем фото
|
||||||
|
if (data.photoFile) {
|
||||||
|
formData.append('photo', data.photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (this.isEditMode) {
|
||||||
|
// Обновление существующего комплекта
|
||||||
|
response = await fetch(CONFIG.API.PRODUCT_KIT_UPDATE(this.editingKitId), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Создание нового комплекта
|
||||||
|
response = await fetch(CONFIG.API.CREATE_TEMP_KIT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast('success', this.isEditMode ? 'Комплект обновлён' : 'Комплект создан и размещён на витрине');
|
||||||
|
|
||||||
|
// Очищаем корзину
|
||||||
|
this.cart.clear();
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Вызываем callback
|
||||||
|
if (this.isEditMode && this.onKitUpdated) {
|
||||||
|
this.onKitUpdated(result);
|
||||||
|
} else if (!this.isEditMode && this.onKitCreated) {
|
||||||
|
this.onKitCreated(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', result.error || 'Ошибка при сохранении комплекта');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при сохранении комплекта:', error);
|
||||||
|
showToast('error', 'Ошибка сети при сохранении комплекта');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбирает комплект
|
||||||
|
*/
|
||||||
|
async disassembleKit() {
|
||||||
|
if (!this.isEditMode || !this.editingKitId) return;
|
||||||
|
|
||||||
|
if (!confirm('Разобрать букет и вернуть товары на склад?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.PRODUCT_KIT_DISASSEMBLE(this.editingKitId),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast('success', 'Букет разобран, товары возвращены на склад');
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Обновляем отображение
|
||||||
|
if (this.onKitUpdated) {
|
||||||
|
this.onKitUpdated(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', result.error || 'Ошибка при разборке букета');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при разборке комплекта:', error);
|
||||||
|
showToast('error', 'Ошибка сети при разборке букета');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Списывает комплект
|
||||||
|
*/
|
||||||
|
async writeOffKit() {
|
||||||
|
if (!this.isEditMode || !this.editingKitId) return;
|
||||||
|
|
||||||
|
if (!confirm('Списать букет? Товары будут удалены из учёта.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
CONFIG.API.PRODUCT_KIT_WRITE_OFF(this.editingKitId),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast('success', 'Букет списан');
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Обновляем отображение
|
||||||
|
if (this.onKitUpdated) {
|
||||||
|
this.onKitUpdated(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', result.error || 'Ошибка при списании букета');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при списании комплекта:', error);
|
||||||
|
showToast('error', 'Ошибка сети при списании букета');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрывает модальное окно
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.modalInstance) {
|
||||||
|
this.modalInstance.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет товар во временную корзину (для режима редактирования)
|
||||||
|
* @param {Object} product - Данные товара
|
||||||
|
* @param {number} quantity - Количество
|
||||||
|
*/
|
||||||
|
addProductToTempCart(product, quantity = 1) {
|
||||||
|
const key = `product-${product.id}-${product.sales_unit_id || 'base'}`;
|
||||||
|
|
||||||
|
if (this.tempCart.has(key)) {
|
||||||
|
const existing = this.tempCart.get(key);
|
||||||
|
existing.qty = roundQuantity(existing.qty + quantity);
|
||||||
|
} else {
|
||||||
|
this.tempCart.set(key, {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
qty: quantity,
|
||||||
|
type: 'product',
|
||||||
|
sales_unit_id: product.sales_unit_id,
|
||||||
|
unit_name: product.unit_name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderTempKitItems();
|
||||||
|
this.updatePriceCalculation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет товар из временной корзины
|
||||||
|
* @param {string} key - Ключ товара
|
||||||
|
*/
|
||||||
|
removeProductFromTempCart(key) {
|
||||||
|
this.tempCart.delete(key);
|
||||||
|
this.renderTempKitItems();
|
||||||
|
this.updatePriceCalculation();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
286
myproject/pos/static/pos/js/utils.js
Normal file
286
myproject/pos/static/pos/js/utils.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты POS Terminal
|
||||||
|
* Общие функции для работы с числами, датами, DOM и т.д.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CONFIG from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасный парсинг JSON с обработкой ошибок
|
||||||
|
* @param {string} jsonString - JSON строка для парсинга
|
||||||
|
* @param {*} defaultValue - Значение по умолчанию при ошибке
|
||||||
|
* @returns {*} Распарсенный объект или defaultValue
|
||||||
|
*/
|
||||||
|
export function safeJSONParse(jsonString, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка парсинга JSON:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Округляет число до N знаков после запятой
|
||||||
|
* Решает проблему погрешности float arithmetic в JavaScript
|
||||||
|
* @param {number} value - Число для округления
|
||||||
|
* @param {number} decimals - Количество знаков после запятой
|
||||||
|
* @returns {number} Округлённое число
|
||||||
|
*/
|
||||||
|
export function roundQuantity(value, decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY) {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||||
|
if (!Number.isInteger(decimals) || decimals < 0) decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY;
|
||||||
|
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует число как денежную сумму
|
||||||
|
* @param {number} value - Сумма
|
||||||
|
* @returns {string} Отформатированная сумма
|
||||||
|
*/
|
||||||
|
export function formatMoney(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return '0.00';
|
||||||
|
return num.toFixed(CONFIG.NUMBERS.DECIMALS_MONEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату как относительное время в русском языке
|
||||||
|
* @param {string|null} isoDate - ISO дата или null
|
||||||
|
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
|
||||||
|
*/
|
||||||
|
export function formatDaysAgo(isoDate) {
|
||||||
|
if (!isoDate) return '';
|
||||||
|
|
||||||
|
const created = new Date(isoDate);
|
||||||
|
if (isNaN(created.getTime())) return '';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - created;
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Русские формы множественного числа
|
||||||
|
const lastTwo = diffDays % 100;
|
||||||
|
const lastOne = diffDays % 10;
|
||||||
|
|
||||||
|
let suffix;
|
||||||
|
if (lastTwo >= 11 && lastTwo <= 19) {
|
||||||
|
suffix = 'дней';
|
||||||
|
} else if (lastOne === 1) {
|
||||||
|
suffix = 'день';
|
||||||
|
} else if (lastOne >= 2 && lastOne <= 4) {
|
||||||
|
suffix = 'дня';
|
||||||
|
} else {
|
||||||
|
suffix = 'дней';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${diffDays} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Склонение слов в зависимости от числа
|
||||||
|
* @param {number} number - число
|
||||||
|
* @param {string} one - форма для 1 (товар)
|
||||||
|
* @param {string} two - форма для 2-4 (товара)
|
||||||
|
* @param {string} five - форма для 5+ (товаров)
|
||||||
|
* @returns {string} Правильная форма слова
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce функция
|
||||||
|
* @param {Function} func - Функция для debounce
|
||||||
|
* @param {number} wait - Задержка в миллисекундах
|
||||||
|
* @returns {Function} Функция с debounce
|
||||||
|
*/
|
||||||
|
export function debounce(func, wait = CONFIG.TIMEOUTS.DEBOUNCE_SEARCH) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает CSRF токен из DOM или cookie
|
||||||
|
* @returns {string|null} CSRF токен
|
||||||
|
*/
|
||||||
|
export function getCsrfToken() {
|
||||||
|
// Пытаемся найти токен в DOM (из {% csrf_token %})
|
||||||
|
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
if (csrfInput) {
|
||||||
|
return csrfInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: пытаемся прочитать из cookie
|
||||||
|
return getCookie('csrftoken');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает значение cookie по имени
|
||||||
|
* @param {string} name - Имя cookie
|
||||||
|
* @returns {string|null} Значение cookie
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли устройство мобильным
|
||||||
|
* @returns {boolean} true если мобильное устройство
|
||||||
|
*/
|
||||||
|
export function isMobileDevice() {
|
||||||
|
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||||
|
|
||||||
|
// Проверка по юзер-агенту
|
||||||
|
const isMobileUA = CONFIG.MOBILE.USER_AGENTS.some(keyword =>
|
||||||
|
userAgent.indexOf(keyword) > -1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверка по размеру экрана
|
||||||
|
const isSmallScreen = window.innerWidth < CONFIG.MOBILE.SCREEN_WIDTH_THRESHOLD;
|
||||||
|
|
||||||
|
return isMobileUA || isSmallScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает toast уведомление
|
||||||
|
* @param {'success'|'error'} type - Тип уведомления
|
||||||
|
* @param {string} message - Текст сообщения
|
||||||
|
*/
|
||||||
|
export function showToast(type, message) {
|
||||||
|
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
|
||||||
|
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
|
||||||
|
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
|
||||||
|
|
||||||
|
const toastElement = document.getElementById(toastId);
|
||||||
|
const messageElement = document.getElementById(messageId);
|
||||||
|
|
||||||
|
if (!toastElement || !messageElement) {
|
||||||
|
console.warn('Toast элементы не найдены в DOM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем сообщение
|
||||||
|
messageElement.textContent = message;
|
||||||
|
|
||||||
|
// Добавляем цвет фона
|
||||||
|
toastElement.classList.add(bgClass, 'text-white');
|
||||||
|
|
||||||
|
// Создаём и показываем toast
|
||||||
|
const toast = new bootstrap.Toast(toastElement, {
|
||||||
|
delay: CONFIG.TIMEOUTS.TOAST_DELAY,
|
||||||
|
autohide: true
|
||||||
|
});
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
// Убираем класс цвета после скрытия
|
||||||
|
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||||
|
toastElement.classList.remove(bgClass, 'text-white');
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасное экранирование HTML
|
||||||
|
* @param {string} text - Текст для экранирования
|
||||||
|
* @returns {string} Экранированный текст
|
||||||
|
*/
|
||||||
|
export function escapeHtml(text) {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает иконку для режима объединения скидок
|
||||||
|
* @param {string} mode - Режим объединения
|
||||||
|
* @returns {string} HTML иконки
|
||||||
|
*/
|
||||||
|
export 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] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает описание режима объединения скидок
|
||||||
|
* @param {string} mode - Режим объединения
|
||||||
|
* @returns {string} Описание
|
||||||
|
*/
|
||||||
|
export function getCombineModeTitle(mode) {
|
||||||
|
const titles = {
|
||||||
|
'stack': 'Складывается с другими скидками',
|
||||||
|
'max_only': 'Применяется только максимальная из этого типа',
|
||||||
|
'exclusive': 'Отменяет все другие скидки'
|
||||||
|
};
|
||||||
|
return titles[mode] || mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает AbortController с таймаутом
|
||||||
|
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||||
|
* @returns {AbortController} AbortController с таймаутом
|
||||||
|
*/
|
||||||
|
export function createAbortController(timeoutMs = 10000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасный fetch с обработкой ошибок и таймаутом
|
||||||
|
* @param {string} url - URL для запроса
|
||||||
|
* @param {Object} options - Опции fetch
|
||||||
|
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||||
|
* @returns {Promise<Response>} Ответ от сервера
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url, options = {}, timeoutMs = 10000) {
|
||||||
|
const controller = createAbortController(timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timeout');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -769,6 +769,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
|
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
|
||||||
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
<script type="module" src="{% static 'pos/js/terminal.js' %}"></script>
|
||||||
<script src="{% static 'pos/js/cart-item-editor.js' %}"></script>
|
<script src="{% static 'pos/js/cart-item-editor.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user