From 9dccc1f4b5371bb481cb87348e04b09f89cc9419 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 27 Jan 2026 18:27:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20POS=20=D1=82=D0=B5=D1=80=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B0=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?ES6=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pos/static/pos/js/cart-item-editor.js | 150 +- myproject/pos/static/pos/js/cart.js | 541 ++ myproject/pos/static/pos/js/checkout.js | 852 +++ myproject/pos/static/pos/js/config.js | 85 + myproject/pos/static/pos/js/customer.js | 290 ++ myproject/pos/static/pos/js/modules.js | 36 + myproject/pos/static/pos/js/products.js | 544 ++ myproject/pos/static/pos/js/showcase.js | 672 +++ myproject/pos/static/pos/js/terminal.js | 4620 ++++------------- myproject/pos/static/pos/js/utils.js | 286 + myproject/pos/templates/pos/terminal.html | 2 +- 11 files changed, 4321 insertions(+), 3757 deletions(-) create mode 100644 myproject/pos/static/pos/js/cart.js create mode 100644 myproject/pos/static/pos/js/checkout.js create mode 100644 myproject/pos/static/pos/js/config.js create mode 100644 myproject/pos/static/pos/js/customer.js create mode 100644 myproject/pos/static/pos/js/modules.js create mode 100644 myproject/pos/static/pos/js/products.js create mode 100644 myproject/pos/static/pos/js/showcase.js create mode 100644 myproject/pos/static/pos/js/utils.js diff --git a/myproject/pos/static/pos/js/cart-item-editor.js b/myproject/pos/static/pos/js/cart-item-editor.js index 86694f3..4c9f073 100644 --- a/myproject/pos/static/pos/js/cart-item-editor.js +++ b/myproject/pos/static/pos/js/cart-item-editor.js @@ -17,12 +17,35 @@ 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 - ключ товара в корзине */ 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) { console.error('CartItemEditor: Item not found for key:', cartKey); return; @@ -42,33 +65,35 @@ 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 - const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2); - document.getElementById('editModalBasePrice').textContent = fmtMoney(basePrice) + ' руб.'; - - document.getElementById('editModalPrice').value = roundPrice(basePrice); - document.getElementById('editModalQuantity').value = item.qty || 1; + if (nameEl) nameEl.textContent = item.name || '—'; + if (basePriceEl) basePriceEl.textContent = fmtMoney(basePrice) + ' руб.'; + if (priceInput) priceInput.value = roundPrice(basePrice); + if (qtyInput) qtyInput.value = item.qty || 1; // Для витринных комплектов блокируем изменение количества - const qtyInput = document.getElementById('editModalQuantity'); const qtyHint = document.getElementById('editModalQtyHint'); if (isShowcaseKit) { - qtyInput.disabled = true; - qtyHint.style.display = 'block'; + if (qtyInput) qtyInput.disabled = true; + if (qtyHint) qtyHint.style.display = 'block'; } else { - qtyInput.disabled = false; - qtyHint.style.display = 'none'; + if (qtyInput) qtyInput.disabled = false; + if (qtyHint) qtyHint.style.display = 'none'; } // Бейдж единицы измерения const unitBadge = document.getElementById('editModalUnitBadge'); - if (item.unit_name) { - unitBadge.textContent = item.unit_name; - unitBadge.style.display = 'inline-block'; - } else { - unitBadge.style.display = 'none'; + if (unitBadge) { + if (item.unit_name) { + unitBadge.textContent = item.unit_name; + unitBadge.style.display = 'inline-block'; + } else { + unitBadge.style.display = 'none'; + } } updateTotal(); @@ -84,18 +109,27 @@ * Обновление суммы в модалке */ function updateTotal() { - const price = parseFloat(document.getElementById('editModalPrice').value) || 0; - const qty = parseFloat(document.getElementById('editModalQuantity').value) || 0; + const priceInput = document.getElementById('editModalPrice'); + 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); - document.getElementById('editModalTotal').textContent = fmtMoney(price * qty) + ' руб.'; + const price = parseFloat(priceInput.value) || 0; + const qty = parseFloat(qtyInput.value) || 0; + + if (totalEl) { + totalEl.textContent = fmtMoney(price * qty) + ' руб.'; + } // Индикатор изменения цены - const warning = document.getElementById('editModalPriceWarning'); - if (Math.abs(price - basePrice) > 0.01) { - warning.style.display = 'block'; - } else { - warning.style.display = 'none'; + if (warningEl) { + if (Math.abs(price - basePrice) > 0.01) { + warningEl.style.display = 'block'; + } else { + warningEl.style.display = 'none'; + } } } @@ -105,33 +139,43 @@ function saveChanges() { if (!editingCartKey) return; - const newPrice = parseFloat(document.getElementById('editModalPrice').value) || 0; - const newQty = parseFloat(document.getElementById('editModalQuantity').value) || 1; + const priceInput = document.getElementById('editModalPrice'); + 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) { - // Используем 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'; item.price = newPrice; // Для витринных комплектов не меняем количество if (!isShowcaseKit) { - item.qty = rndQty(newQty, 3); + item.qty = roundQty(newQty, 3); } 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') { - renderCart(); - } - - // Сохранение на сервере - if (typeof saveCartToServer === 'function') { - saveCartToServer(); + if (typeof window.renderCart === 'function') { + window.renderCart(); } console.log('CartItemEditor: Changes saved for', item.name); @@ -170,30 +214,36 @@ // Обновление суммы при изменении полей priceInput.addEventListener('input', updateTotal); - qtyInput.addEventListener('input', updateTotal); + if (qtyInput) qtyInput.addEventListener('input', updateTotal); // Авто-выделение всего текста при фокусе priceInput.addEventListener('focus', function() { this.select(); }); - qtyInput.addEventListener('focus', function() { - this.select(); - }); + if (qtyInput) { + qtyInput.addEventListener('focus', function() { + this.select(); + }); + } // Кнопка сохранения confirmBtn.addEventListener('click', saveChanges); // Сброс при закрытии модалки const modalEl = document.getElementById('editCartItemModal'); - modalEl.addEventListener('hidden.bs.modal', reset); + if (modalEl) { + modalEl.addEventListener('hidden.bs.modal', reset); + } // Enter для сохранения priceInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') saveChanges(); }); - qtyInput.addEventListener('keypress', function(e) { - if (e.key === 'Enter') saveChanges(); - }); + if (qtyInput) { + qtyInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') saveChanges(); + }); + } } // Экспорт функций для использования из terminal.js diff --git a/myproject/pos/static/pos/js/cart.js b/myproject/pos/static/pos/js/cart.js new file mode 100644 index 0000000..8f6cfc1 --- /dev/null +++ b/myproject/pos/static/pos/js/cart.js @@ -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} */ + 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} Успешно ли сохранение + */ + 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} + */ + 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; + } + } +} diff --git a/myproject/pos/static/pos/js/checkout.js b/myproject/pos/static/pos/js/checkout.js new file mode 100644 index 0000000..93f1458 --- /dev/null +++ b/myproject/pos/static/pos/js/checkout.js @@ -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') + ? ' ' + : ''; + + html += ` +
+
+
${typeIcon}${item.name}
+
+ ${formatMoney(item.price)} × ${roundQuantity(item.qty)} + ${item.unit_name ? ' ' + item.unit_name : ''} +
+
+
${formatMoney(itemTotal)}
+
+ `; + }); + + container.innerHTML = html || '

Корзина пуста

'; + } + + /** + * Рендерит виджет оплаты + */ + 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 ` +
+ + +
+ + + `; + } + + /** + * Рендерит виджет смешанной оплаты + */ + renderMixedPaymentWidget(total, walletBalance) { + const isSystemCustomer = this.customerManager.isSystemCustomer(); + let html = ` +
+ +
+ `; + + // Наличные + html += ` +
+
+ +
+ +
+ `; + + // Карта + html += ` +
+
+ +
+ +
+ `; + + // Кошелёк (если не системный клиент) + if (!isSystemCustomer && walletBalance > 0) { + html += ` +
+
+ + Доступно: ${formatMoney(walletBalance)} +
+ +
+ `; + } + + html += ` +
+
+ Введено: + 0.00 руб. +
+
+ Осталось: + ${formatMoney(total)} руб. +
+
+ `; + + 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 = '
  • Нет доступных скидок
  • '; + return; + } + + let html = ''; + this.availableDiscounts.forEach(discount => { + const icon = getCombineModeIcon(discount.combine_mode); + const valueText = discount.type === 'percent' + ? `${discount.value}%` + : `${formatMoney(discount.value)} руб.`; + + html += ` +
  • + +
  • + `; + }); + + 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 `
    ${d.name}: ${valueText}
    `; + }).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 + }; + } +} diff --git a/myproject/pos/static/pos/js/config.js b/myproject/pos/static/pos/js/config.js new file mode 100644 index 0000000..168746a --- /dev/null +++ b/myproject/pos/static/pos/js/config.js @@ -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; diff --git a/myproject/pos/static/pos/js/customer.js b/myproject/pos/static/pos/js/customer.js new file mode 100644 index 0000000..545c6d3 --- /dev/null +++ b/myproject/pos/static/pos/js/customer.js @@ -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} + */ + 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} + */ + 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 $(' ' + customer.text + ''); + } + + // Формируем текст в одну строку + const parts = []; + + // Имя + const name = customer.name || customer.text; + parts.push('' + $('
    ').text(name).html() + ''); + + // Телефон и Email + const contactInfo = []; + if (customer.phone) { + contactInfo.push($('
    ').text(customer.phone).html()); + } + if (customer.email) { + contactInfo.push($('
    ').text(customer.email).html()); + } + + if (contactInfo.length > 0) { + parts.push(' (' + contactInfo.join(', ') + ')'); + } + + return $('' + parts.join('') + ''); + } + + /** + * Форматирует выбранного клиента + * @private + */ + _formatCustomerSelection(customer) { + return customer.name || customer.text; + } +} diff --git a/myproject/pos/static/pos/js/modules.js b/myproject/pos/static/pos/js/modules.js new file mode 100644 index 0000000..c6f6a15 --- /dev/null +++ b/myproject/pos/static/pos/js/modules.js @@ -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 + }; +} diff --git a/myproject/pos/static/pos/js/products.js b/myproject/pos/static/pos/js/products.js new file mode 100644 index 0000000..3622ce6 --- /dev/null +++ b/myproject/pos/static/pos/js/products.js @@ -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} + */ + 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} + */ + 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} + */ + 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 = ''; + } + + 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 ? ` 🛒${inCart}` : ''; + + const daysAgo = formatDaysAgo(item.showcase_created_at); + const daysBadge = daysAgo ? ` ${daysAgo}` : ''; + + element.innerHTML = `${badgeText}${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 = ' В корзине'; + lockBadge.title = 'Добавлен в вашу корзину'; + } else { + lockBadge.className = 'badge bg-danger'; + lockBadge.innerHTML = ' Занят'; + lockBadge.title = `В корзине ${item.locked_by_user}`; + + card.style.opacity = '0.5'; + card.style.cursor = 'not-allowed'; + } + + card.appendChild(lockBadge); + } + } +} diff --git a/myproject/pos/static/pos/js/showcase.js b/myproject/pos/static/pos/js/showcase.js new file mode 100644 index 0000000..62152a2 --- /dev/null +++ b/myproject/pos/static/pos/js/showcase.js @@ -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 = ' Редактировать комплект на витрине'; + if (confirmBtn) { + confirmBtn.innerHTML = ' Сохранить изменения'; + } + 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 = ' Создать временный комплект на витрину'; + if (confirmBtn) { + confirmBtn.innerHTML = ' Создать и зарезервировать'; + } + 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 = ''; + this.showcases.forEach(showcase => { + html += ``; + }); + + select.innerHTML = html; + } + + /** + * Рендерит список товаров во временной корзине + */ + renderTempKitItems() { + const container = document.getElementById('tempKitItemsList'); + if (!container) return; + + if (this.tempCart.size === 0) { + container.innerHTML = '

    Нет товаров

    '; + return; + } + + let html = ''; + let totalBasePrice = 0; + + this.tempCart.forEach((item, key) => { + const itemTotal = item.price * item.qty; + totalBasePrice += itemTotal; + + html += ` +
    +
    +
    ${escapeHtml(item.name)}
    +
    + ${formatMoney(item.price)} × ${roundQuantity(item.qty)} + ${item.unit_name ? ' ' + item.unit_name : ''} +
    +
    +
    ${formatMoney(itemTotal)}
    +
    + `; + }); + + 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(); + } +} diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index cce5f52..62e185a 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -1,406 +1,645 @@ -// POS Terminal JavaScript - /** - * Округляет число до N знаков после запятой для корректного отображения. - * Решает проблему погрешности float arithmetic в JavaScript. - * @param {number} value - Число для округления - * @param {number} decimals - Количество знаков после запятой (по умолчанию 3) - * @returns {number} Округлённое число + * POS Terminal JavaScript - Refactored + * Основной модуль, использующий разделённые модули */ -function roundQuantity(value, decimals = 3) { - if (value === null || value === undefined || isNaN(value)) return 0; - return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); -} -/** - * Показывает toast уведомление в правом верхнем углу - * @param {string} type - 'success' или 'error' - * @param {string} message - Текст сообщения - */ -function showToast(type, message) { - const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast'; - const messageId = type === 'success' ? 'toastMessage' : 'errorMessage'; - const bgClass = type === 'success' ? 'bg-success' : 'bg-danger'; +import CONFIG from './config.js'; +import { + safeJSONParse, + roundQuantity, + formatMoney, + formatDaysAgo, + getNoun, + debounce, + getCsrfToken, + showToast, + isMobileDevice, + getCombineModeIcon, + getCombineModeTitle, + safeFetch, + escapeHtml +} from './utils.js'; +import { Cart } from './cart.js'; +import { CustomerManager } from './customer.js'; +import { ProductManager } from './products.js'; +import { CheckoutManager } from './checkout.js'; +import { ShowcaseManager } from './showcase.js'; - const toastElement = document.getElementById(toastId); - const messageElement = document.getElementById(messageId); +// ===== ИНИЦИАЛИЗАЦИЯ ДАННЫХ ===== - // Устанавливаем сообщение - messageElement.textContent = message; +const CATEGORIES = safeJSONParse( + document.getElementById('categoriesData')?.textContent || '[]', + [] +); - // Добавляем цвет фона - toastElement.classList.add(bgClass, 'text-white'); +const selectedCustomerData = safeJSONParse( + document.getElementById('selectedCustomerData')?.textContent || '{}', + null +); - // Создаём и показываем toast (автоматически скроется через 5 секунд - стандарт Bootstrap) - const toast = new bootstrap.Toast(toastElement, { - delay: 5000, - autohide: true - }); - toast.show(); +const SYSTEM_CUSTOMER = safeJSONParse( + document.getElementById('systemCustomerData')?.textContent || '{}', + { id: 0, name: 'Системный клиент', wallet_balance: 0 } +); - // Убираем класс цвета после скрытия - toastElement.addEventListener('hidden.bs.toast', () => { - toastElement.classList.remove(bgClass, 'text-white'); - }, { once: true }); -} +const currentWarehouse = safeJSONParse( + document.getElementById('currentWarehouseData')?.textContent || '{}', + { id: null } +); -const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent); -let ITEMS = []; // Будем загружать через API -let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); +const savedCartData = safeJSONParse( + document.getElementById('cartData')?.textContent || '{}', + {} +); + +// ===== ИНИЦИАЛИЗАЦИЯ МЕНЕДЖЕРОВ ===== + +const cart = new Cart(); + +// Используем сохраненного клиента из Redis или системного по умолчанию +const initialCustomer = selectedCustomerData || SYSTEM_CUSTOMER; +const customerManager = new CustomerManager(SYSTEM_CUSTOMER); + +const productManager = new ProductManager({ + currentWarehouse, + onError: (msg, err) => console.error(msg, err) +}); + +const checkoutManager = new CheckoutManager({ + cart, + customerManager, + currentWarehouse, + onCheckoutComplete: (data) => { + console.log('Checkout completed:', data); + // Перезагружаем страницу для сброса состояния + setTimeout(() => location.reload(), 1500); + }, + onCheckoutError: (error) => { + console.error('Checkout error:', error); + } +}); + +const showcaseManager = new ShowcaseManager({ + cart, + onKitCreated: () => { + // Обновляем отображение витрины + if (isShowcaseView) { + productManager.loadShowcaseKits().then(renderProducts); + } + }, + onKitUpdated: () => { + if (isShowcaseView) { + productManager.loadShowcaseKits().then(renderProducts); + } + } +}); + +// ===== СОСТОЯНИЕ UI ===== let currentCategoryId = null; let isShowcaseView = false; -const cart = new Map(); -// Экспорт корзины для использования в других модулях -window.cart = cart; - -// Переменные для пагинации -let currentPage = 1; -let hasMoreItems = false; -let isLoadingItems = false; -let currentSearchQuery = ''; // Текущий поисковый запрос +let wasShowcaseViewBeforeSearch = false; +let currentSearchQuery = ''; let searchDebounceTimer = null; -// Переменные для режима редактирования -let isEditMode = false; -let editingKitId = null; +// Модальное окно выбора единицы продажи +let unitModalProduct = null; +let unitModalSalesUnits = []; +let selectedSalesUnit = null; +let unitModalInstance = null; -// Переменная для сохранения состояния витрины перед поиском -let wasShowcaseViewBeforeSearch = false; +// ===== ИНИЦИАЛИЗАЦИЯ ===== -// Временная корзина для модального окна создания/редактирования комплекта -const tempCart = new Map(); +document.addEventListener('DOMContentLoaded', () => { + // Инициализируем слушатели ПЕРЕД загрузкой данных + cart.addListener(renderCart); + customerManager.addListener(updateCustomerDisplay); -// ===== ПЕРЕМЕННЫЕ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== -let unitModalProduct = null; // Текущий товар для модального окна -let unitModalSalesUnits = []; // Список единиц продажи -let selectedSalesUnit = null; // Выбранная единица продажи -let unitModalInstance = null; // Bootstrap Modal instance - -// ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS ===== - -let saveCartTimeout = null; - -/** - * Сохраняет корзину в Redis с debounce 500ms - */ -function saveCartToRedis() { - // Отменяем предыдущий таймер - if (saveCartTimeout) { - clearTimeout(saveCartTimeout); - } - - // Устанавливаем новый таймер - saveCartTimeout = setTimeout(() => { - // Конвертируем Map в обычный объект - const cartObj = {}; - cart.forEach((value, key) => { - cartObj[key] = value; - }); - - // Отправляем на сервер - fetch('/pos/api/save-cart/', { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ cart: cartObj }) - }) - .then(response => response.json()) - .then(data => { - if (!data.success) { - console.error('Ошибка сохранения корзины:', data.error); - } - }) - .catch(error => { - console.error('Ошибка при сохранении корзины в Redis:', error); - }); - }, 500); // Debounce 500ms -} - -// ===== УПРАВЛЕНИЕ КЛИЕНТОМ И СКЛАДОМ ===== -// Загружаем данные системного клиента -const SYSTEM_CUSTOMER = JSON.parse(document.getElementById('systemCustomerData').textContent); - -// Текущий выбранный клиент (загружается из Redis или системный) -let selectedCustomer = JSON.parse(document.getElementById('selectedCustomerData').textContent); - -// Текущий склад -const currentWarehouse = JSON.parse(document.getElementById('currentWarehouseData').textContent); - -function formatMoney(v) { - return (Number(v)).toFixed(2); -} - -/** - * Форматирует дату как относительное время в русском языке - * @param {string|null} isoDate - ISO дата или null - * @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д. - */ -function formatDaysAgo(isoDate) { - if (!isoDate) return ''; - - const created = new Date(isoDate); - 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 = 'дня'; + // Восстанавливаем корзину из Redis (слушатель уже подключен) + if (savedCartData && Object.keys(savedCartData).length > 0) { + cart.fromJSON(savedCartData); } else { - suffix = 'дней'; + // Если корзина пуста - всё равно рендерим для отображения "Корзина пуста" + renderCart(); } - return `${diffDays} ${suffix}`; + // Устанавливаем начального клиента (из Redis или системный) + // Если нет сохраненного клиента - используем системного + if (selectedCustomerData && selectedCustomerData.id && selectedCustomerData.id !== SYSTEM_CUSTOMER.id) { + customerManager.selectedCustomer = selectedCustomerData; + } else { + // Явно устанавливаем системного клиента + customerManager.selectedCustomer = SYSTEM_CUSTOMER; + } + + // Инициализируем UI + initCustomerSelect2(); + updateCustomerDisplay(customerManager.getCurrentCustomer()); + renderCategories(); + + // Загружаем начальные товары + productManager.loadItems().then(() => { + renderProducts(); + }); + + setupInfiniteScroll(); + initEventListeners(); + + // Инициализируем менеджеры + checkoutManager.init(); + showcaseManager.init(); + + // Установить фокус на строку поиска только на десктопе + if (!isMobileDevice()) { + document.getElementById('searchInput')?.focus(); + } + + // Глобальные ссылки для обратной совместимости с cart-item-editor.js + window.cart = cart; + window.renderCart = renderCart; +}); + +// ===== РЕНДЕРИНГ КАТЕГОРИЙ ===== + +function renderCategories() { + const grid = document.getElementById('categoryGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + // Кнопка "Витрина" + const showcaseCol = createCategoryColumn({ + id: 'showcase', + name: ' ВИТРИНА', + isActive: isShowcaseView, + onClick: async () => { + isShowcaseView = true; + currentCategoryId = null; + currentSearchQuery = ''; + document.getElementById('searchInput').value = ''; + // Устанавливаем флаг в productManager перед загрузкой + productManager.isShowcaseView = true; + await productManager.loadShowcaseKits(); + renderCategories(); + renderProducts(); + }, + customStyle: { backgroundColor: CONFIG.CSS.SHOWCASE_BG, borderColor: CONFIG.CSS.SHOWCASE_BORDER } + }); + grid.appendChild(showcaseCol); + + // Кнопка "Все" + const allCol = createCategoryColumn({ + id: 'all', + name: 'Все товары', + isActive: currentCategoryId === null && !isShowcaseView, + onClick: async () => { + currentCategoryId = null; + isShowcaseView = false; + productManager.isShowcaseView = false; + currentSearchQuery = ''; + document.getElementById('searchInput').value = ''; + renderCategories(); + await productManager.loadItems(); + renderProducts(); + } + }); + grid.appendChild(allCol); + + // Категории + CATEGORIES.forEach(cat => { + const col = createCategoryColumn({ + id: cat.id, + name: cat.name, + isActive: currentCategoryId === cat.id && !isShowcaseView, + onClick: async () => { + currentCategoryId = cat.id; + isShowcaseView = false; + productManager.isShowcaseView = false; + currentSearchQuery = ''; + document.getElementById('searchInput').value = ''; + renderCategories(); + await productManager.loadItems({ categoryId: cat.id }); + renderProducts(); + } + }); + grid.appendChild(col); + }); } -// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== +function createCategoryColumn({ id, name, isActive, onClick, customStyle = {} }) { + const col = document.createElement('div'); + col.className = 'col-6 col-custom-3 col-md-3 col-lg-2'; -/** - * Обновляет отображение выбранного клиента в UI - * Обновляет: - * - Кнопку "Выбрать клиента" в корзине (показывает имя клиента) - * - Кнопку "Выбрать клиента" в модалке продажи (показывает имя клиента) - * - Видимость кнопок сброса в обоих местах (показываем только для не-системного клиента) - * - Ссылку на анкету клиента (показываем только для не-системного клиента) - * - Баланс кошелька в модальном окне продажи (если оно открыто) - */ -function updateCustomerDisplay() { - // Определяем, системный ли это клиент - const isSystemCustomer = Number(selectedCustomer.id) === Number(SYSTEM_CUSTOMER.id); - const displayName = isSystemCustomer ? 'Выбрать клиента' : selectedCustomer.name; + const card = document.createElement('div'); + card.className = 'card category-card' + (isActive ? ' active' : ''); + card.dataset.categoryId = id; + + if (customStyle.backgroundColor) { + card.style.backgroundColor = customStyle.backgroundColor; + } + if (customStyle.borderColor) { + card.style.borderColor = customStyle.borderColor; + } + + card.onclick = onClick; - // Обновляем текст кнопки в корзине - const btnText = document.getElementById('customerSelectBtnText'); - if (btnText) { - btnText.textContent = displayName; + const body = document.createElement('div'); + body.className = 'card-body'; + + const nameDiv = document.createElement('div'); + nameDiv.className = 'category-name'; + nameDiv.innerHTML = name; + + body.appendChild(nameDiv); + card.appendChild(body); + col.appendChild(card); + + return col; +} + +// ===== РЕНДЕРИНГ ТОВАРОВ ===== + +function renderProducts() { + const grid = document.getElementById('productGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + const filtered = productManager.getFilteredItems({ wasShowcaseViewBeforeSearch }); + + filtered.forEach(item => { + const card = productManager.renderProductCard(item, { cart: cart.items }); + + // Добавляем обработчик клика + const cardElement = card.querySelector('.product-card'); + if (cardElement) { + cardElement.onclick = () => handleProductClick(item); + } + + grid.appendChild(card); + }); +} + +async function handleProductClick(item) { + // Проверка на витринный комплект, заблокированный другим + if (item.type === 'showcase_kit' && item.is_locked && !item.locked_by_me) { + showToast('error', `Этот букет уже в корзине кассира "${item.locked_by_user}"`); + return; } - // Обновляем текст кнопки в модалке продажи - const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText'); - if (checkoutBtnText) { - checkoutBtnText.textContent = displayName; + // Обработка товаров с единицами продажи + if (item.type === 'product' && item.has_sales_units) { + if (item.sales_units_count === 1 && item.default_sales_unit) { + await addProductWithUnit(item, item.default_sales_unit, 1); + } else { + await openProductUnitModal(item); + } + return; } - // Обновляем видимость кнопок сброса (в корзине и в модалке продажи) + // Витринные комплекты + if (item.type === 'showcase_kit') { + const result = await cart.addShowcaseKit(item, 1); + if (!result.success) { + showToast('error', result.error); + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); + } + } + return; + } - [document.getElementById('resetCustomerBtn'), - document.getElementById('checkoutResetCustomerBtn')].forEach(resetBtn => { - if (resetBtn) { - resetBtn.style.display = isSystemCustomer ? 'none' : 'block'; + // Обычные товары и комплекты + const cartKey = Cart.generateKey(item.type, item.id); + if (cart.has(cartKey)) { + const existing = cart.get(cartKey); + cart.updateQuantity(cartKey, existing.qty + 1); + } else { + cart.add(cartKey, { + id: item.id, + name: item.name, + price: Number(item.price), + qty: 1, + type: item.type + }); + } + + // Фокус на поле количества + if (!isMobileDevice()) { + setTimeout(() => focusQuantityInput(cartKey), 50); + } +} + +// ===== РЕНДЕРИНГ КОРЗИНЫ ===== + +function renderCart() { + const list = document.getElementById('cartList'); + if (!list) return; + + list.innerHTML = ''; + + if (cart.isEmpty) { + list.innerHTML = '

    Корзина пуста

    '; + document.getElementById('cartTotal').textContent = '0.00'; + updateShowcaseButtonState(); + updateMobileCartBar(); + return; + } + + cart.toArray().forEach(({ key, item }) => { + const row = createCartItemRow(key, item); + list.appendChild(row); + }); + + document.getElementById('cartTotal').textContent = formatMoney(cart.total); + updateShowcaseButtonState(); + updateMobileCartBar(); +} + +function createCartItemRow(cartKey, item) { + const row = document.createElement('div'); + row.className = 'cart-item mb-2'; + row.style.cursor = 'pointer'; + row.title = 'Нажмите для редактирования'; + + if (item.price_overridden) { + row.classList.add('price-overridden'); + } + + const isShowcaseKit = item.type === 'showcase_kit'; + if (isShowcaseKit) { + row.style.backgroundColor = CONFIG.CSS.SHOWCASE_BG; + row.style.border = '1px solid ' + CONFIG.CSS.SHOWCASE_BORDER; + row.style.borderRadius = '4px'; + row.style.padding = '8px'; + } + + // Название и цена + const namePrice = document.createElement('div'); + namePrice.className = 'item-name-price'; + + let typeIcon = (item.type === 'kit' || item.type === 'showcase_kit') + ? ' ' + : ''; + + let unitInfo = item.unit_name + ? ` ${item.unit_name}` + : ''; + + namePrice.innerHTML = ` +
    ${typeIcon}${escapeHtml(item.name)}
    +
    + ${formatMoney(item.price)}${unitInfo} +
    + `; + + // Знак умножения + const multiplySign = document.createElement('span'); + multiplySign.className = 'multiply-sign'; + multiplySign.textContent = 'x'; + + // Контролы количества + const qtyControl = createQuantityControls(cartKey, item, isShowcaseKit); + + // Сумма + const itemTotal = document.createElement('div'); + itemTotal.className = 'item-total'; + itemTotal.textContent = formatMoney(item.price * item.qty); + + // Кнопка удаления + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-sm btn-link text-danger p-0'; + deleteBtn.innerHTML = ''; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + removeFromCart(cartKey); + }; + + row.appendChild(namePrice); + row.appendChild(multiplySign); + row.appendChild(qtyControl); + row.appendChild(itemTotal); + row.appendChild(deleteBtn); + + // Обработчик клика для редактирования + row.addEventListener('click', (e) => { + if (e.target.closest('button') || e.target.closest('input')) return; + // Используем глобальный CartItemEditor + if (window.CartItemEditor) { + window.CartItemEditor.openModal(cartKey); } }); - // Обновляем ссылку на анкету клиента + return row; +} + +function createQuantityControls(cartKey, item, isShowcaseKit) { + const qtyControl = document.createElement('div'); + qtyControl.className = 'd-flex align-items-center'; + qtyControl.style.gap = '2px'; + + const minusBtn = document.createElement('button'); + minusBtn.className = 'btn btn-outline-secondary btn-sm'; + minusBtn.innerHTML = ''; + + const qtyInput = document.createElement('input'); + qtyInput.type = 'number'; + qtyInput.className = 'qty-input form-control form-control-sm'; + qtyInput.style.width = '60px'; + qtyInput.style.textAlign = 'center'; + qtyInput.style.padding = '0.375rem 0.25rem'; + qtyInput.value = roundQuantity(item.qty); + qtyInput.min = 1; + + const plusBtn = document.createElement('button'); + plusBtn.className = 'btn btn-outline-secondary btn-sm'; + plusBtn.innerHTML = ''; + + if (isShowcaseKit) { + // Витринные комплекты - readonly + qtyInput.readOnly = true; + qtyInput.style.backgroundColor = CONFIG.CSS.SHOWCASE_BG; + + minusBtn.onclick = async (e) => { + e.preventDefault(); + const result = await cart.decreaseShowcaseKitQty(cartKey); + if (!result.success) { + showToast('error', result.error); + } + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); + } + }; + + plusBtn.onclick = async (e) => { + e.preventDefault(); + const result = await cart.increaseShowcaseKitQty(cartKey); + if (!result.success) { + showToast('error', result.error); + } + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); + } + }; + } else { + // Обычные товары + qtyInput.step = item.quantity_step || CONFIG.NUMBERS.QUANTITY_STEP; + qtyInput.onchange = async (e) => { + const newQty = parseFloat(e.target.value) || 1; + await cart.updateQuantity(cartKey, newQty); + if (!isShowcaseView && item.type === 'product') { + renderProducts(); + } + }; + qtyInput.onblur = (e) => { + e.target.value = roundQuantity(parseFloat(e.target.value) || 1); + }; + + minusBtn.onclick = async (e) => { + e.preventDefault(); + const currentQty = cart.get(cartKey).qty; + const step = item.quantity_step || 1; + await cart.updateQuantity(cartKey, currentQty - step); + if (!isShowcaseView && item.type === 'product') { + renderProducts(); + } + }; + + plusBtn.onclick = async (e) => { + e.preventDefault(); + const currentQty = cart.get(cartKey).qty; + const step = item.quantity_step || 1; + await cart.updateQuantity(cartKey, currentQty + step); + if (!isShowcaseView && item.type === 'product') { + renderProducts(); + } + }; + } + + qtyControl.appendChild(minusBtn); + qtyControl.appendChild(qtyInput); + qtyControl.appendChild(plusBtn); + + return qtyControl; +} + +async function removeFromCart(cartKey) { + const item = cart.get(cartKey); + + if (item?.type === 'showcase_kit') { + await cart.removeShowcaseKit(cartKey); + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); + } + } else { + cart.remove(cartKey); + if (!isShowcaseView && item?.type === 'product') { + renderProducts(); + } + } +} + +async function clearCart() { + await cart.releaseAllLocks(); + cart.clear(); + + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); + } else { + renderProducts(); + } +} + +function focusQuantityInput(cartKey) { + const qtyInputs = document.querySelectorAll('.qty-input'); + const itemIndex = Array.from(cart.items.keys()).indexOf(cartKey); + if (itemIndex !== -1 && qtyInputs[itemIndex]) { + qtyInputs[itemIndex].focus(); + qtyInputs[itemIndex].select(); + } +} + +// ===== МОБИЛЬНАЯ КОРЗИНА ===== + +function updateMobileCartBar() { + const countEl = document.querySelector('.mobile-cart-count'); + const totalEl = document.querySelector('.mobile-cart-total'); + + if (!countEl || !totalEl) return; + + const count = cart.totalQuantity; + const total = cart.total; + + const displayCount = Math.round(count); + countEl.textContent = `${displayCount} ${getNoun(displayCount, 'товар', 'товара', 'товаров')}`; + totalEl.textContent = formatMoney(total); +} + +// ===== УПРАВЛЕНИЕ КЛИЕНТАМИ ===== + +function updateCustomerDisplay(customer) { + const isSystemCustomer = customerManager.isSystemCustomer(); + const displayName = isSystemCustomer ? 'Выбрать клиента' : customer.name; + + // Обновляем кнопки + const btnText = document.getElementById('customerSelectBtnText'); + if (btnText) btnText.textContent = displayName; + + const checkoutBtnText = document.getElementById('checkoutCustomerSelectBtnText'); + if (checkoutBtnText) checkoutBtnText.textContent = displayName; + + // Кнопки сброса + [document.getElementById('resetCustomerBtn'), + document.getElementById('checkoutResetCustomerBtn')].forEach(btn => { + if (btn) btn.style.display = isSystemCustomer ? 'none' : 'block'; + }); + + // Ссылка на профиль const profileLink = document.getElementById('customerProfileLink'); if (profileLink) { if (isSystemCustomer) { profileLink.style.display = 'none'; } else { - profileLink.href = `/customers/${selectedCustomer.id}/`; + profileLink.href = `/customers/${customer.id}/`; profileLink.style.display = 'block'; } } - // Обновляем баланс кошелька в модальном окне продажи (если оно открыто) + // Баланс кошелька updateCheckoutWalletBalance(); } -/** - * Обновляет баланс кошелька клиента в модальном окне продажи - */ function updateCheckoutWalletBalance() { const walletDiv = document.getElementById('checkoutWalletBalance'); - if (!walletDiv) return; // Модалка еще не инициализирована + if (!walletDiv) return; - const customer = selectedCustomer || SYSTEM_CUSTOMER; - const walletBalance = customer.wallet_balance || 0; - const isSystemCustomer = Number(customer.id) === Number(SYSTEM_CUSTOMER.id); + const customer = customerManager.getCurrentCustomer(); + const isSystemCustomer = customerManager.isSystemCustomer(); if (!isSystemCustomer) { - document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2); + document.getElementById('checkoutWalletBalanceAmount').textContent = + (customer.wallet_balance || 0).toFixed(2); walletDiv.style.display = 'block'; } else { walletDiv.style.display = 'none'; } - - // Переинициализируем виджет оплаты, если модалка открыта - updatePaymentWidgetCustomer(); } -/** - * Обновляет данные клиента в виджете оплаты (если он инициализирован) - */ -function updatePaymentWidgetCustomer() { - // Проверяем, открыта ли модалка checkout и инициализирован ли виджет - const checkoutModal = document.getElementById('checkoutModal'); - if (!checkoutModal || !checkoutModal.classList.contains('show')) return; - if (!paymentWidget) return; - - // Получаем текущий режим оплаты - const isMixedMode = document.getElementById('mixedPaymentMode').classList.contains('active'); - const mode = isMixedMode ? 'mixed' : 'single'; - - // Переинициализируем виджет с новыми данными клиента - reinitPaymentWidget(mode); -} - -/** - * Устанавливает нового клиента и сохраняет в Redis - * @param {number} customerId - ID клиента - * @param {string} customerName - Имя клиента - * @param {number} walletBalance - Баланс кошелька клиента (опционально) - */ -function selectCustomer(customerId, customerName, walletBalance = 0) { - selectedCustomer = { - id: customerId, - name: customerName, - wallet_balance: walletBalance - }; - updateCustomerDisplay(); - - // Сохраняем выбор в Redis через AJAX - fetch(`/pos/api/set-customer/${customerId}/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - } - }) - .then(response => response.json()) - .then(data => { - if (!data.success) { - console.error('Ошибка сохранения клиента:', data.error); - } else { - // Обновляем баланс из ответа сервера - selectedCustomer.wallet_balance = data.wallet_balance || 0; - } - }) - .catch(error => { - console.error('Ошибка при сохранении клиента в Redis:', error); - }); -} - -/** - * Инициализация Select2 для поиска клиента - */ function initCustomerSelect2() { - const $searchInput = $('#customerSearchInput'); - - $searchInput.select2({ - theme: 'bootstrap-5', - dropdownParent: $('#selectCustomerModal'), - placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)', - minimumInputLength: 3, - allowClear: true, - ajax: { - url: '/customers/api/search/', - dataType: 'json', - delay: 300, - data: function (params) { - return { - q: params.term - }; - }, - processResults: function (data) { - return { - results: data.results - }; - }, - cache: true - }, - templateResult: formatCustomerOption, // Форматирование результатов в выпадающем списке - templateSelection: formatCustomerSelection // Форматирование выбранного значения - }); - - // Обработка выбора клиента из списка - $searchInput.on('select2:select', function (e) { - const data = e.params.data; - - // Проверяем это не опция "Создать нового клиента" - if (data.id === 'create_new') { - // Открываем модалку создания - const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); - modal.hide(); - openCreateCustomerModal(data.text); - return; - } - - // Выбираем клиента с балансом - selectCustomer(parseInt(data.id), data.name, data.wallet_balance || 0); - - // Закрываем модалку - const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); - modal.hide(); - - // Очищаем Select2 - $searchInput.val(null).trigger('change'); + customerManager.initSelect2('#customerSearchInput', { + modalId: '#selectCustomerModal', + onCreateNew: (prefillName) => openCreateCustomerModal(prefillName) }); } -/** - * Форматирование опции клиента в выпадающем списке Select2 - * Показывает: Имя, телефон, email в одну строку - */ -function formatCustomerOption(customer) { - if (customer.loading) { - return customer.text; - } - - // Если это опция "Создать нового клиента" - if (customer.id === 'create_new') { - return $(' ' + customer.text + ''); - } - - // Формируем текст в одну строку: Имя (жирным) + контакты (мелким) - const parts = []; - - // Имя - const name = customer.name || customer.text; - parts.push('' + $('
    ').text(name).html() + ''); - - // Телефон и Email - const contactInfo = []; - if (customer.phone) { - contactInfo.push($('
    ').text(customer.phone).html()); - } - if (customer.email) { - contactInfo.push($('
    ').text(customer.email).html()); - } - - if (contactInfo.length > 0) { - parts.push(' (' + contactInfo.join(', ') + ')'); - } - - return $('' + parts.join('') + ''); -} - -/** - * Форматирование выбранного клиента в поле Select2 - * Показывает только имя - */ -function formatCustomerSelection(customer) { - return customer.name || customer.text; -} - -/** - * Открывает модальное окно создания нового клиента - * @param {string} prefillName - Предзаполненное имя (из поиска) - */ -function openCreateCustomerModal(prefillName = '') { +async function openCreateCustomerModal(prefillName = '') { const modal = new bootstrap.Modal(document.getElementById('createCustomerModal')); - - // Очищаем форму + document.getElementById('newCustomerName').value = prefillName || ''; document.getElementById('newCustomerPhone').value = ''; document.getElementById('newCustomerEmail').value = ''; @@ -409,204 +648,129 @@ function openCreateCustomerModal(prefillName = '') { modal.show(); } -/** - * Создаёт нового клиента через API - */ async function createNewCustomer() { const name = document.getElementById('newCustomerName').value.trim(); const phone = document.getElementById('newCustomerPhone').value.trim(); const email = document.getElementById('newCustomerEmail').value.trim(); const errorBlock = document.getElementById('createCustomerError'); - // Валидация if (!name) { errorBlock.textContent = 'Укажите имя клиента'; errorBlock.classList.remove('d-none'); return; } - // Скрываем ошибку errorBlock.classList.add('d-none'); - try { - const response = await fetch('/customers/api/create/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - }, - body: JSON.stringify({ - name: name, - phone: phone || null, - email: email || null - }) - }); - - const data = await response.json(); - - if (data.success) { - // Выбираем созданного клиента с балансом - selectCustomer(data.id, data.name, data.wallet_balance || 0); - - // Закрываем модалку - const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal')); - modal.hide(); - - // Показываем уведомление - alert(`Клиент "${data.name}" успешно создан!`); - } else { - // Показываем ошибку - errorBlock.textContent = data.error || 'Ошибка при создании клиента'; - errorBlock.classList.remove('d-none'); - } - } catch (error) { - console.error('Error creating customer:', error); - errorBlock.textContent = 'Ошибка сети при создании клиента'; + const result = await customerManager.createCustomer({ name, phone, email }); + + if (result.success) { + const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal')); + modal.hide(); + } else { + errorBlock.textContent = result.error; errorBlock.classList.remove('d-none'); } } -// ===== ФУНКЦИИ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== +// ===== МОДАЛЬНОЕ ОКНО ЕДИНИЦЫ ПРОДАЖИ ===== -/** - * Открывает модальное окно выбора единицы продажи - * @param {object} product - Объект товара с информацией о единицах продажи - */ async function openProductUnitModal(product) { unitModalProduct = product; - // Устанавливаем название товара - document.getElementById('unitModalProductName').textContent = + document.getElementById('unitModalProductName').textContent = `${product.name}${product.sku ? ' (' + product.sku + ')' : ''}`; - // Загружаем единицы продажи - try { - const response = await fetch( - `/products/api/products/${product.id}/sales-units/?warehouse=${currentWarehouse.id}` - ); - const data = await response.json(); - - if (!data.success || !data.sales_units || data.sales_units.length === 0) { - alert('Не удалось загрузить единицы продажи'); - return; - } - - unitModalSalesUnits = data.sales_units; - - // Отрисовываем список единиц - renderUnitSelectionList(); - - // Выбираем единицу по умолчанию или первую - const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0]; - if (defaultUnit) { - selectUnit(defaultUnit); - } - - // Открываем модальное окно - if (!unitModalInstance) { - unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal')); - } - unitModalInstance.show(); - - } catch (error) { - console.error('Ошибка загрузки единиц продажи:', error); - alert('Ошибка загрузки данных. Попробуйте ещё раз.'); + const salesUnits = await productManager.loadSalesUnits(product.id); + + if (!salesUnits || salesUnits.length === 0) { + showToast('error', 'Не удалось загрузить единицы продажи'); + return; } + + unitModalSalesUnits = salesUnits; + renderUnitSelectionList(); + + const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0]; + if (defaultUnit) { + selectUnit(defaultUnit); + } + + if (!unitModalInstance) { + unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal')); + } + unitModalInstance.show(); } -/** - * Отрисовывает список единиц продажи - */ function renderUnitSelectionList() { const listContainer = document.getElementById('unitSelectionList'); listContainer.innerHTML = ''; unitModalSalesUnits.forEach(unit => { - const card = document.createElement('div'); - card.className = 'unit-selection-card'; - card.dataset.unitId = unit.id; - card.onclick = () => selectUnit(unit); - - // Доступное количество - const availableQty = parseFloat(unit.available_quantity || 0); - let stockBadgeClass = 'stock-badge-none'; - let stockText = 'Нет на складе'; - - if (availableQty > 10) { - stockBadgeClass = 'stock-badge-good'; - stockText = `${availableQty} шт доступно`; - } else if (availableQty > 0) { - stockBadgeClass = 'stock-badge-low'; - stockText = `${availableQty} шт доступно`; - } - - // Бейдж "По умолчанию" - const defaultBadge = unit.is_default ? - 'По умолчанию' : ''; - - card.innerHTML = ` -
    -
    -
    ${unit.name}${defaultBadge}
    -
    -
    ${formatMoney(unit.actual_price)} руб
    -
    -
    - ${stockText} -
    - `; - + const card = createUnitCard(unit); listContainer.appendChild(card); }); } -/** - * Выбирает единицу продажи - * @param {object} unit - Объект единицы продажи - */ +function createUnitCard(unit) { + const card = document.createElement('div'); + card.className = 'unit-selection-card'; + card.dataset.unitId = unit.id; + card.onclick = () => selectUnit(unit); + + const availableQty = parseFloat(unit.available_quantity || 0); + let stockBadgeClass = 'stock-badge-none'; + let stockText = 'Нет на складе'; + + if (availableQty > 10) { + stockBadgeClass = 'stock-badge-good'; + stockText = `${availableQty} шт доступно`; + } else if (availableQty > 0) { + stockBadgeClass = 'stock-badge-low'; + stockText = `${availableQty} шт доступно`; + } + + const defaultBadge = unit.is_default ? + 'По умолчанию' : ''; + + card.innerHTML = ` +
    +
    +
    ${unit.name}${defaultBadge}
    +
    +
    ${formatMoney(unit.actual_price)} руб
    +
    +
    + ${stockText} +
    + `; + + return card; +} + function selectUnit(unit) { selectedSalesUnit = unit; - // Обновляем визуальное выделение document.querySelectorAll('.unit-selection-card').forEach(card => { - if (card.dataset.unitId === String(unit.id)) { - card.classList.add('selected'); - } else { - card.classList.remove('selected'); - } + card.classList.toggle('selected', card.dataset.unitId === String(unit.id)); }); - // Обновляем отображение выбранной единицы - document.getElementById('selectedUnitDisplay').textContent = - unit.name; + document.getElementById('selectedUnitDisplay').textContent = unit.name; - // Устанавливаем минимальное количество и шаг const qtyInput = document.getElementById('unitModalQuantity'); - qtyInput.value = roundQuantity(unit.min_quantity, 3); + qtyInput.value = roundQuantity(unit.min_quantity); qtyInput.min = unit.min_quantity; qtyInput.step = unit.quantity_step; - // Устанавливаем цену document.getElementById('unitModalPrice').value = unit.actual_price; - - // Обновляем подсказку - const hintEl = document.getElementById('unitQtyHint'); - hintEl.textContent = `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`; - - // Сбрасываем индикатор изменения цены + document.getElementById('unitQtyHint').textContent = + `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`; document.getElementById('priceOverrideIndicator').style.display = 'none'; - // Пересчитываем итого calculateUnitModalSubtotal(); - - // Валидируем количество validateUnitQuantity(); } -/** - * Проверяет количество на соответствие ограничениям - * @returns {boolean} - true если валидно - */ function validateUnitQuantity() { if (!selectedSalesUnit) return false; @@ -615,7 +779,6 @@ function validateUnitQuantity() { const errorEl = document.getElementById('unitQtyError'); const confirmBtn = document.getElementById('confirmAddUnitToCart'); - // Проверка минимального количества if (qty < parseFloat(selectedSalesUnit.min_quantity)) { errorEl.textContent = `Минимальное количество: ${selectedSalesUnit.min_quantity}`; errorEl.style.display = 'block'; @@ -623,7 +786,6 @@ function validateUnitQuantity() { return false; } - // Проверка шага (с учётом погрешности) const step = parseFloat(selectedSalesUnit.quantity_step); const minQty = parseFloat(selectedSalesUnit.min_quantity); const diff = qty - minQty; @@ -637,25 +799,18 @@ function validateUnitQuantity() { return false; } - // Всё ок, скрываем ошибку errorEl.style.display = 'none'; confirmBtn.disabled = false; return true; } -/** - * Рассчитывает итоговую сумму - */ function calculateUnitModalSubtotal() { - const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value) || 0; - const qty = roundQuantity(qtyRaw, 3); // Округляем количество + const qty = parseFloat(document.getElementById('unitModalQuantity').value) || 0; const price = parseFloat(document.getElementById('unitModalPrice').value) || 0; - // Округляем до 2 знаков после запятой для корректного отображения const subtotal = Math.round(qty * price * 100) / 100; document.getElementById('unitModalSubtotal').textContent = `${formatMoney(subtotal)} руб`; - // Проверяем изменение цены if (selectedSalesUnit && Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01) { document.getElementById('priceOverrideIndicator').style.display = 'block'; } else { @@ -663,31 +818,22 @@ function calculateUnitModalSubtotal() { } } -/** - * Добавляет товар с выбранной единицей в корзину - */ function addToCartFromModal() { - if (!validateUnitQuantity()) { - return; - } + if (!validateUnitQuantity()) return; - const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value); - const qty = roundQuantity(qtyRaw, 3); // Округляем количество + const qty = roundQuantity(parseFloat(document.getElementById('unitModalQuantity').value)); const price = parseFloat(document.getElementById('unitModalPrice').value); const priceOverridden = Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01; - // Формируем ключ корзины: product-{id}-{sales_unit_id} - const cartKey = `product-${unitModalProduct.id}-${selectedSalesUnit.id}`; + const cartKey = Cart.generateKey('product', unitModalProduct.id, selectedSalesUnit.id); - // Добавляем или обновляем в корзине if (cart.has(cartKey)) { const existing = cart.get(cartKey); - existing.qty = roundQuantity(existing.qty + qty, 3); // Округляем сумму - existing.price = price; // Обновляем цену - existing.quantity_step = parseFloat(selectedSalesUnit.quantity_step) || 1; // Обновляем шаг + cart.updateQuantity(cartKey, existing.qty + qty); + existing.price = price; existing.price_overridden = priceOverridden; } else { - cart.set(cartKey, { + cart.add(cartKey, { id: unitModalProduct.id, name: unitModalProduct.name, price: price, @@ -695,39 +841,26 @@ function addToCartFromModal() { type: 'product', sales_unit_id: selectedSalesUnit.id, unit_name: selectedSalesUnit.name, - quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества + quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, price_overridden: priceOverridden }); } - // Обновляем корзину - renderCart(); - saveCartToRedis(); - - // Перерисовываем товары для обновления визуального остатка if (!isShowcaseView) { renderProducts(); } - // Закрываем модальное окно unitModalInstance.hide(); } -/** - * Добавляет товар с единицей продажи напрямую в корзину (без модального окна) - * Используется для быстрого добавления когда у товара только одна единица продажи - * @param {object} product - Товар из ITEMS - * @param {object} salesUnit - Единица продажи (default_sales_unit) - * @param {number} qty - Количество для добавления - */ -async function addProductWithUnitToCart(product, salesUnit, qty = 1) { - const cartKey = `product-${product.id}-${salesUnit.id}`; +async function addProductWithUnit(product, salesUnit, qty = 1) { + const cartKey = Cart.generateKey('product', product.id, salesUnit.id); if (cart.has(cartKey)) { const existing = cart.get(cartKey); - existing.qty = roundQuantity(existing.qty + qty, 3); + await cart.updateQuantity(cartKey, existing.qty + qty); } else { - cart.set(cartKey, { + cart.add(cartKey, { id: product.id, name: product.name, price: Number(salesUnit.price), @@ -739,2798 +872,68 @@ async function addProductWithUnitToCart(product, salesUnit, qty = 1) { }); } - renderCart(); - saveCartToRedis(); - - // Перерисовываем товары для обновления визуального остатка if (!isShowcaseView) { renderProducts(); } - // Фокус на поле количества - setTimeout(() => { - const qtyInputs = document.querySelectorAll('.qty-input'); - const itemIndex = Array.from(cart.keys()).indexOf(cartKey); - - if (itemIndex !== -1 && qtyInputs[itemIndex]) { - if (!isMobileDevice()) { - qtyInputs[itemIndex].focus(); - qtyInputs[itemIndex].select(); - } - } - }, 50); -} - -function renderCategories() { - const grid = document.getElementById('categoryGrid'); - grid.innerHTML = ''; - - // Кнопка "Витрина" - первая в ряду - const showcaseCol = document.createElement('div'); - showcaseCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2'; - const showcaseCard = document.createElement('div'); - showcaseCard.className = 'card category-card showcase-card' + (isShowcaseView ? ' active' : ''); - showcaseCard.style.backgroundColor = '#fff3cd'; - showcaseCard.style.borderColor = '#ffc107'; - showcaseCard.onclick = async () => { - isShowcaseView = true; - currentCategoryId = null; - currentSearchQuery = ''; // Сбрасываем поиск - document.getElementById('searchInput').value = ''; // Очищаем поле поиска - await refreshShowcaseKits(); // Загружаем свежие данные - renderCategories(); - renderProducts(); - }; - const showcaseBody = document.createElement('div'); - showcaseBody.className = 'card-body'; - const showcaseName = document.createElement('div'); - showcaseName.className = 'category-name'; - showcaseName.innerHTML = ' ВИТРИНА'; - showcaseBody.appendChild(showcaseName); - showcaseCard.appendChild(showcaseBody); - showcaseCol.appendChild(showcaseCard); - grid.appendChild(showcaseCol); - - // Кнопка "Все" - const allCol = document.createElement('div'); - allCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2'; - const allCard = document.createElement('div'); - allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : ''); - allCard.dataset.categoryId = 'all'; // Для идентификации в updateSearchIndicator - allCard.onclick = async () => { - currentCategoryId = null; - isShowcaseView = false; - currentSearchQuery = ''; // Сбрасываем поиск - document.getElementById('searchInput').value = ''; // Очищаем поле поиска - renderCategories(); - await loadItems(); // Загрузка через API - }; - const allBody = document.createElement('div'); - allBody.className = 'card-body'; - const allName = document.createElement('div'); - allName.className = 'category-name'; - allName.textContent = 'Все товары'; - allBody.appendChild(allName); - allCard.appendChild(allBody); - allCol.appendChild(allCard); - grid.appendChild(allCol); - - // Категории - CATEGORIES.forEach(cat => { - const col = document.createElement('div'); - col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5'; - - const card = document.createElement('div'); - card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : ''); - card.onclick = async () => { - currentCategoryId = cat.id; - isShowcaseView = false; - currentSearchQuery = ''; // Сбрасываем поиск - document.getElementById('searchInput').value = ''; // Очищаем поле поиска - renderCategories(); - await loadItems(); // Загрузка через API - }; - - const body = document.createElement('div'); - body.className = 'card-body'; - - const name = document.createElement('div'); - name.className = 'category-name'; - name.textContent = cat.name; - - body.appendChild(name); - card.appendChild(body); - col.appendChild(card); - grid.appendChild(col); - }); -} - -function renderProducts() { - const grid = document.getElementById('productGrid'); - grid.innerHTML = ''; - - let filtered; - - // Если активен поиск (3+ символов) - показываем результаты поиска - if (currentSearchQuery && currentSearchQuery.length >= 3) { - // Базовые результаты - товары из API (поиск по всем товарам) - filtered = [...ITEMS]; - - // Если перед началом поиска была активна витрина - добавляем витринные комплекты в результаты - if (wasShowcaseViewBeforeSearch) { - const searchTerm = currentSearchQuery.toLowerCase().trim(); - const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0); - - // Фильтруем витринные комплекты по поисковому запросу - const filteredShowcaseKits = showcaseKits.filter(item => { - const name = (item.name || '').toLowerCase(); - const sku = (item.sku || '').toLowerCase(); - // Каждый токен должен совпадать хотя бы с одним словом в name или sku - return tokens.every(token => name.includes(token) || sku.includes(token)); - }); - - // Объединяем товары и витринные комплекты - filtered = [...filtered, ...filteredShowcaseKits]; - } - } else if (isShowcaseView) { - // Режим витрины без поиска - показываем все витринные комплекты - filtered = showcaseKits; - } else { - // Обычный режим без поиска - ITEMS отфильтрованы по категории - filtered = ITEMS; - } - - filtered.forEach(item => { - const col = document.createElement('div'); - col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5'; - - const card = document.createElement('div'); - card.className = 'card product-card'; - card.style.position = 'relative'; - card.onclick = () => addToCart(item); - - // Если это витринный комплект - добавляем кнопку редактирования - if (item.type === 'showcase_kit') { - // ИНДИКАЦИЯ БЛОКИРОВКИ - if (item.is_locked) { - // Создаем бейдж блокировки - const lockBadge = document.createElement('div'); - lockBadge.style.position = 'absolute'; - lockBadge.style.top = '5px'; - lockBadge.style.left = '5px'; - lockBadge.style.zIndex = '10'; - - if (item.locked_by_me) { - // Заблокирован мной - зеленый бейдж - lockBadge.className = 'badge bg-success'; - lockBadge.innerHTML = ' В корзине'; - lockBadge.title = 'Добавлен в вашу корзину'; - } else { - // Заблокирован другим кассиром - красный бейдж + блокируем карточку - lockBadge.className = 'badge bg-danger'; - lockBadge.innerHTML = ' Занят'; - lockBadge.title = `В корзине ${item.locked_by_user}`; - - // Затемняем карточку и блокируем клики - card.style.opacity = '0.5'; - card.style.cursor = 'not-allowed'; - card.onclick = (e) => { - e.stopPropagation(); - alert(`Этот букет уже в корзине кассира "${item.locked_by_user}".\nДождитесь освобождения блокировки.`); - }; - } - - card.appendChild(lockBadge); - } - - // Кнопка редактирования (только если НЕ заблокирован другим) - if (!item.is_locked || item.locked_by_me) { - const editBtn = document.createElement('button'); - editBtn.className = 'btn btn-sm btn-outline-primary'; - editBtn.style.position = 'absolute'; - editBtn.style.top = '5px'; - editBtn.style.right = '5px'; - editBtn.style.zIndex = '10'; - editBtn.innerHTML = ''; - editBtn.onclick = (e) => { - e.stopPropagation(); - openEditKitModal(item.id); - }; - card.appendChild(editBtn); - - // Индикатор неактуальной цены (красный кружок) - if (item.price_outdated) { - const outdatedBadge = document.createElement('div'); - outdatedBadge.className = 'badge bg-danger'; - outdatedBadge.style.position = 'absolute'; - outdatedBadge.style.top = '5px'; - outdatedBadge.style.right = '45px'; - outdatedBadge.style.zIndex = '10'; - outdatedBadge.style.width = '18px'; - outdatedBadge.style.height = '18px'; - outdatedBadge.style.padding = '0'; - outdatedBadge.style.borderRadius = '50%'; - outdatedBadge.style.display = 'flex'; - outdatedBadge.style.alignItems = 'center'; - outdatedBadge.style.justifyContent = 'center'; - outdatedBadge.style.fontSize = '10px'; - outdatedBadge.style.minWidth = '18px'; - outdatedBadge.title = 'Цена неактуальна'; - outdatedBadge.innerHTML = '!'; - card.appendChild(outdatedBadge); - } - } - } - - const body = document.createElement('div'); - body.className = 'card-body'; - - // Изображение товара/комплекта - const imageDiv = document.createElement('div'); - imageDiv.className = 'product-image'; - if (item.image) { - const img = document.createElement('img'); - img.src = item.image; - img.alt = item.name; - img.loading = 'lazy'; // Lazy loading - imageDiv.appendChild(img); - } else { - imageDiv.innerHTML = ''; - } - - // Информация о товаре/комплекте - const info = document.createElement('div'); - info.className = 'product-info'; - - const name = document.createElement('div'); - name.className = 'product-name'; - name.textContent = item.name; - - const stock = document.createElement('div'); - stock.className = 'product-stock'; - - // Для витринных комплектов показываем количество (доступно/всего) и дней на витрине - if (item.type === 'showcase_kit') { - const availableCount = item.available_count || 0; - const totalCount = item.total_count || availableCount; - const inCart = totalCount - availableCount; - - // Показываем: доступно / всего (и сколько в корзине) - let badgeClass = availableCount > 0 ? 'bg-success' : 'bg-secondary'; - let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`; - let cartInfo = inCart > 0 ? ` 🛒${inCart}` : ''; - - // Добавляем отображение дней с момента создания как бейдж справа - const daysAgo = formatDaysAgo(item.showcase_created_at); - const daysBadge = daysAgo ? ` ${daysAgo}` : ''; - - stock.innerHTML = `${badgeText}${daysBadge}${cartInfo}`; - stock.style.display = 'flex'; - stock.style.justifyContent = 'space-between'; - stock.style.alignItems = 'center'; - stock.style.color = '#856404'; - stock.style.fontWeight = 'bold'; - } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { - // Для обычных товаров показываем остатки - // Если у товара есть единицы продажи - отображаем в единицах продажи - const available = parseFloat(item.available_qty) || 0; - const reserved = parseFloat(item.reserved_qty) || 0; - - // Используем единицу продажи если есть - if (item.default_sales_unit) { - const unit = item.default_sales_unit; - const conversionFactor = parseFloat(unit.conversion_factor) || 1; - - // Вычисляем количество в корзине в единицах продажи - const cartKey = `product-${item.id}-${unit.id}`; - const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0; - - // Свободное количество в единицах продажи - const availableInUnit = parseFloat(item.available_qty_in_unit) || 0; - const reservedInUnit = reserved * conversionFactor; - const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty; - const freeRounded = roundQuantity(freeInUnit, 1); // Округляем для отображения - - // Создаём элементы для стилизации - const freeSpan = document.createElement('span'); - freeSpan.style.fontSize = '1.1em'; - freeSpan.style.fontWeight = 'bold'; - - const qtyText = document.createElement('span'); - qtyText.textContent = freeRounded; - freeSpan.appendChild(qtyText); - - const unitBadge = document.createElement('span'); - unitBadge.className = 'badge bg-secondary ms-1'; - unitBadge.style.fontSize = '0.7rem'; - unitBadge.textContent = unit.name; - freeSpan.appendChild(unitBadge); - - // Отображаем корзину если есть - if (inCartBaseQty > 0) { - const suffixSpan = document.createElement('span'); - suffixSpan.textContent = ` (−${roundQuantity(inCartBaseQty, 1)}🛒)`; - suffixSpan.style.fontSize = '0.85em'; - suffixSpan.style.marginLeft = '3px'; - - stock.appendChild(freeSpan); - stock.appendChild(suffixSpan); - } else { - stock.appendChild(freeSpan); - } - - // Цветовая индикация - if (freeInUnit <= 0) { - stock.style.color = '#dc3545'; // Красный - } else if (freeInUnit < 5) { - stock.style.color = '#ffc107'; // Жёлтый - } else { - stock.style.color = '#28a745'; // Зелёный - } - } else { - // Отображение в базовых единицах (старая логика) - const cartKey = `product-${item.id}`; - const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0; - - const free = available - reserved - inCart; - const freeRounded = roundQuantity(free, 3); - - const freeSpan = document.createElement('span'); - freeSpan.textContent = freeRounded; - freeSpan.style.fontSize = '1.1em'; - freeSpan.style.fontWeight = 'bold'; - - const suffixParts = []; - if (reserved > 0) { - suffixParts.push(`−${roundQuantity(reserved, 3)}`); - } - if (inCart > 0) { - suffixParts.push(`−${roundQuantity(inCart, 3)}🛒`); - } - - if (suffixParts.length > 0) { - const suffixSpan = document.createElement('span'); - suffixSpan.textContent = `(${suffixParts.join(' ')})`; - suffixSpan.style.fontSize = '0.85em'; - suffixSpan.style.marginLeft = '3px'; - - stock.appendChild(freeSpan); - stock.appendChild(suffixSpan); - } else { - stock.appendChild(freeSpan); - } - - if (free <= 0) { - stock.style.color = '#dc3545'; - } else if (free < 5) { - stock.style.color = '#ffc107'; - } else { - stock.style.color = '#28a745'; - } - } - } else { - // Комплекты: показываем доступное количество - if (item.type === 'kit' && item.free_qty !== undefined) { - const availableKits = parseFloat(item.free_qty) || 0; - if (availableKits > 0) { - stock.textContent = `В наличии: ${Math.floor(availableKits)} компл.`; - stock.style.color = '#28a745'; // Зелёный - } else { - stock.textContent = 'Под заказ'; - stock.style.color = '#dc3545'; // Красный - } - } else { - // Fallback для старых данных - stock.textContent = item.in_stock ? 'В наличии' : 'Под заказ'; - if (!item.in_stock) { - stock.style.color = '#dc3545'; - } - } - } - - const sku = document.createElement('div'); - sku.className = 'product-sku'; - - const skuText = document.createElement('span'); - skuText.textContent = item.sku || 'н/д'; - - const priceSpan = document.createElement('span'); - priceSpan.className = 'product-price'; - // Используем цену из единицы продажи если есть, иначе базовую цену - const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price; - priceSpan.textContent = `${formatMoney(itemPrice)}`; - - sku.appendChild(skuText); - sku.appendChild(priceSpan); - - info.appendChild(name); - info.appendChild(stock); - info.appendChild(sku); - - body.appendChild(imageDiv); - body.appendChild(info); - card.appendChild(body); - col.appendChild(card); - grid.appendChild(col); - }); -} - -// Загрузка товаров через API -async function loadItems(append = false) { - if (isLoadingItems) return; - - isLoadingItems = true; - - if (!append) { - currentPage = 1; - ITEMS = []; - } - - try { - const params = new URLSearchParams({ - page: currentPage, - page_size: 60 - }); - - // При активном поиске игнорируем категорию - ищем по всем товарам - if (currentCategoryId && !currentSearchQuery) { - params.append('category_id', currentCategoryId); - } - - // Добавляем поисковый запрос, если есть - if (currentSearchQuery) { - params.append('query', currentSearchQuery); - } - - const response = await fetch(`/pos/api/items/?${params}`); - const data = await response.json(); - - if (data.success) { - if (append) { - ITEMS = ITEMS.concat(data.items); - } else { - ITEMS = data.items; - } - - hasMoreItems = data.has_more; - - if (data.has_more) { - currentPage = data.next_page; - } - - renderProducts(); - } - } catch (error) { - console.error('Ошибка загрузки товаров:', error); - } finally { - isLoadingItems = false; + if (!isMobileDevice()) { + setTimeout(() => focusQuantityInput(cartKey), 50); } } -// Infinite scroll -function setupInfiniteScroll() { - const grid = document.getElementById('productGrid'); - const observer = new IntersectionObserver( - (entries) => { - entries.forEach(entry => { - if (entry.isIntersecting && hasMoreItems && !isLoadingItems && !isShowcaseView) { - loadItems(true); // Догрузка - } - }); - }, - { - rootMargin: '200px' - } - ); +// ===== ВИТРИНА ===== - // Наблюдаем за концом грида - const sentinel = document.createElement('div'); - sentinel.id = 'scroll-sentinel'; - sentinel.style.height = '1px'; - grid.parentElement.appendChild(sentinel); - observer.observe(sentinel); -} - -// ===== ВИЗУАЛЬНАЯ ИНДИКАЦИЯ ГЛОБАЛЬНОГО ПОИСКА ===== - -/** - * Обновляет визуальную индикацию глобального поиска. - * При активном поиске подсвечивает кнопку "Все товары". - */ -function updateSearchIndicator() { - const allCard = document.querySelector('.category-card[data-category-id="all"]'); - if (!allCard) return; - - if (currentSearchQuery && currentSearchQuery.length >= 3) { - // Активен глобальный поиск - подсвечиваем "Все товары" - allCard.classList.add('active'); - allCard.style.backgroundColor = '#e3f2fd'; // Светло-голубой фон - allCard.style.borderColor = '#2196f3'; // Синяя рамка - allCard.title = 'Идёт поиск по всем товарам'; - } else { - // Поиск неактивен - возвращаем обычный стиль - allCard.style.backgroundColor = ''; - allCard.style.borderColor = ''; - allCard.title = ''; - // Активный класс управляется renderCategories - if (currentCategoryId !== null) { - allCard.classList.remove('active'); - } - } -} - -async function addToCart(item) { - // ПРОВЕРКА НА НАЛИЧИЕ ЕДИНИЦ ПРОДАЖИ - // Если у товара одна единица продажи - добавляем сразу - // Если несколько - показываем модальное окно выбора - console.log('addToCart:', item.name, 'has_sales_units:', item.has_sales_units, 'sales_units_count:', item.sales_units_count); - - if (item.type === 'product' && item.has_sales_units) { - // Если одна единица продажи - добавляем сразу - if (item.sales_units_count === 1 && item.default_sales_unit) { - await addProductWithUnitToCart(item, item.default_sales_unit, 1); - return; - } - // Иначе открываем модальное окно выбора единицы - await openProductUnitModal(item); - return; - } - - const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1" - - // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) - if (item.type === 'showcase_kit') { - // Пытаемся заблокировать 1 экземпляр через API - // API сам проверит доступность и вернёт ошибку если нет свободных - try { - const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ quantity: 1 }) - }); - - const data = await response.json(); - - if (!response.ok || !data.success) { - // Нет доступных экземпляров или другая ошибка - alert(data.error || 'Не удалось добавить букет в корзину'); - // Обновляем витрину чтобы показать актуальное состояние - if (isShowcaseView) { - await loadShowcaseKits(); - } - return; - } - - // Успешно заблокировали - добавляем/обновляем в корзине - const lockedItemIds = data.locked_item_ids || []; - - if (cart.has(cartKey)) { - // Добавляем к существующим - const existing = cart.get(cartKey); - existing.qty += lockedItemIds.length; - existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds]; - } else { - // Создаём новую запись - cart.set(cartKey, { - id: item.id, - name: item.name, - price: Number(item.price), - qty: lockedItemIds.length, - type: item.type, - showcase_item_ids: lockedItemIds, - lock_expires_at: data.lock_expires_at - }); - } - - // Обновляем список витрины (чтобы показать актуальные available_count) - if (isShowcaseView) { - await loadShowcaseKits(); - } - - } catch (error) { - console.error('Ошибка при добавлении витринного комплекта:', error); - alert('Ошибка сервера. Попробуйте еще раз.'); - return; - } - } else { - // ОБЫЧНАЯ ЛОГИКА для товаров и комплектов - if (!cart.has(cartKey)) { - cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type }); - } else { - const cartItem = cart.get(cartKey); - cartItem.qty = roundQuantity(cartItem.qty + 1, 3); - } - } - - renderCart(); - saveCartToRedis(); // Сохраняем в Redis - - // Перерисовываем товары для обновления визуального остатка - if (!isShowcaseView && item.type === 'product') { - renderProducts(); - } - - // Автоматический фокус на поле количества (только для обычных товаров) - if (item.type !== 'showcase_kit') { - setTimeout(() => { - const qtyInputs = document.querySelectorAll('.qty-input'); - const itemIndex = Array.from(cart.keys()).indexOf(cartKey); - - if (itemIndex !== -1 && qtyInputs[itemIndex]) { - // Устанавливаем фокус только на десктопных устройствах - if (!isMobileDevice()) { - qtyInputs[itemIndex].focus(); - qtyInputs[itemIndex].select(); // Выделяем весь текст - } - } - }, 50); - } -} - -// Вспомогательная функция для обновления количества товара в корзине -async function updateCartItemQty(cartKey, newQty) { - const item = cart.get(cartKey); - if (!item) return; - - // Округляем новое количество - const roundedQty = roundQuantity(newQty, 3); - - if (roundedQty <= 0) { - await removeFromCart(cartKey); - } else { - item.qty = roundedQty; - renderCart(); - saveCartToRedis(); - - // Перерисовываем товары для обновления визуального остатка - if (!isShowcaseView && item.type === 'product') { - renderProducts(); - } - } -} - -function renderCart() { - const list = document.getElementById('cartList'); - list.innerHTML = ''; - let total = 0; - - if (cart.size === 0) { - list.innerHTML = '

    Корзина пуста

    '; - document.getElementById('cartTotal').textContent = '0.00'; - updateShowcaseButtonState(); // Обновляем состояние кнопки - updateMobileCartBar(); // Обновляем мобильный бар даже когда корзина пуста - return; - } - - cart.forEach((item, cartKey) => { - const row = document.createElement('div'); - row.className = 'cart-item mb-2'; - row.style.cursor = 'pointer'; - row.title = 'Нажмите для редактирования'; - - // Индикатор изменённой цены - if (item.price_overridden) { - row.classList.add('price-overridden'); - } - - // СПЕЦИАЛЬНАЯ СТИЛИЗАЦИЯ для витринных комплектов - const isShowcaseKit = item.type === 'showcase_kit'; - if (isShowcaseKit) { - row.style.backgroundColor = '#fff3cd'; // Желтый фон - row.style.border = '1px solid #ffc107'; - row.style.borderRadius = '4px'; - row.style.padding = '8px'; - } - - // Левая часть: Название и цена единицы - const namePrice = document.createElement('div'); - namePrice.className = 'item-name-price'; - - // Иконка только для комплектов - let typeIcon = ''; - if (item.type === 'kit' || item.type === 'showcase_kit') { - typeIcon = ' '; - } - - // Единица продажи (если есть) - let unitInfo = ''; - if (item.sales_unit_id && item.unit_name) { - unitInfo = ` ${item.unit_name}`; - } - - namePrice.innerHTML = ` -
    ${typeIcon}${item.name}
    -
    - ${formatMoney(item.price)}${unitInfo} -
    - `; - - // Знак умножения - const multiplySign = document.createElement('span'); - multiplySign.className = 'multiply-sign'; - multiplySign.textContent = 'x'; - - // Контейнер для кнопок количества - const qtyControl = document.createElement('div'); - qtyControl.className = 'd-flex align-items-center'; - qtyControl.style.gap = '2px'; - - // СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - if (isShowcaseKit) { - // Кнопка минус - const minusBtn = document.createElement('button'); - minusBtn.className = 'btn btn-outline-secondary btn-sm'; - minusBtn.innerHTML = ''; - minusBtn.onclick = async (e) => { - e.preventDefault(); - await decreaseShowcaseKitQty(cartKey); - }; - - // Поле количества (только для отображения, readonly) - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.className = 'qty-input form-control form-control-sm'; - qtyInput.style.width = '60px'; - qtyInput.style.textAlign = 'center'; - qtyInput.style.padding = '0.375rem 0.25rem'; - qtyInput.value = roundQuantity(item.qty, 3); - qtyInput.min = 1; - qtyInput.readOnly = true; // Только чтение - изменяем только через +/- - qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных - - // Кнопка плюс - const plusBtn = document.createElement('button'); - plusBtn.className = 'btn btn-outline-secondary btn-sm'; - plusBtn.innerHTML = ''; - plusBtn.onclick = async (e) => { - e.preventDefault(); - await increaseShowcaseKitQty(cartKey); - }; - - // Собираем контейнер - qtyControl.appendChild(minusBtn); - qtyControl.appendChild(qtyInput); - qtyControl.appendChild(plusBtn); - } else { - // ОБЫЧНАЯ ЛОГИКА для товаров и комплектов - - // Кнопка минус - const minusBtn = document.createElement('button'); - minusBtn.className = 'btn btn-outline-secondary btn-sm'; - minusBtn.innerHTML = ''; - minusBtn.onclick = async (e) => { - e.preventDefault(); - const currentQty = cart.get(cartKey).qty; - const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию - await updateCartItemQty(cartKey, roundQuantity(currentQty - step, 3)); - }; - - // Поле ввода количества - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.className = 'qty-input form-control form-control-sm'; - qtyInput.style.width = '60px'; - qtyInput.style.textAlign = 'center'; - qtyInput.style.padding = '0.375rem 0.25rem'; - qtyInput.value = roundQuantity(item.qty, 3); - qtyInput.min = 1; - qtyInput.step = item.quantity_step || 0.001; // Устанавливаем шаг единицы продажи - qtyInput.onchange = async (e) => { - const newQty = parseFloat(e.target.value) || 1; - await updateCartItemQty(cartKey, newQty); - }; - // Округление при потере фокуса - qtyInput.onblur = (e) => { - const rawValue = parseFloat(e.target.value) || 1; - e.target.value = roundQuantity(rawValue, 3); - }; - - // Кнопка плюс - const plusBtn = document.createElement('button'); - plusBtn.className = 'btn btn-outline-secondary btn-sm'; - plusBtn.innerHTML = ''; - plusBtn.onclick = async (e) => { - e.preventDefault(); - const currentQty = cart.get(cartKey).qty; - const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию - await updateCartItemQty(cartKey, roundQuantity(currentQty + step, 3)); - }; - - // Собираем контейнер - qtyControl.appendChild(minusBtn); - qtyControl.appendChild(qtyInput); - qtyControl.appendChild(plusBtn); - } - - // Сумма за позицию - const itemTotal = document.createElement('div'); - itemTotal.className = 'item-total'; - itemTotal.textContent = formatMoney(item.price * item.qty); - - // Кнопка удаления - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn btn-sm btn-link text-danger p-0'; - deleteBtn.innerHTML = ''; - deleteBtn.onclick = () => removeFromCart(cartKey); - - row.appendChild(namePrice); - row.appendChild(multiplySign); - row.appendChild(qtyControl); - row.appendChild(itemTotal); - row.appendChild(deleteBtn); - - // Обработчик клика для редактирования товара - row.addEventListener('click', function (e) { - // Игнорируем клики на кнопки управления количеством и удаления - if (e.target.closest('button') || e.target.closest('input')) { - return; - } - console.log('Cart row clicked, cartKey:', cartKey, 'CartItemEditor:', typeof window.CartItemEditor); - if (window.CartItemEditor) { - window.CartItemEditor.openModal(cartKey); - } else { - console.error('CartItemEditor not available!'); - } - }); - - list.appendChild(row); - - total += item.qty * item.price; - }); - - document.getElementById('cartTotal').textContent = formatMoney(total); - - // Обновляем состояние кнопки "НА ВИТРИНУ" - updateShowcaseButtonState(); - - // Обновляем мобильный бар корзины - updateMobileCartBar(); -} - -/** - * Склонение слов в зависимости от числа - * @param {number} number - число - * @param {string} one - форма для 1 (товар) - * @param {string} two - форма для 2-4 (товара) - * @param {string} five - форма для 5+ (товаров) - */ -function getNoun(number, one, two, five) { - const n = Math.abs(number); - const n10 = n % 10; - const n100 = n % 100; - - if (n100 >= 11 && n100 <= 19) { - return five; - } - if (n10 === 1) { - return one; - } - if (n10 >= 2 && n10 <= 4) { - return two; - } - return five; -} - -/** - * Обновляет мобильный бар корзины - */ -function updateMobileCartBar() { - const countEl = document.querySelector('.mobile-cart-count'); - const totalEl = document.querySelector('.mobile-cart-total'); - - if (!countEl || !totalEl) return; - - let count = 0; - let total = 0; - - cart.forEach((item) => { - count += item.qty; - total += item.qty * item.price; - }); - - // Округляем количество до целого для отображения - const displayCount = Math.round(count); - countEl.textContent = `${displayCount} ${getNoun(displayCount, 'товар', 'товара', 'товаров')}`; - totalEl.textContent = formatMoney(total); -} - -async function removeFromCart(cartKey) { - const item = cart.get(cartKey); - - // СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку - if (item && item.type === 'showcase_kit') { - try { - // Передаём конкретные showcase_item_ids для снятия блокировки - const body = {}; - if (item.showcase_item_ids && item.showcase_item_ids.length > 0) { - body.showcase_item_ids = item.showcase_item_ids; - } - - const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - const data = await response.json(); - - if (!response.ok) { - console.error('Ошибка при снятии блокировки:', data.error); - // Продолжаем удаление из корзины даже при ошибке - } - - // Обновляем список витрины (чтобы убрать индикацию блокировки) - if (isShowcaseView) { - await loadShowcaseKits(); - } - - } catch (error) { - console.error('Ошибка при снятии блокировки витринного комплекта:', error); - // Продолжаем удаление из корзины - } - } - - cart.delete(cartKey); - renderCart(); - saveCartToRedis(); // Сохраняем в Redis - - // Перерисовываем товары для обновления визуального остатка - if (!isShowcaseView && item && item.type === 'product') { - renderProducts(); - } -} - -/** - * Увеличивает количество витринного комплекта в корзине - * Добавляет еще один экземпляр через API (если есть доступные) - */ -async function increaseShowcaseKitQty(cartKey) { - const item = cart.get(cartKey); - if (!item || item.type !== 'showcase_kit') return; - - try { - // Пытаемся заблокировать еще 1 экземпляр - const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ quantity: 1 }) - }); - - const data = await response.json(); - - if (!response.ok || !data.success) { - // Нет доступных экземпляров - alert(data.error || 'Нет доступных экземпляров этого букета на витрине'); - return; - } - - // Успешно заблокировали - обновляем корзину - const lockedItemIds = data.locked_item_ids || []; - item.qty += lockedItemIds.length; - item.showcase_item_ids = [...(item.showcase_item_ids || []), ...lockedItemIds]; - - renderCart(); - saveCartToRedis(); - - // Обновляем список витрины - if (isShowcaseView) { - await loadShowcaseKits(); - } - } catch (error) { - console.error('Ошибка при увеличении количества витринного комплекта:', error); - alert('Ошибка сервера. Попробуйте еще раз.'); - } -} - -/** - * Уменьшает количество витринного комплекта в корзине - * Снимает блокировку с одного экземпляра (последнего в списке) - */ -async function decreaseShowcaseKitQty(cartKey) { - const item = cart.get(cartKey); - if (!item || item.type !== 'showcase_kit') return; - - // Если количество = 1, удаляем полностью - if (item.qty <= 1) { - await removeFromCart(cartKey); - return; - } - - try { - // Снимаем блокировку с последнего экземпляра - const showcaseItemIds = item.showcase_item_ids || []; - if (showcaseItemIds.length === 0) { - // Нет ID - просто удаляем - await removeFromCart(cartKey); - return; - } - - // Берем последний ID из списка - const itemIdToRelease = showcaseItemIds[showcaseItemIds.length - 1]; - - const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ showcase_item_ids: [itemIdToRelease] }) - }); - - const data = await response.json(); - - if (!response.ok) { - console.error('Ошибка при снятии блокировки:', data.error); - } - - // Обновляем корзину - item.qty -= 1; - item.showcase_item_ids = showcaseItemIds.filter(id => id !== itemIdToRelease); - - renderCart(); - saveCartToRedis(); - - // Обновляем список витрины - if (isShowcaseView) { - await loadShowcaseKits(); - } - } catch (error) { - console.error('Ошибка при уменьшении количества витринного комплекта:', error); - alert('Ошибка сервера. Попробуйте еще раз.'); - } -} - -async function clearCart() { - // Сбрасываем все свои блокировки витринных букетов - try { - await fetch('/pos/api/showcase-kits/release-all-my-locks/', { - method: 'POST', - headers: { 'X-CSRFToken': getCsrfToken() } - }); - } catch (e) { - console.error('Ошибка сброса блокировок:', e); - } - - // Очищаем корзину - cart.clear(); - renderCart(); - saveCartToRedis(); // Сохраняем пустую корзину в Redis - - // Обновляем отображение товаров/витрины чтобы показать актуальные остатки - if (isShowcaseView) { - await loadShowcaseKits(); - } else { - renderProducts(); // Перерисовать карточки товаров с актуальными остатками - } -} - -document.getElementById('clearCart').onclick = clearCart; - -/** - * Обновляет состояние кнопки "НА ВИТРИНУ" - * Блокирует кнопку если в корзине есть витринный комплект - */ function updateShowcaseButtonState() { const showcaseBtn = document.getElementById('addToShowcaseBtn'); if (!showcaseBtn) return; - // Проверяем наличие витринных комплектов в корзине - let hasShowcaseKit = false; - for (const [cartKey, item] of cart) { - if (item.type === 'showcase_kit') { - hasShowcaseKit = true; - break; - } - } + const hasShowcaseKit = cart.hasShowcaseKits(); - if (hasShowcaseKit) { - // Блокируем кнопку - showcaseBtn.disabled = true; - showcaseBtn.classList.add('disabled'); - showcaseBtn.style.opacity = '0.5'; - showcaseBtn.style.cursor = 'not-allowed'; - showcaseBtn.title = '⚠️ В корзине уже есть витринный комплект. Удалите его перед созданием нового'; - } else { - // Разблокируем кнопку - showcaseBtn.disabled = false; - showcaseBtn.classList.remove('disabled'); - showcaseBtn.style.opacity = '1'; - showcaseBtn.style.cursor = 'pointer'; - showcaseBtn.title = 'Создать букет на витрину из текущей корзины'; - } + showcaseBtn.disabled = hasShowcaseKit; + showcaseBtn.classList.toggle('disabled', hasShowcaseKit); + showcaseBtn.style.opacity = hasShowcaseKit ? '0.5' : '1'; + showcaseBtn.style.cursor = hasShowcaseKit ? 'not-allowed' : 'pointer'; + showcaseBtn.title = hasShowcaseKit + ? '⚠️ В корзине уже есть витринный комплект. Удалите его перед созданием нового' + : 'Создать букет на витрину из текущей корзины'; } -// Кнопка "На витрину" - функционал будет добавлен позже -document.getElementById('addToShowcaseBtn').onclick = () => { - openCreateTempKitModal(); -}; +// ===== ОТЛОЖЕННЫЙ ЗАКАЗ ===== -// Функция открытия модального окна для создания временного комплекта -async function openCreateTempKitModal() { - // Проверяем что корзина не пуста - if (cart.size === 0) { - alert('Корзина пуста. Добавьте товары перед созданием комплекта.'); +/** + * Создаёт отложенный заказ (черновик) из текущей корзины + */ +async function createDeferredOrder() { + if (cart.isEmpty) { + showToast('error', 'Корзина пуста! Добавьте товары в корзину.'); return; } - // Проверяем что в корзине НЕТ витринных комплектов - let hasShowcaseKit = false; - for (const [cartKey, item] of cart) { - if (item.type === 'showcase_kit') { - hasShowcaseKit = true; - break; - } - } - - if (hasShowcaseKit) { - alert('⚠️ В корзине уже есть витринный комплект!\n\nНельзя создать новый букет на витрину, пока в корзине находится другой витринный букет.\n\nУдалите витринный букет из корзины или завершите текущую продажу.'); - return; - } - - // Проверяем что в корзине только товары (не обычные комплекты) - let hasKits = false; - for (const [cartKey, item] of cart) { - if (item.type === 'kit') { - hasKits = true; - break; - } - } - - if (hasKits) { - alert('В корзине есть комплекты. Для витрины добавляйте только отдельные товары.'); - return; - } - - // Копируем содержимое cart в tempCart (изолированное состояние модалки) - tempCart.clear(); - cart.forEach((item, key) => { - tempCart.set(key, { ...item }); // Глубокая копия объекта - }); - - // Генерируем название по умолчанию - const randomSuffix = Math.floor(Math.random() * 900) + 100; - const defaultName = `Витринный букет ${randomSuffix}`; - document.getElementById('tempKitName').value = defaultName; - - // Загружаем список витрин - await loadShowcases(); - - // Заполняем список товаров из tempCart - renderTempKitItems(); - - // Открываем модальное окно - const modal = new bootstrap.Modal(document.getElementById('createTempKitModal')); - modal.show(); -} - -// Открытие модального окна для редактирования комплекта -async function openEditKitModal(kitId) { try { - // Загружаем данные комплекта - const response = await fetch(`/pos/api/product-kits/${kitId}/`); - const data = await response.json(); - - if (!data.success) { - alert(`Ошибка: ${data.error}`); - return; - } - - const kit = data.kit; - - // Устанавливаем режим редактирования - isEditMode = true; - editingKitId = kitId; - - // Загружаем список витрин - await loadShowcases(); - - // Очищаем tempCart и заполняем составом комплекта - tempCart.clear(); - kit.items.forEach(item => { - const cartKey = `product-${item.product_id}`; - tempCart.set(cartKey, { - id: item.product_id, - name: item.name, - price: Number(item.price), - actual_catalog_price: item.actual_catalog_price ? Number(item.actual_catalog_price) : Number(item.price), - qty: Number(item.qty), - type: 'product' - }); - }); - renderTempKitItems(); // Отображаем товары в модальном окне - - // Заполняем поля формы - document.getElementById('tempKitName').value = kit.name; - document.getElementById('tempKitDescription').value = kit.description; - - // Заполняем поле даты размещения на витрине - if (kit.showcase_created_at) { - // Конвертируем ISO в формат datetime-local (YYYY-MM-DDTHH:MM) - const date = new Date(kit.showcase_created_at); - // Компенсация смещения часового пояса - const offset = date.getTimezoneOffset() * 60000; - const localDate = new Date(date.getTime() - offset); - document.getElementById('showcaseCreatedAt').value = localDate.toISOString().slice(0, 16); - } else { - document.getElementById('showcaseCreatedAt').value = ''; - } - - document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type; - document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value; - - if (kit.sale_price) { - document.getElementById('useSalePrice').checked = true; - document.getElementById('salePrice').value = kit.sale_price; - document.getElementById('salePriceBlock').style.display = 'block'; - } else { - document.getElementById('useSalePrice').checked = false; - document.getElementById('salePrice').value = ''; - document.getElementById('salePriceBlock').style.display = 'none'; - } - - // Выбираем витрину - if (kit.showcase_id) { - document.getElementById('showcaseSelect').value = kit.showcase_id; - } - - // Отображаем фото, если есть - if (kit.photo_url) { - document.getElementById('photoPreviewImg').src = kit.photo_url; - document.getElementById('photoPreview').style.display = 'block'; - } else { - document.getElementById('photoPreview').style.display = 'none'; - } - - // Обновляем цены - updatePriceCalculations(); - - // Меняем заголовок и кнопку - document.getElementById('createTempKitModalLabel').textContent = 'Редактирование витринного букета'; - document.getElementById('confirmCreateTempKit').textContent = 'Сохранить изменения'; - - // По��азываем кнопку "Разобрать" и блок добавления товаров - document.getElementById('disassembleKitBtn').style.display = 'block'; - document.getElementById('writeOffKitBtn').style.display = 'block'; - document.getElementById('showcaseKitQuantityBlock').style.display = 'none'; - document.getElementById('addProductBlock').style.display = 'block'; - - // Инициализируем компонент поиска товаров - setTimeout(() => { - if (window.ProductSearchPicker) { - const picker = ProductSearchPicker.init('#temp-kit-product-picker', { - onAddSelected: function (product, instance) { - if (product) { - // Добавляем товар в tempCart - const cartKey = `product-${product.id}`; - if (tempCart.has(cartKey)) { - // Увеличиваем количество - const existing = tempCart.get(cartKey); - existing.qty += 1; - } else { - // Добавляем новый товар - tempCart.set(cartKey, { - id: product.id, - name: product.text || product.name, - price: Number(product.price || 0), - qty: 1, - type: 'product' - }); - } - - // Обновляем отображение - renderTempKitItems(); - - // Очищаем выбор в пикере - instance.clearSelection(); - } - } - }); - } - }, 100); - - // Открываем модальное окно - const modal = new bootstrap.Modal(document.getElementById('createTempKitModal')); - modal.show(); - - // Проверяем актуальность цен (сразу после открытия) - checkPricesActual(); - - } catch (error) { - console.error('Error loading kit for edit:', error); - alert('Ошибка при загрузке комплекта'); - } -} - -// Проверка актуальности цен в витринном комплекте -function checkPricesActual() { - // Удаляем старый warning если есть - const existingWarning = document.getElementById('priceOutdatedWarning'); - if (existingWarning) existingWarning.remove(); - - // Проверяем цены используя actual_catalog_price из tempCart (уже загружен с бэкенда) - const outdatedItems = []; - let oldTotalPrice = 0; - let newTotalPrice = 0; - - tempCart.forEach((item, cartKey) => { - if (item.type === 'product' && item.actual_catalog_price !== undefined) { - const savedPrice = parseFloat(item.price); - const actualPrice = parseFloat(item.actual_catalog_price); - const qty = parseFloat(item.qty) || 1; - - if (Math.abs(savedPrice - actualPrice) > 0.01) { - oldTotalPrice += savedPrice * qty; - newTotalPrice += actualPrice * qty; - outdatedItems.push({ - name: item.name, - old: savedPrice, - new: actualPrice, - qty: qty - }); - } - } - }); - - if (outdatedItems.length > 0) { - showPriceOutdatedWarning(oldTotalPrice, newTotalPrice); - } -} - -// Показать warning о неактуальных ценах -function showPriceOutdatedWarning(oldTotalPrice, newTotalPrice) { - const modalBody = document.querySelector('#createTempKitModal .modal-body'); - - const warning = document.createElement('div'); - warning.id = 'priceOutdatedWarning'; - warning.className = 'alert alert-warning alert-dismissible fade show d-flex align-items-start'; - warning.innerHTML = ` - -
    - Цена неактуальна!
    - При сохранении комплекта было: ${formatMoney(oldTotalPrice)} руб.
    - Актуальная цена сейчас: ${formatMoney(newTotalPrice)} руб. - -
    - - `; - - modalBody.insertBefore(warning, modalBody.firstChild); -} - -// Актуализировать цены в комплекте -function actualizeKitPrices() { - tempCart.forEach((item) => { - if (item.type === 'product' && item.actual_catalog_price !== undefined) { - item.price = item.actual_catalog_price; - // Удаляем actual_catalog_price чтобы не показывался warning снова - delete item.actual_catalog_price; - } - }); - - // Отключаем чекбокс "Установить свою цену" чтобы использовать актуализированную цену - document.getElementById('useSalePrice').checked = false; - document.getElementById('salePrice').value = ''; - document.getElementById('salePriceBlock').style.display = 'none'; - - // Перерисовываем товары и пересчитываем цену (после отключения чекбокса!) - renderTempKitItems(); - updatePriceCalculations(); - - // Убираем warning - const warning = document.getElementById('priceOutdatedWarning'); - if (warning) warning.remove(); -} - -// Обновление списка витринных комплектов -async function loadShowcaseKits() { - try { - const response = await fetch('/pos/api/showcase-kits/'); - const data = await response.json(); - - if (data.success) { - showcaseKits = data.items; - // Перерисовываем грид если мы в режиме витрины - if (isShowcaseView) { - renderProducts(); - } - } else { - console.error('Failed to refresh showcase kits:', data); - } - } catch (error) { - console.error('Error refreshing showcase kits:', error); - } -} - -// Алиас для совместимости -const refreshShowcaseKits = loadShowcaseKits; - -// Загрузка списка витрин -async function loadShowcases() { - try { - const response = await fetch('/pos/api/get-showcases/'); - const data = await response.json(); - - const select = document.getElementById('showcaseSelect'); - select.innerHTML = ''; - - if (data.success && data.showcases.length > 0) { - let defaultShowcaseId = null; - - data.showcases.forEach(showcase => { - const option = document.createElement('option'); - option.value = showcase.id; - option.textContent = `${showcase.name} (${showcase.warehouse_name})`; - select.appendChild(option); - - // Запоминаем витрину по умолчанию - if (showcase.is_default) { - defaultShowcaseId = showcase.id; - } - }); - - // Автовыбор витрины по умолчанию - if (defaultShowcaseId) { - select.value = defaultShowcaseId; - } - } else { - select.innerHTML = ''; - } - } catch (error) { - console.error('Error loading showcases:', error); - alert('Ошибка загрузки витрин'); - } -} - -// Отображение товаров из tempCart в модальном окне -function renderTempKitItems() { - const container = document.getElementById('tempKitItemsList'); - container.innerHTML = ''; - - let estimatedTotal = 0; - - tempCart.forEach((item, cartKey) => { - // Только товары (не комплекты) - if (item.type !== 'product') return; - - const itemDiv = document.createElement('div'); - itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; - - // Левая часть: название и цена - const leftDiv = document.createElement('div'); - leftDiv.className = 'flex-grow-1'; - - // Название товара - const nameSpan = document.createElement('strong'); - nameSpan.className = 'small'; - nameSpan.textContent = item.name; - leftDiv.appendChild(nameSpan); - leftDiv.appendChild(document.createElement('br')); - - // Цена с возможностью редактирования - const priceContainer = document.createElement('div'); - priceContainer.className = 'd-inline-flex align-items-center gap-1'; - - // Отображение цены (кликабельное) - const priceDisplay = document.createElement('small'); - priceDisplay.className = 'text-muted price-display'; - priceDisplay.style.cursor = 'pointer'; - priceDisplay.innerHTML = `${formatMoney(item.price)} руб. / шт.`; - priceDisplay.title = 'Кликните для изменения цены'; - - // Поле ввода (скрыто по умолчанию) - const priceInput = document.createElement('input'); - priceInput.type = 'number'; - priceInput.step = '0.01'; - priceInput.className = 'form-control form-control-sm'; - priceInput.style.width = '80px'; - priceInput.style.display = 'none'; - priceInput.value = item.price; - - // Клик на цену — показать input - priceDisplay.onclick = () => { - priceDisplay.style.display = 'none'; - priceInput.style.display = 'inline-block'; - priceInput.focus(); - priceInput.select(); - }; - - // Потеря фокуса или Enter — сохранить и скрыть input - const savePrice = () => { - const newPrice = parseFloat(priceInput.value) || 0; - item.price = newPrice; - priceDisplay.innerHTML = `${formatMoney(newPrice)} руб. / шт.`; - priceInput.style.display = 'none'; - priceDisplay.style.display = 'inline'; - renderTempKitItems(); // Пересчёт итогов - }; - - priceInput.onblur = savePrice; - priceInput.onkeydown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - savePrice(); - } - }; - - priceContainer.appendChild(priceInput); - priceContainer.appendChild(priceDisplay); - leftDiv.appendChild(priceContainer); - - // Правая часть: контролы количества и удаление - const rightDiv = document.createElement('div'); - rightDiv.className = 'd-flex align-items-center gap-2'; - - // Кнопка минус - const minusBtn = document.createElement('button'); - minusBtn.className = 'btn btn-sm btn-outline-secondary'; - minusBtn.innerHTML = ''; - minusBtn.onclick = (e) => { - e.preventDefault(); - if (item.qty > 1) { - item.qty--; - } else { - tempCart.delete(cartKey); - } - renderTempKitItems(); - }; - - // Поле количества - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.className = 'form-control form-control-sm text-center'; - qtyInput.style.width = '60px'; - qtyInput.value = item.qty; - qtyInput.min = 1; - qtyInput.onchange = (e) => { - const newQty = parseInt(e.target.value) || 1; - item.qty = Math.max(1, newQty); - renderTempKitItems(); - }; - - // Кнопка плюс - const plusBtn = document.createElement('button'); - plusBtn.className = 'btn btn-sm btn-outline-secondary'; - plusBtn.innerHTML = ''; - plusBtn.onclick = (e) => { - e.preventDefault(); - item.qty++; - renderTempKitItems(); - }; - - // Сумма за товар - const totalDiv = document.createElement('div'); - totalDiv.className = 'text-end ms-2'; - totalDiv.style.minWidth = '80px'; - totalDiv.innerHTML = `${formatMoney(item.qty * item.price)} руб.`; - - // Кнопка удаления - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn btn-sm btn-outline-danger'; - deleteBtn.innerHTML = ''; - deleteBtn.onclick = (e) => { - e.preventDefault(); - tempCart.delete(cartKey); - renderTempKitItems(); - }; - - rightDiv.appendChild(minusBtn); - rightDiv.appendChild(qtyInput); - rightDiv.appendChild(plusBtn); - rightDiv.appendChild(totalDiv); - rightDiv.appendChild(deleteBtn); - - itemDiv.appendChild(leftDiv); - itemDiv.appendChild(rightDiv); - container.appendChild(itemDiv); - - estimatedTotal += item.qty * item.price; - }); - - // Если корзина пуста - if (tempCart.size === 0) { - container.innerHTML = '

    Нет товаров

    '; - } - - // Обновляем все расчеты цен - updatePriceCalculations(estimatedTotal); -} - -// Расчет и обновление всех цен -function updatePriceCalculations(basePrice = null) { - // Если basePrice не передан, пересчитываем из tempCart - if (basePrice === null) { - basePrice = 0; - tempCart.forEach((item, cartKey) => { - if (item.type === 'product') { - basePrice += item.qty * item.price; - } - }); - } - - // Базовая цена - document.getElementById('tempKitBasePrice').textContent = formatMoney(basePrice) + ' руб.'; - - // Корректировка - const adjustmentType = document.getElementById('priceAdjustmentType').value; - const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0; - - let calculatedPrice = basePrice; - if (adjustmentType !== 'none' && adjustmentValue > 0) { - if (adjustmentType === 'increase_percent') { - calculatedPrice = basePrice + (basePrice * adjustmentValue / 100); - } else if (adjustmentType === 'increase_amount') { - calculatedPrice = basePrice + adjustmentValue; - } else if (adjustmentType === 'decrease_percent') { - calculatedPrice = Math.max(0, basePrice - (basePrice * adjustmentValue / 100)); - } else if (adjustmentType === 'decrease_amount') { - calculatedPrice = Math.max(0, basePrice - adjustmentValue); - } - } - - document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.'; - - // Финальная цена (с учетом sale_price если задана) - const useSalePrice = document.getElementById('useSalePrice').checked; - const salePrice = parseFloat(document.getElementById('salePrice').value) || 0; - - let finalPrice = calculatedPrice; - if (useSalePrice && salePrice > 0) { - finalPrice = salePrice; - } - - document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice); -} - -// Обработчики для полей цены -document.getElementById('priceAdjustmentType').addEventListener('change', function () { - const adjustmentBlock = document.getElementById('adjustmentValueBlock'); - if (this.value === 'none') { - adjustmentBlock.style.display = 'none'; - document.getElementById('priceAdjustmentValue').value = '0'; - } else { - adjustmentBlock.style.display = 'block'; - } - updatePriceCalculations(); -}); - -document.getElementById('priceAdjustmentValue').addEventListener('input', function () { - updatePriceCalculations(); -}); - -document.getElementById('useSalePrice').addEventListener('change', function () { - const salePriceBlock = document.getElementById('salePriceBlock'); - if (this.checked) { - salePriceBlock.style.display = 'block'; - } else { - salePriceBlock.style.display = 'none'; - document.getElementById('salePrice').value = ''; - } - updatePriceCalculations(); -}); - -document.getElementById('salePrice').addEventListener('input', function () { - updatePriceCalculations(); -}); - -// Обработчик загрузки фото -document.getElementById('tempKitPhoto').addEventListener('change', function (e) { - const file = e.target.files[0]; - if (file) { - if (!file.type.startsWith('image/')) { - alert('Пожалуйста, выберите файл изображения'); - this.value = ''; - return; - } - - // Превью - const reader = new FileReader(); - reader.onload = function (event) { - document.getElementById('photoPreviewImg').src = event.target.result; - document.getElementById('photoPreview').style.display = 'block'; - }; - reader.readAsDataURL(file); - } -}); - -// Удаление фото -document.getElementById('removePhoto').addEventListener('click', function () { - document.getElementById('tempKitPhoto').value = ''; - document.getElementById('photoPreview').style.display = 'none'; - document.getElementById('photoPreviewImg').src = ''; -}); - -// Подтверждение создания/редактирования временного комплекта -document.getElementById('confirmCreateTempKit').onclick = async () => { - const kitName = document.getElementById('tempKitName').value.trim(); - const showcaseId = document.getElementById('showcaseSelect').value; - const description = document.getElementById('tempKitDescription').value.trim(); - const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value; - const photoFile = document.getElementById('tempKitPhoto').files[0]; - - // Валидация - if (!kitName) { - alert('Введите название комплекта'); - return; - } - - if (!showcaseId && !isEditMode) { - alert('Выберите витрину'); - return; - } - - // Собираем товары из tempCart (изолированное состояние модалки) - const items = []; - tempCart.forEach((item, cartKey) => { - if (item.type === 'product') { - items.push({ - product_id: item.id, + const items = cart.toArray().map(({ item }) => { + const itemData = { + type: item.type, + id: item.id, quantity: item.qty, - unit_price: item.price // Передаём изменённую цену из корзины - }); - } - }); - - if (items.length === 0) { - alert('Нет товаров для создания комплекта'); - return; - } - - // Получаем данные о ценах - const priceAdjustmentType = document.getElementById('priceAdjustmentType').value; - const priceAdjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0; - const useSalePrice = document.getElementById('useSalePrice').checked; - const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0; - - // Получаем количество букетов для создания - const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1; - - // Вычисляем итоговую цену комплекта на основе изменённых цен в корзине - let calculatedPrice = 0; - tempCart.forEach((item) => { - if (item.type === 'product') { - calculatedPrice += item.qty * item.price; - } - }); - - // Формируем FormData для отправки с файлом - const formData = new FormData(); - formData.append('kit_name', kitName); - if (showcaseId) { - formData.append('showcase_id', showcaseId); - formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину - } - formData.append('description', description); - if (showcaseCreatedAt) { - formData.append('showcase_created_at', showcaseCreatedAt); - } - formData.append('items', JSON.stringify(items)); - formData.append('price_adjustment_type', priceAdjustmentType); - formData.append('price_adjustment_value', priceAdjustmentValue); - // Если пользователь явно указал свою цену - if (useSalePrice && salePrice > 0) { - formData.append('sale_price', salePrice); - } - - // Фото: для редактирования проверяем, удалено ли оно - if (photoFile) { - formData.append('photo', photoFile); - } else if (isEditMode && document.getElementById('photoPreview').style.display === 'none') { - // Если фото было удалено - formData.append('remove_photo', '1'); - } - - // Отправляем запрос на сервер - const confirmBtn = document.getElementById('confirmCreateTempKit'); - confirmBtn.disabled = true; - - const url = isEditMode - ? `/pos/api/product-kits/${editingKitId}/update/` - : '/pos/api/create-temp-kit/'; - - const actionText = isEditMode ? 'Сохранение...' : 'Создание...'; - confirmBtn.innerHTML = `${actionText}`; - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken() - // Не указываем Content-Type - браузер сам установит multipart/form-data - }, - body: formData - }); - - const data = await response.json(); - - if (data.success) { - // Успех! - const createdCount = data.available_count || 1; - const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : ''; - - let successMessage = isEditMode - ? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.` - : `✅ ${data.message} - -Комплект: ${data.kit_name} -Цена: ${data.kit_price} руб.${qtyInfo} -Зарезервировано компонентов: ${data.reservations_count}`; - - // Если есть предупреждение о нехватке товара - добавляем его - if (data.warnings && data.warnings.length > 0) { - successMessage += '\n\n⚠️ ВНИМАНИЕ: Нехватка товара на складе!\n'; - data.warnings.forEach(warning => { - successMessage += `\n• ${warning}`; - }); - successMessage += '\n\nПроверьте остатки и пополните склад.'; + price: item.price + }; + if (item.sales_unit_id) { + itemData.sales_unit_id = item.sales_unit_id; } - - alert(successMessage); - - // Очищаем tempCart (изолированное состояние модалки) - tempCart.clear(); - - // Сбрасываем поля формы - document.getElementById('tempKitDescription').value = ''; - document.getElementById('showcaseCreatedAt').value = ''; - document.getElementById('tempKitPhoto').value = ''; - document.getElementById('photoPreview').style.display = 'none'; - document.getElementById('priceAdjustmentType').value = 'none'; - document.getElementById('priceAdjustmentValue').value = '0'; - document.getElementById('adjustmentValueBlock').style.display = 'none'; - document.getElementById('useSalePrice').checked = false; - document.getElementById('salePrice').value = ''; - document.getElementById('salePriceBlock').style.display = 'none'; - document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества - - // Запоминаем, был ли режим редактирования до сброса - const wasEditMode = isEditMode; - - // Сбрасываем режим редактирования - isEditMode = false; - editingKitId = null; - - // Закрываем модальное окно - const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); - modal.hide(); - - // Если это было СОЗДАНИЕ витринного комплекта из корзины, - // очищаем основную корзину POS - if (!wasEditMode) { - await clearCart(); - } - - // Обновляем витринные комплекты и переключаемся на вид витрины - isShowcaseView = true; - currentCategoryId = null; - await refreshShowcaseKits(); - renderCategories(); - renderProducts(); - } else { - alert(`Ошибка: ${data.error}`); - } - } catch (error) { - console.error('Error saving kit:', error); - alert('Ошибка при сохранении комплекта'); - } finally { - confirmBtn.disabled = false; - const btnText = isEditMode - ? ' Сохранить изменения' - : ' Создать и зарезервировать'; - confirmBtn.innerHTML = btnText; - } -}; - -// Обработчик кнопки "Разобрать букет" -document.getElementById('disassembleKitBtn').addEventListener('click', async () => { - if (!isEditMode || !editingKitId) { - alert('Ошибка: режим редактирования не активен'); - return; - } - - // Запрос подтверждения - const confirmed = confirm( - 'Вы уверены?\n\n' + - 'Букет будет разобран:\n' + - '• Все резервы компонентов будут освобождены\n' + - '• Комплект будет помечен как "Снят"\n\n' + - 'Это действие нельзя отменить!' - ); - - if (!confirmed) { - return; - } - - try { - const response = await fetch(`/pos/api/product-kits/${editingKitId}/disassemble/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken() + if (item.type === 'showcase_kit' && item.showcase_item_ids) { + itemData.showcase_item_ids = item.showcase_item_ids; } + return itemData; }); - const data = await response.json(); + const customer = customerManager.getCurrentCustomer(); - if (data.success) { - alert(`✅ ${data.message}\n\nОсвобождено резервов: ${data.released_count}`); - - // Закрываем модальное окно - const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); - modal.hide(); - - // Обновляем витринные комплекты - isShowcaseView = true; - currentCategoryId = null; - await refreshShowcaseKits(); - renderCategories(); - renderProducts(); - } else { - alert(`❌ Ошибка: ${data.error}`); - } - } catch (error) { - console.error('Error disassembling kit:', error); - alert('Произошла ошибка при разборе букета'); - } -}); - -// Обработчик кнопки "Списать букет" -document.getElementById('writeOffKitBtn').addEventListener('click', async () => { - if (!isEditMode || !editingKitId) { - alert('Ошибка: режим редактирования не активен'); - return; - } - - // Запрос подтверждения - const confirmed = confirm( - 'Вы уверены?\n\n' + - 'Букет будет списан:\n' + - '• Будет создан документ списания с компонентами букета\n' + - '• Комплект будет помечен как "Снят"\n' + - '• Будет открыта страница документа для редактирования\n\n' + - 'Продолжить?' - ); - - if (!confirmed) { - return; - } - - try { - const response = await fetch(`/pos/api/product-kits/${editingKitId}/write-off/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken() - } - }); - - const data = await response.json(); - - if (data.success) { - // Закрываем модальное окно - const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); - modal.hide(); - - // Перенаправляем на страницу документа - window.location.href = data.redirect_url; - } else { - alert(`❌ Ошибка: ${data.error}`); - } - } catch (error) { - console.error('Error writing off kit:', error); - alert('Произошла ошибка при списании букета'); - } -}); - -// Вспомогательная функция для определения мобильного устройства -function isMobileDevice() { - // Проверяем по юзер-агенту и размеру экрана - const userAgent = navigator.userAgent || navigator.vendor || window.opera; - - // Проверка по юзер-агенту - const mobileKeywords = ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Windows Phone']; - const isMobileUA = mobileKeywords.some(keyword => - userAgent.indexOf(keyword) > -1 - ); - - // Проверка по размеру экрана (ширина меньше 768px часто указывает на мобильные устройства) - const isSmallScreen = window.innerWidth < 768; - - return isMobileUA || isSmallScreen; -} - -// Вспомогательная функция для получения CSRF токена (единая версия) -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - -// Алиас для обратной совместимости -// ВАЖНО: При CSRF_USE_SESSIONS=True токен хранится в сессии, а не в cookie -// Извлекаем его из скрытого поля в HTML ({% csrf_token %}) -const getCsrfToken = () => { - // Пытаемся найти токен в DOM (из {% csrf_token %}) - const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]'); - if (csrfInput) { - return csrfInput.value; - } - - // Fallback: пытаемся прочитать из cookie (если CSRF_USE_SESSIONS=False) - return getCookie('csrftoken'); -}; - -// Сброс режима редактирования при закрытии модального окна -document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal', function () { - // Очищаем tempCart (изолированное состояние модалки) - tempCart.clear(); - - // Сброс режима редактирования при закрытии модального окна - if (isEditMode) { - // Сбрасываем режим редактирования - isEditMode = false; - editingKitId = null; - - // Восстанавливаем заголовок и текст кнопки - document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины'; - document.getElementById('confirmCreateTempKit').innerHTML = ' Создать и зарезервировать'; - - // Скрываем кнопки "Разобрать" и "Списать" и блок добавления товаров - document.getElementById('disassembleKitBtn').style.display = 'none'; - document.getElementById('writeOffKitBtn').style.display = 'none'; - document.getElementById('showcaseKitQuantityBlock').style.display = 'block'; - document.getElementById('addProductBlock').style.display = 'none'; - } -}); - -// Открытие модалки "Продажа" и рендер сводки корзины -document.getElementById('checkoutNow').onclick = () => { - if (cart.size === 0) { - alert('Корзина пуста. Добавьте товары перед продажей.'); - return; - } - renderCheckoutModal(); - const modal = new bootstrap.Modal(document.getElementById('checkoutModal')); - modal.show(); -}; - -// Рендер позиций корзины и итога в модалке продажи -function renderCheckoutModal() { - const container = document.getElementById('checkoutItems'); - container.innerHTML = ''; - let total = 0; - - cart.forEach((item) => { - const row = document.createElement('div'); - row.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; - - // Иконка для комплектов - let typeIcon = ''; - if (item.type === 'kit' || item.type === 'showcase_kit') { - typeIcon = ''; - } else { - typeIcon = ''; - } - - row.innerHTML = ` -
    -
    ${typeIcon}${item.name}
    - ${item.qty} шт × ${formatMoney(item.price)} руб. -
    -
    ${formatMoney(item.qty * item.price)} руб.
    - `; - container.appendChild(row); - total += item.qty * item.price; - }); - - // Обновляем информацию о клиенте - updateCustomerDisplay(); -} - -// ===== CHECKOUT: ПОДТВЕРЖДЕНИЕ ПРОДАЖИ ===== - -let paymentWidget = null; - -// Переменные состояния скидок -let appliedPromoCode = null; // примененный промокод -let appliedManualDiscount = null; // выбранная вручную скидка (из списка) -let appliedCustomDiscount = null; // произвольная скидка {value: number, isPercent: boolean} -let availableDiscounts = []; // список доступных скидок -let skipAutoDiscount = false; // флаг отмены авто-скидки -let cartDiscounts = { - orderDiscounts: [], // скидки на заказ (теперь массив) - itemDiscounts: [], // скидки на позиции - totalDiscount: 0, // общая сумма скидки - excludedBy: null // исключающая скидка -}; - -// При открытии модалки checkout -document.getElementById('checkoutModal').addEventListener('show.bs.modal', async () => { - const customer = selectedCustomer || SYSTEM_CUSTOMER; - const walletBalance = customer.wallet_balance || 0; - - // Сбрасываем скидки - resetDiscounts(); - - // Показываем баланс кошелька - const walletDiv = document.getElementById('checkoutWalletBalance'); - if (customer.id !== SYSTEM_CUSTOMER.id) { - document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2); - walletDiv.style.display = 'block'; - } else { - walletDiv.style.display = 'none'; - } - - // Вычисляем итоговую сумму - let totalAmount = 0; - cart.forEach((item) => { - totalAmount += item.qty * item.price; - }); - - // Проверяем автоматические скидки - await checkAutoDiscounts(); - - // Загружаем доступные скидки для ручного выбора - await loadAvailableDiscounts(); - - // Применяем скидки к итоговой сумме - const finalTotal = Math.max(0, totalAmount - cartDiscounts.totalDiscount); - document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; - - // Инициализируем виджет в single mode - initPaymentWidget('single', { - order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 }, - customer: { id: customer.id, name: customer.name, wallet_balance: walletBalance } - }); -}); - -// Переключение режима оплаты -document.getElementById('singlePaymentMode').addEventListener('click', function () { - document.getElementById('singlePaymentMode').classList.add('active'); - document.getElementById('mixedPaymentMode').classList.remove('active'); - reinitPaymentWidget('single'); -}); - -document.getElementById('mixedPaymentMode').addEventListener('click', function () { - document.getElementById('mixedPaymentMode').classList.add('active'); - document.getElementById('singlePaymentMode').classList.remove('active'); - reinitPaymentWidget('mixed'); -}); - -function reinitPaymentWidget(mode) { - const customer = selectedCustomer || SYSTEM_CUSTOMER; - const totalAmountText = document.getElementById('checkoutFinalPrice').textContent; - const totalAmount = parseFloat(totalAmountText.replace(/[^\d.]/g, '')); - - initPaymentWidget(mode, { - order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 }, - customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 } - }); -} - -// ===== ФУНКЦИИ ДЛЯ РАБОТЫ СО СКИДКАМИ ===== - -// Сброс скидок -function resetDiscounts() { - appliedPromoCode = null; - appliedManualDiscount = null; - appliedCustomDiscount = null; - availableDiscounts = []; - skipAutoDiscount = false; - cartDiscounts = { - orderDiscount: null, - itemDiscounts: [], - totalDiscount: 0 - }; - - // Сбрасываем UI - document.getElementById('promoCodeInput').value = ''; - document.getElementById('promoCodeError').style.display = 'none'; - document.getElementById('promoCodeError').textContent = ''; - document.getElementById('promoCodeSuccess').style.display = 'none'; - document.getElementById('promoCodeSuccess').textContent = ''; - document.getElementById('removePromoBtn').style.display = 'none'; - - // Новые элементы UI - document.getElementById('autoDiscountsContainer').style.display = 'none'; - document.getElementById('autoDiscountsList').innerHTML = ''; - document.getElementById('manualDiscountContainer').style.display = 'none'; - document.getElementById('discountsSummary').style.display = 'none'; - document.getElementById('itemDiscountsBreakdown').innerHTML = ''; - - // Сбрасываем произвольную скидку - document.getElementById('customDiscountInput').value = ''; - document.getElementById('customDiscountIsPercent').checked = true; - document.getElementById('customDiscountError').style.display = 'none'; - document.getElementById('customDiscountError').textContent = ''; - document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block'; - document.getElementById('removeCustomDiscountBtn').style.display = 'none'; -} - -// Проверить автоматические скидки -async function checkAutoDiscounts() { - try { - const items = Array.from(cart.values()).map(item => ({ - type: item.type, - id: item.id, - quantity: item.qty, - price: item.price - })); - - const customer = selectedCustomer || SYSTEM_CUSTOMER; - - const response = await fetch('/pos/api/discounts/calculate/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - }, - body: JSON.stringify({ - items: items, - customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null, - manual_discount_id: appliedManualDiscount?.id || null, - skip_auto_discount: skipAutoDiscount - }) - }); - - const result = await response.json(); - - if (result.success) { - cartDiscounts.totalDiscount = result.total_discount || 0; - cartDiscounts.orderDiscounts = result.order_discounts || []; - cartDiscounts.itemDiscounts = result.item_discounts || []; - cartDiscounts.excludedBy = result.excluded_by || null; - - updateDiscountsUI(result); - } - } catch (error) { - console.error('Ошибка при проверке автоматических скидок:', error); - } -} - -// Получить иконку для режима объединения -function getCombineModeIcon(mode) { - const icons = { - 'stack': '', - 'max_only': '', - 'exclusive': '' - }; - return icons[mode] || ''; -} - -// Получить описание режима объединения -function getCombineModeTitle(mode) { - const titles = { - 'stack': 'Складывается с другими скидками', - 'max_only': 'Применяется только максимальная из этого типа', - 'exclusive': 'Отменяет все другие скидки' - }; - return titles[mode] || mode; -} - -// Обновить UI скидок -function updateDiscountsUI(result) { - const autoContainer = document.getElementById('autoDiscountsContainer'); - const autoList = document.getElementById('autoDiscountsList'); - const summaryDiv = document.getElementById('discountsSummary'); - const itemBreakdown = document.getElementById('itemDiscountsBreakdown'); - - // Очистка - autoList.innerHTML = ''; - itemBreakdown.innerHTML = ''; - - let hasDiscounts = false; - - // 1. Скидки на заказ (теперь может быть несколько) - const orderDiscounts = result.order_discounts || []; - if (orderDiscounts.length > 0) { - hasDiscounts = true; - autoContainer.style.display = 'block'; - - orderDiscounts.forEach(disc => { - const div = document.createElement('div'); - div.className = 'd-flex justify-content-between align-items-center w-100'; - const modeIcon = getCombineModeIcon(disc.combine_mode); - div.innerHTML = ` - ${modeIcon} ${disc.discount_name} - -${disc.discount_amount.toFixed(2)} руб. - `; - autoList.appendChild(div); - }); - - // Показываем информацию о комбинировании - if (orderDiscounts.length > 1) { - const infoDiv = document.createElement('div'); - infoDiv.className = 'text-muted small mt-1'; - infoDiv.innerHTML = ' Скидки скомбинированы'; - autoList.appendChild(infoDiv); - } - - // Показываем кнопку отмены (только если еще не пропущена) - document.getElementById('skipAutoDiscountBtn').style.display = 'block'; - } else { - autoContainer.style.display = 'none'; - document.getElementById('skipAutoDiscountBtn').style.display = 'none'; - } - - // 2. Скидки на позиции (новый формат с массивом discounts) - const itemDiscounts = result.item_discounts || []; - if (itemDiscounts.length > 0) { - hasDiscounts = true; - itemDiscounts.forEach(item => { - if (item.discounts && item.discounts.length > 0) { - const discNames = item.discounts.map(d => { - const modeIcon = getCombineModeIcon(d.combine_mode); - return `${modeIcon} ${d.discount_name}`; - }).join(', '); - const div = document.createElement('div'); - div.className = 'text-muted small'; - div.innerHTML = `• ${discNames}: -${item.total_discount.toFixed(2)} руб.`; - itemBreakdown.appendChild(div); - } - }); - } - - // 3. Ручная скидка (из списка) - if (appliedManualDiscount) { - hasDiscounts = true; - document.getElementById('manualDiscountContainer').style.display = 'block'; - document.getElementById('manualDiscountName').textContent = appliedManualDiscount.name; - document.getElementById('manualDiscountAmount').textContent = - `-${appliedManualDiscount.amount.toFixed(2)} руб.`; - } else { - document.getElementById('manualDiscountContainer').style.display = 'none'; - } - - // 4. Произвольная скидка - if (appliedCustomDiscount) { - hasDiscounts = true; - const customDiscountAmount = appliedCustomDiscount.isPercent - ? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100 - : appliedCustomDiscount.value; - const discountText = appliedCustomDiscount.isPercent - ? `-${appliedCustomDiscount.value}% (-${customDiscountAmount.toFixed(2)} руб.)` - : `-${customDiscountAmount.toFixed(2)} руб.`; - - // Показываем в summary или добавляем как отдельную строку - const customDiv = document.createElement('div'); - customDiv.className = 'd-flex justify-content-between align-items-center mt-1'; - customDiv.innerHTML = ` - Произвольная скидка ${discountText} - `; - itemBreakdown.appendChild(customDiv); - } - - // Показываем/скрываем summary - if (hasDiscounts) { - summaryDiv.style.display = 'block'; - document.getElementById('discountsSubtotal').textContent = - (result.cart_subtotal || 0).toFixed(2) + ' руб.'; - - // Рассчитываем итоговую скидку с учетом произвольной - let totalDiscount = result.total_discount || 0; - if (appliedCustomDiscount) { - const customDiscountAmount = appliedCustomDiscount.isPercent - ? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100 - : appliedCustomDiscount.value; - totalDiscount += customDiscountAmount; - } - document.getElementById('discountsTotalDiscount').textContent = - '-' + totalDiscount.toFixed(2) + ' руб.'; - } else { - summaryDiv.style.display = 'none'; - } - - // Обновляем итоговую цену - let totalDiscount = result.total_discount || 0; - if (appliedCustomDiscount) { - const customDiscountAmount = appliedCustomDiscount.isPercent - ? (result.cart_subtotal || 0) * appliedCustomDiscount.value / 100 - : appliedCustomDiscount.value; - totalDiscount += customDiscountAmount; - } - const finalTotal = Math.max(0, (result.cart_subtotal || 0) - totalDiscount); - document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; - - // Пересчитываем виджет оплаты - reinitPaymentWidget(document.getElementById('singlePaymentMode').classList.contains('active') ? 'single' : 'mixed'); -} - -// Загрузить доступные скидки -async function loadAvailableDiscounts() { - try { - let cartTotal = 0; - cart.forEach((item) => { - cartTotal += item.qty * item.price; - }); - - const response = await fetch(`/pos/api/discounts/available/?cart_total=${cartTotal}`); - const result = await response.json(); - - if (result.success) { - availableDiscounts = result.order_discounts; - renderDiscountsDropdown(result.order_discounts); - } - } catch (error) { - console.error('Ошибка загрузки скидок:', error); - } -} - -// Отобразить список скидок в dropdown -function renderDiscountsDropdown(discounts) { - const list = document.getElementById('discountsDropdownList'); - list.innerHTML = ''; - - if (discounts.length === 0) { - list.innerHTML = '
  • Нет доступных скидок
  • '; - return; - } - - discounts.forEach(d => { - const li = document.createElement('li'); - const valueText = d.discount_type === 'percentage' ? `${d.value}%` : `${d.value} руб.`; - const minText = d.min_order_amount ? `(от ${d.min_order_amount} руб.)` : ''; - const modeIcon = getCombineModeIcon(d.combine_mode); - const modeTitle = getCombineModeTitle(d.combine_mode); - - const a = document.createElement('a'); - a.href = '#'; - a.className = 'dropdown-item d-flex justify-content-between'; - a.title = modeTitle; - a.innerHTML = ` - ${modeIcon} ${d.name} - ${valueText} ${minText} - `; - a.onclick = (e) => { - e.preventDefault(); - applyManualDiscount(d); - }; - li.appendChild(a); - list.appendChild(li); - }); -} - -// Применить скидку вручную -async function applyManualDiscount(discount) { - // Рассчитываем сумму скидки на клиенте (для отображения до ответа сервера) - let cartTotal = 0; - cart.forEach((item) => { - cartTotal += item.qty * item.price; - }); - let discountAmount = discount.discount_type === 'percentage' - ? cartTotal * (discount.value / 100) - : discount.value; - - appliedManualDiscount = { ...discount, amount: discountAmount }; - await checkAutoDiscounts(); - await loadAvailableDiscounts(); -} - -// Удалить ручную скидку -document.getElementById('removeManualDiscountBtn').addEventListener('click', async () => { - appliedManualDiscount = null; - await checkAutoDiscounts(); - await loadAvailableDiscounts(); -}); - -// Отменить автоматическую скидку -document.getElementById('skipAutoDiscountBtn').addEventListener('click', async () => { - skipAutoDiscount = true; - await checkAutoDiscounts(); -}); - -// Применить промокод -async function applyPromoCode() { - const code = document.getElementById('promoCodeInput').value.trim().toUpperCase(); - if (!code) return; - - // Вычисляем сумму корзины - let cartTotal = 0; - cart.forEach((item) => { - cartTotal += item.qty * item.price; - }); - - try { - const response = await fetch('/pos/api/discounts/validate-promo/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - }, - body: JSON.stringify({ - promo_code: code, - cart_total: cartTotal - }) - }); - - const result = await response.json(); - - if (result.success) { - appliedPromoCode = result.promo_code; - - // Пересчитываем скидки с промокодом (updateDiscountsUI обновит UI) - await recalculateDiscountsWithPromo(code); - - // Показываем успех - document.getElementById('promoCodeSuccess').textContent = - `Скидка: ${result.promo_code.discount_name} (${result.promo_code.discount_type === 'percentage' ? result.promo_code.discount_value + '%' : result.promo_code.discount_value + ' руб.'})`; - document.getElementById('promoCodeSuccess').style.display = 'block'; - document.getElementById('promoCodeError').style.display = 'none'; - document.getElementById('removePromoBtn').style.display = 'inline-block'; - } else { - // Показываем ошибку - document.getElementById('promoCodeError').textContent = result.error || 'Неверный промокод'; - document.getElementById('promoCodeError').style.display = 'block'; - document.getElementById('promoCodeSuccess').style.display = 'none'; - } - } catch (error) { - console.error('Ошибка при применении промокода:', error); - document.getElementById('promoCodeError').textContent = 'Ошибка при проверке промокода'; - document.getElementById('promoCodeError').style.display = 'block'; - } -} - -// Пересчитать скидки с промокодом -async function recalculateDiscountsWithPromo(promoCode) { - try { - const items = Array.from(cart.values()).map(item => ({ - type: item.type, - id: item.id, - quantity: item.qty, - price: item.price - })); - - const customer = selectedCustomer || SYSTEM_CUSTOMER; - - const response = await fetch('/pos/api/discounts/calculate/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - }, - body: JSON.stringify({ - items: items, - promo_code: promoCode, - customer_id: customer.id !== SYSTEM_CUSTOMER.id ? customer.id : null - }) - }); - - const result = await response.json(); - - if (result.success) { - cartDiscounts.totalDiscount = result.total_discount || 0; - cartDiscounts.orderDiscounts = result.order_discounts || []; - cartDiscounts.itemDiscounts = result.item_discounts || []; - cartDiscounts.excludedBy = result.excluded_by || null; - updateDiscountsUI(result); - } - } catch (error) { - console.error('Ошибка при пересчёте скидок:', error); - } -} - -// Обновить итоговую сумму с учётом скидок -function updateCheckoutTotalWithDiscounts() { - let cartTotal = 0; - cart.forEach((item) => { - cartTotal += item.qty * item.price; - }); - - const finalTotal = Math.max(0, cartTotal - cartDiscounts.totalDiscount); - document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalTotal) + ' руб.'; - - // Пересоздаём платёжный виджет с новой суммой - const customer = selectedCustomer || SYSTEM_CUSTOMER; - initPaymentWidget('single', { - order: { total: finalTotal, amount_due: finalTotal, amount_paid: 0 }, - customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 } - }); -} - -// Удалить промокод -function removePromoCode() { - appliedPromoCode = null; - - // Пересчитываем без промокода (updateDiscountsUI обновит UI) - recalculateDiscountsWithPromo(null).then(() => { - document.getElementById('promoCodeInput').value = ''; - document.getElementById('removePromoBtn').style.display = 'none'; - document.getElementById('promoCodeSuccess').style.display = 'none'; - }); -} - -// Обработчики кнопок промокода -document.getElementById('applyPromoBtn').addEventListener('click', applyPromoCode); - -document.getElementById('removePromoBtn').addEventListener('click', removePromoCode); - -document.getElementById('promoCodeInput').addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - applyPromoCode(); - } -}); - -// ===== ПРОИЗВОЛЬНАЯ СКИДКА ===== - -// Применить произвольную скидку -async function applyCustomDiscount() { - const input = document.getElementById('customDiscountInput'); - const isPercent = document.getElementById('customDiscountIsPercent').checked; - const errorDiv = document.getElementById('customDiscountError'); - - const value = parseFloat(input.value); - - // Валидация - if (isNaN(value) || value <= 0) { - errorDiv.textContent = 'Введите корректное значение скидки'; - errorDiv.style.display = 'block'; - return; - } - - if (isPercent && value > 100) { - errorDiv.textContent = 'Процент не может превышать 100%'; - errorDiv.style.display = 'block'; - return; - } - - // Проверяем сумму корзины - let cartTotal = 0; - cart.forEach((item) => { - cartTotal += item.qty * item.price; - }); - - if (!isPercent && value > cartTotal) { - errorDiv.textContent = `Скидка не может превышать сумму заказа (${cartTotal.toFixed(2)} руб.)`; - errorDiv.style.display = 'block'; - return; - } - - // Сохраняем произвольную скидку - appliedCustomDiscount = { value, isPercent }; - - // Сбрасываем другие типы скидок (взаимоисключающие) - appliedPromoCode = null; - appliedManualDiscount = null; - - // Обновляем UI - errorDiv.style.display = 'none'; - document.getElementById('applyCustomDiscountBtn').style.display = 'none'; - document.getElementById('removeCustomDiscountBtn').style.display = 'inline-block'; - document.getElementById('promoCodeInput').value = ''; - document.getElementById('promoCodeSuccess').style.display = 'none'; - document.getElementById('promoCodeError').style.display = 'none'; - - // Пересчитываем скидки - await checkAutoDiscounts(); -} - -// Удалить произвольную скидку -async function removeCustomDiscount() { - appliedCustomDiscount = null; - - // Обновляем UI - document.getElementById('customDiscountInput').value = ''; - document.getElementById('applyCustomDiscountBtn').style.display = 'inline-block'; - document.getElementById('removeCustomDiscountBtn').style.display = 'none'; - document.getElementById('customDiscountError').style.display = 'none'; - - // Пересчитываем скидки - await checkAutoDiscounts(); -} - -// Обработчики кнопок произвольной скидки -document.getElementById('applyCustomDiscountBtn').addEventListener('click', applyCustomDiscount); - -document.getElementById('removeCustomDiscountBtn').addEventListener('click', removeCustomDiscount); - -document.getElementById('customDiscountInput').addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - applyCustomDiscount(); - } -}); - -document.getElementById('customDiscountInput').addEventListener('input', () => { - document.getElementById('customDiscountError').style.display = 'none'; -}); - -async function initPaymentWidget(mode, data) { - const paymentMethods = [ - { id: 1, code: 'account_balance', name: 'С баланса счёта' }, - { id: 2, code: 'cash', name: 'Наличными' }, - { id: 3, code: 'card', name: 'Картой' }, - { id: 4, code: 'online', name: 'Онлайн' } - ]; - - // Динамически загружаем PaymentWidget если еще не загружен - if (!window.PaymentWidget) { - try { - const module = await import('/static/orders/js/payment_widget.js'); - window.PaymentWidget = module.PaymentWidget; - } catch (error) { - console.error('Ошибка загрузки PaymentWidget:', error); - alert('Ошибка загрузки модуля оплаты. Перезагрузите страницу.'); - return; - } - } - - paymentWidget = new window.PaymentWidget({ - containerId: 'paymentWidgetContainer', - mode: mode, - order: data.order, - customer: data.customer, - paymentMethods: paymentMethods, - onSubmit: (paymentsData) => handleCheckoutSubmit(paymentsData) - }); -} - -// Обработчик кнопки "Подтвердить продажу" -document.getElementById('confirmCheckoutBtn').onclick = () => { - if (paymentWidget) { - paymentWidget.submit(); - } -}; - -// Отправка заказа на сервер -async function handleCheckoutSubmit(paymentsData) { - try { - // Блокируем кнопку - const btn = document.getElementById('confirmCheckoutBtn'); - btn.disabled = true; - btn.innerHTML = 'Обработка...'; - - // Собираем данные - const customer = selectedCustomer || SYSTEM_CUSTOMER; const orderData = { customer_id: customer.id, - warehouse_id: currentWarehouse.id, - items: Array.from(cart.values()).map(item => { - const itemData = { - type: item.type, - id: item.id, - quantity: item.qty, - price: item.price - }; - // Для витринных букетов передаём ID конкретных экземпляров - if (item.type === 'showcase_kit' && item.showcase_item_ids) { - itemData.showcase_item_ids = item.showcase_item_ids; - } - // Для товаров с единицами продажи - if (item.sales_unit_id) { - itemData.sales_unit_id = item.sales_unit_id; - } - return itemData; - }), - payments: paymentsData, - notes: document.getElementById('orderNote').value.trim(), - promo_code: appliedPromoCode?.code || null, - manual_discount_id: appliedManualDiscount?.id || null, - custom_discount: appliedCustomDiscount ? { - value: appliedCustomDiscount.value, - is_percent: appliedCustomDiscount.isPercent - } : null + items: items }; - // Отправляем на сервер - const response = await fetch('/pos/api/checkout/', { + const response = await safeFetch(CONFIG.API.CREATE_DEFERRED_ORDER, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -3542,215 +945,270 @@ async function handleCheckoutSubmit(paymentsData) { const result = await response.json(); if (result.success) { - console.log('✅ Заказ успешно создан:', result); - - // Показываем toast уведомление - showToast('success', `Заказ #${result.order_number} успешно создан! Сумма: ${result.total_amount.toFixed(2)} руб.`); + showToast('success', `Заказ #${result.order_number} создан (черновик)`); // Очищаем корзину cart.clear(); - renderCart(); - console.log('🧹 Корзина очищена'); - // Закрываем модалку - const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal')); - if (modal) { - modal.hide(); - console.log('❌ Модалка закрыта'); + // Перезагружаем витрину если нужно + if (isShowcaseView) { + await productManager.loadShowcaseKits(); + renderProducts(); } - // Перезагружаем страницу для обновления остатков товаров - console.log('🔄 Перезагрузка страницы...'); - setTimeout(() => { - window.location.reload(); - }, 500); - + // Открываем форму редактирования + window.open(`/orders/${result.order_number}/edit/`, '_blank'); } else { - showToast('error', 'Ошибка: ' + result.error); + showToast('error', result.error || 'Ошибка при создании заказа'); } - } catch (error) { - console.error('Ошибка checkout:', error); - showToast('error', 'Ошибка при проведении продажи: ' + error.message); - } finally { - // Разблокируем кнопку - const btn = document.getElementById('confirmCheckoutBtn'); - btn.disabled = false; - btn.innerHTML = ' Подтвердить продажу'; + console.error('Ошибка при создании отложенного заказа:', error); + showToast('error', 'Произошла ошибка при создании черновика заказа'); } } -// ===== ОБРАБОТЧИКИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== +// ===== ОБРАБОТЧИКИ СОБЫТИЙ ===== -// Кнопка "Выбрать клиента" в корзине -document.getElementById('customerSelectBtn').addEventListener('click', () => { - const modal = new bootstrap.Modal(document.getElementById('selectCustomerModal')); - modal.show(); -}); +function initEventListeners() { + // Очистка корзины + document.getElementById('clearCart')?.addEventListener('click', clearCart); -// Кнопка сброса клиента на системного (в корзине) -document.getElementById('resetCustomerBtn').addEventListener('click', () => { - selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name, SYSTEM_CUSTOMER.wallet_balance || 0); -}); + // Отложенный заказ + document.getElementById('scheduleLater')?.addEventListener('click', createDeferredOrder); + document.getElementById('mobileScheduleLaterBtn')?.addEventListener('click', createDeferredOrder); -// Кнопка "Выбрать клиента" в модалке продажи -document.getElementById('checkoutCustomerSelectBtn').addEventListener('click', () => { - const modal = new bootstrap.Modal(document.getElementById('selectCustomerModal')); - modal.show(); -}); + // Поиск + initSearch(); -// Кнопка сброса клиента на системного (в модалке продажи) -document.getElementById('checkoutResetCustomerBtn').addEventListener('click', () => { - selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name, SYSTEM_CUSTOMER.wallet_balance || 0); -}); + // Мобильная корзина + initMobileCart(); -// Кнопка "Создать нового клиента" в модалке выбора -document.getElementById('createNewCustomerBtn').addEventListener('click', () => { - // Закрываем модалку выбора - const selectModal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); - selectModal.hide(); + // Модальное окно единицы продажи + initUnitModal(); - // Открываем модалку создания - openCreateCustomerModal(); -}); + // Checkout + initCheckout(); -// Кнопка "Выбрать системного клиента" -document.getElementById('selectSystemCustomerBtn').addEventListener('click', () => { - selectCustomer(SYSTEM_CUSTOMER.id, SYSTEM_CUSTOMER.name, SYSTEM_CUSTOMER.wallet_balance || 0); + // Смена склада + initWarehouseChange(); - // Закрываем модалку - const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); - modal.hide(); -}); + // Категории на мобильных + initMobileCategories(); + + // Создание клиента + document.getElementById('confirmCreateCustomerBtn')?.addEventListener('click', createNewCustomer); + document.getElementById('createNewCustomerBtn')?.addEventListener('click', () => { + const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + modal?.hide(); + openCreateCustomerModal(); + }); + document.getElementById('selectSystemCustomerBtn')?.addEventListener('click', async () => { + await customerManager.resetToSystem(); + const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); + modal?.hide(); + }); + + // Сброс клиента + document.getElementById('resetCustomerBtn')?.addEventListener('click', () => customerManager.resetToSystem()); + document.getElementById('checkoutResetCustomerBtn')?.addEventListener('click', () => customerManager.resetToSystem()); + + // Выбор клиента + document.getElementById('customerSelectBtn')?.addEventListener('click', () => { + new bootstrap.Modal(document.getElementById('selectCustomerModal')).show(); + }); + document.getElementById('checkoutCustomerSelectBtn')?.addEventListener('click', () => { + new bootstrap.Modal(document.getElementById('selectCustomerModal')).show(); + }); +} -// Кнопка подтверждения создания клиента -document.getElementById('confirmCreateCustomerBtn').addEventListener('click', () => { - createNewCustomer(); -}); +function initSearch() { + const searchInput = document.getElementById('searchInput'); + const clearSearchBtn = document.getElementById('clearSearchBtn'); -// Инициализация Select2 при загрузке страницы -document.addEventListener('DOMContentLoaded', () => { - initCustomerSelect2(); - updateCustomerDisplay(); // Обновляем UI с системным клиентом + if (!searchInput) return; - // Восстанавливаем корзину из Redis (если есть сохраненные данные) - const savedCartData = JSON.parse(document.getElementById('cartData').textContent); - if (savedCartData && Object.keys(savedCartData).length > 0) { - // Конвертируем обычный объект обратно в Map - Object.entries(savedCartData).forEach(([key, value]) => { - cart.set(key, value); - }); - renderCart(); // Отрисовываем восстановленную корзину - } + searchInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); - // ===== ОБРАБОТЧИКИ ДЛЯ МОДАЛКИ ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ ===== + if (clearSearchBtn) { + clearSearchBtn.style.display = e.target.value.length > 0 ? 'block' : 'none'; + } - // Кнопки изменения количества - document.getElementById('unitQtyDecrement').addEventListener('click', () => { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + } + + if (query === '') { + currentSearchQuery = ''; + if (wasShowcaseViewBeforeSearch) { + isShowcaseView = true; + wasShowcaseViewBeforeSearch = false; + renderCategories(); + renderProducts(); + } else if (!isShowcaseView) { + productManager.loadItems().then(renderProducts); + } + return; + } + + if (query.length < CONFIG.SEARCH.MIN_LENGTH) return; + + if (isShowcaseView) { + wasShowcaseViewBeforeSearch = true; + isShowcaseView = false; + productManager.isShowcaseView = false; + renderCategories(); + } + + searchDebounceTimer = setTimeout(async () => { + currentSearchQuery = query; + await productManager.loadItems({ searchQuery: query }); + renderProducts(); + }, CONFIG.TIMEOUTS.DEBOUNCE_SEARCH); + }); + + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchInput.blur(); + } + }); + + clearSearchBtn?.addEventListener('click', () => { + searchInput.value = ''; + clearSearchBtn.style.display = 'none'; + currentSearchQuery = ''; + + if (wasShowcaseViewBeforeSearch) { + isShowcaseView = true; + productManager.isShowcaseView = true; + wasShowcaseViewBeforeSearch = false; + renderCategories(); + renderProducts(); + } else if (!isShowcaseView) { + productManager.loadItems().then(renderProducts); + } else { + renderProducts(); + } + }); +} + +function initMobileCart() { + const mobileCartSummary = document.getElementById('mobileCartSummary'); + const mobileCartClose = document.getElementById('mobileCartClose'); + const mobileCartOverlay = document.getElementById('mobileCartOverlay'); + const mobileCheckoutBtn = document.getElementById('mobileCheckoutBtn'); + const mobileClearCartBtn = document.getElementById('mobileClearCartBtn'); + + mobileCartSummary?.addEventListener('click', () => { + const overlay = document.getElementById('mobileCartOverlay'); + const body = document.getElementById('mobileCartBody'); + if (body && overlay) { + const cartList = document.getElementById('cartList'); + body.innerHTML = cartList ? cartList.innerHTML : '

    Корзина пуста

    '; + overlay.classList.add('active'); + } + }); + + mobileCartClose?.addEventListener('click', () => { + mobileCartOverlay?.classList.remove('active'); + }); + + mobileCartOverlay?.addEventListener('click', (e) => { + if (e.target.id === 'mobileCartOverlay') { + e.target.classList.remove('active'); + } + }); + + mobileCheckoutBtn?.addEventListener('click', () => { + document.getElementById('checkoutNow')?.click(); + }); + + mobileClearCartBtn?.addEventListener('click', () => { + document.getElementById('clearCart')?.click(); + }); +} + +function initUnitModal() { + document.getElementById('unitQtyDecrement')?.addEventListener('click', () => { const input = document.getElementById('unitModalQuantity'); const step = parseFloat(input.step) || 1; const newValue = Math.max(parseFloat(input.min), parseFloat(input.value) - step); - input.value = roundQuantity(newValue, 3); + input.value = roundQuantity(newValue); calculateUnitModalSubtotal(); validateUnitQuantity(); }); - document.getElementById('unitQtyIncrement').addEventListener('click', () => { + document.getElementById('unitQtyIncrement')?.addEventListener('click', () => { const input = document.getElementById('unitModalQuantity'); const step = parseFloat(input.step) || 1; const newValue = parseFloat(input.value) + step; - input.value = roundQuantity(newValue, 3); + input.value = roundQuantity(newValue); calculateUnitModalSubtotal(); validateUnitQuantity(); }); - // Изменение количества вручную - document.getElementById('unitModalQuantity').addEventListener('input', () => { + document.getElementById('unitModalQuantity')?.addEventListener('input', () => { calculateUnitModalSubtotal(); validateUnitQuantity(); }); - // Округление количества при потере фокуса - document.getElementById('unitModalQuantity').addEventListener('blur', (e) => { - const rawValue = parseFloat(e.target.value) || 0; - e.target.value = roundQuantity(rawValue, 3); + document.getElementById('unitModalQuantity')?.addEventListener('blur', (e) => { + e.target.value = roundQuantity(parseFloat(e.target.value) || 0); calculateUnitModalSubtotal(); validateUnitQuantity(); }); - // Изменение цены - document.getElementById('unitModalPrice').addEventListener('input', () => { - calculateUnitModalSubtotal(); + document.getElementById('unitModalPrice')?.addEventListener('input', calculateUnitModalSubtotal); + document.getElementById('confirmAddUnitToCart')?.addEventListener('click', addToCartFromModal); +} + +function initCheckout() { + document.getElementById('checkoutNow')?.addEventListener('click', () => { + checkoutManager.open(); + }); +} + +function initWarehouseChange() { + document.getElementById('changeWarehouseBtn')?.addEventListener('click', () => { + new bootstrap.Modal(document.getElementById('selectWarehouseModal')).show(); }); - // Кнопка подтверждения добавления в корзину - document.getElementById('confirmAddUnitToCart').addEventListener('click', () => { - addToCartFromModal(); + document.addEventListener('click', async (e) => { + const warehouseItem = e.target.closest('.warehouse-item'); + if (!warehouseItem) return; + + const warehouseId = warehouseItem.dataset.warehouseId; + const warehouseName = warehouseItem.dataset.warehouseName; + + if (cart.size > 0) { + const confirmed = confirm(`При смене склада корзина будет очищена.\n\nПереключиться на склад "${warehouseName}"?`); + if (!confirmed) return; + } + + try { + const response = await safeFetch( + CONFIG.API.SET_WAREHOUSE(warehouseId), + { + method: 'POST', + headers: { 'X-CSRFToken': getCsrfToken() } + } + ); + + const data = await response.json(); + + if (data.success) { + location.reload(); + } else { + showToast('error', `Ошибка: ${data.error}`); + } + } catch (error) { + showToast('error', 'Произошла ошибка при смене склада'); + } }); +} - // ===== МОБИЛЬНАЯ КОРЗИНА ===== - - // Тап на бар — открываем корзину - const mobileCartSummary = document.getElementById('mobileCartSummary'); - if (mobileCartSummary) { - mobileCartSummary.addEventListener('click', () => { - const overlay = document.getElementById('mobileCartOverlay'); - const body = document.getElementById('mobileCartBody'); - - // Копируем содержимое корзины - if (body && overlay) { - const cartList = document.getElementById('cartList'); - body.innerHTML = cartList ? cartList.innerHTML : '

    Корзина пуста

    '; - overlay.classList.add('active'); - } - }); - } - - // Кнопка закрытия мобильной корзины - const mobileCartClose = document.getElementById('mobileCartClose'); - if (mobileCartClose) { - mobileCartClose.addEventListener('click', () => { - const overlay = document.getElementById('mobileCartOverlay'); - if (overlay) { - overlay.classList.remove('active'); - } - }); - } - - // Закрытие по клику на фон - const mobileCartOverlay = document.getElementById('mobileCartOverlay'); - if (mobileCartOverlay) { - mobileCartOverlay.addEventListener('click', (e) => { - if (e.target.id === 'mobileCartOverlay') { - e.target.classList.remove('active'); - } - }); - } - - // Мобильная кнопка "Продать" - const mobileCheckoutBtn = document.getElementById('mobileCheckoutBtn'); - if (mobileCheckoutBtn) { - mobileCheckoutBtn.addEventListener('click', () => { - const checkoutBtn = document.getElementById('checkoutNow'); - if (checkoutBtn) { - checkoutBtn.click(); - } - }); - } - - // Мобильная кнопка "Очистить" - const mobileClearCartBtn = document.getElementById('mobileClearCartBtn'); - if (mobileClearCartBtn) { - mobileClearCartBtn.addEventListener('click', () => { - const clearBtn = document.getElementById('clearCart'); - if (clearBtn) { - clearBtn.click(); - } - }); - } - - // ===== СВОРАЧИВАНИЕ КАТЕГОРИЙ НА МОБИЛЬНЫХ ===== - +function initMobileCategories() { const categoriesToggle = document.getElementById('categoriesToggle'); const categoriesContent = document.getElementById('categoriesContent'); @@ -3760,269 +1218,19 @@ document.addEventListener('DOMContentLoaded', () => { categoriesContent.classList.toggle('collapsed'); }); - // Автоматически сворачиваем категории на мобильных при загрузке if (window.innerWidth <= 767) { categoriesToggle.classList.add('collapsed'); categoriesContent.classList.add('collapsed'); } } - - // ===== МОБИЛЬНЫЙ DROPDOWN "ЕЩЁ" ===== - - // Мобильная кнопка "Отложенный заказ" - const mobileScheduleLaterBtn = document.getElementById('mobileScheduleLaterBtn'); - if (mobileScheduleLaterBtn) { - mobileScheduleLaterBtn.addEventListener('click', () => { - const scheduleBtn = document.getElementById('scheduleLater'); - if (scheduleBtn) { - scheduleBtn.click(); - } - }); - } - - // Мобильная кнопка "На витрину" - const mobileAddToShowcaseBtn = document.getElementById('mobileAddToShowcaseBtn'); - if (mobileAddToShowcaseBtn) { - mobileAddToShowcaseBtn.addEventListener('click', () => { - const showcaseBtn = document.getElementById('addToShowcaseBtn'); - if (showcaseBtn) { - showcaseBtn.click(); - } - }); - } -}); - -// Смена склада -const changeWarehouseBtn = document.getElementById('changeWarehouseBtn'); -if (changeWarehouseBtn) { - changeWarehouseBtn.addEventListener('click', () => { - const modal = new bootstrap.Modal(document.getElementById('selectWarehouseModal')); - modal.show(); - }); } -// Обработка выбора склада из списка -document.addEventListener('click', async (e) => { - const warehouseItem = e.target.closest('.warehouse-item'); - if (!warehouseItem) return; +// ===== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===== - const warehouseId = warehouseItem.dataset.warehouseId; - const warehouseName = warehouseItem.dataset.warehouseName; - - // Проверяем, есть ли товары в корзине - if (cart.size > 0) { - const confirmed = confirm(`При смене склада корзина будет очищена.\n\nПереключиться на склад "${warehouseName}"?`); - if (!confirmed) return; - } - - try { - // Отправляем запрос на смену склада - const response = await fetch(`/pos/api/set-warehouse/${warehouseId}/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken() - } - }); - - const data = await response.json(); - - if (data.success) { - // Перезагружаем страницу для обновления данных - location.reload(); - } else { - alert(`Ошибка: ${data.error}`); - } - } catch (error) { - console.error('Ошибка при смене склада:', error); - alert('Произошла ошибка при смене склада'); - } -}); - - - -// Обработчик поиска с debounce -const searchInput = document.getElementById('searchInput'); -const clearSearchBtn = document.getElementById('clearSearchBtn'); - -searchInput.addEventListener('input', (e) => { - const query = e.target.value.trim(); - - // Показываем/скрываем кнопку очистки - if (e.target.value.length > 0) { - clearSearchBtn.style.display = 'block'; - } else { - clearSearchBtn.style.display = 'none'; - } - - // Отменяем предыдущий таймер - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer); - } - - // Если поле пустое — очищаем поиск и возвращаемся к выбранной категории - if (query === '') { - currentSearchQuery = ''; - updateSearchIndicator(); // Обновляем индикацию - // Если поиск начинался с витрины - возвращаемся к витрине - if (wasShowcaseViewBeforeSearch) { - isShowcaseView = true; - wasShowcaseViewBeforeSearch = false; - renderCategories(); - renderProducts(); - } else if (!isShowcaseView) { - // Возвращаем товары выбранной категории - loadItems(); - } - return; - } - - // Минимальная длина поиска — 3 символа - if (query.length < 3) { - // Не реагируем на ввод менее 3 символов - return; - } - - // Для витрины при 3+ символах - сохраняем состояние и переключаемся на глобальный поиск - if (isShowcaseView) { - // Сохраняем, что была активна витрина перед поиском - wasShowcaseViewBeforeSearch = true; - // Выходим из режима витрины для глобального поиска - isShowcaseView = false; - // Обновляем UI категорий (снимаем выделение с витрины) - renderCategories(); - } - - // Для обычных товаров/комплектов — серверный поиск с debounce 300мс - searchDebounceTimer = setTimeout(async () => { - currentSearchQuery = query; - updateSearchIndicator(); // Обновляем визуальную индикацию - await loadItems(); // Перезагрузка с серверным поиском (по всем категориям) - }, 300); -}); - -// При нажатии Enter на searchInput - скрываем виртуальную клавиатуру -searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - searchInput.blur(); // Скрывает виртуальную клавиатуру на мобильных - } -}); - -// Обработчик кнопки очистки поиска -clearSearchBtn.addEventListener('click', () => { - searchInput.value = ''; - clearSearchBtn.style.display = 'none'; - currentSearchQuery = ''; - updateSearchIndicator(); // Обновляем индикацию - // Если поиск начинался с витрины - возвращаемся к витрине - if (wasShowcaseViewBeforeSearch) { - isShowcaseView = true; - wasShowcaseViewBeforeSearch = false; - renderCategories(); - renderProducts(); - } else if (!isShowcaseView) { - // Возвращаем товары выбранной категории - loadItems(); - } else { - renderProducts(); // Для витрины - просто перерисовываем - } -}); - -// Инициализация -renderCategories(); -renderProducts(); // Сначала пустая сетка -renderCart(); -setupInfiniteScroll(); // Установка infinite scroll - -// Установить фокус на строку поиска только на десктопе -if (!isMobileDevice()) { - document.getElementById('searchInput').focus(); +function setupInfiniteScroll() { + productManager.setupInfiniteScroll(renderProducts); } -// ===== ОТЛОЖЕННЫЙ ЗАКАЗ ===== - -/** - * Создаёт отложенный заказ (черновик) и резервирует витринные букеты - * - * FLOW: - * 1. Создаём Order (статус 'draft') через API - * 2. ShowcaseItem резервируются в той же транзакции (in_cart → reserved) - * 3. Очищаем корзину POS - * 4. Открываем форму редактирования заказа - */ -async function createDeferredOrder() { - // Проверяем, что корзина не пуста - if (cart.size === 0) { - alert('Корзина пуста! Добавьте товары в корзину.'); - return; - } - - try { - // Собираем данные для черновика - const items = Array.from(cart.values()).map(item => { - const itemData = { - type: item.type, - id: item.id, - quantity: item.qty, - price: item.price - }; - // Для товаров с единицами продажи передаём sales_unit_id - if (item.sales_unit_id) { - itemData.sales_unit_id = item.sales_unit_id; - } - // Для витринных букетов передаём showcase_item_ids - if (item.type === 'showcase_kit' && item.showcase_item_ids) { - itemData.showcase_item_ids = item.showcase_item_ids; - } - return itemData; - }); - - const customer = selectedCustomer || SYSTEM_CUSTOMER; - - const orderData = { - customer_id: customer.id, - items: items - }; - - // Создаём заказ через новый endpoint - const response = await fetch('/orders/api/create-from-pos/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - }, - body: JSON.stringify(orderData) - }); - - const result = await response.json(); - - if (result.success) { - console.log(`✅ Заказ #${result.order_number} создан (черновик). ShowcaseItem зарезервированы.`); - - // КРИТИЧНО: Очищаем корзину POS (включая витринные ��укеты) - cart.clear(); - renderCart(); - saveCartToRedis(); // Сохраняем пустую корзину в Redis - - // Перезагружаем витрину (чтобы зарезервированные букеты исчезли) - if (isShowcaseView) { - await refreshShowcaseKits(); - renderProducts(); - } - - // Открываем форму редактирования в новой вкладке - window.open(`/orders/${result.order_number}/edit/`, '_blank'); - } else { - alert(`Ошибка: ${result.error}`); - } - } catch (error) { - console.error('Ошибка при создании отложенного заказа:', error); - alert('Произошла ошибка при создании черновика заказа'); - } -} - -// Обработчик кнопки "ОТЛОЖЕННЫЙ заказ" -const scheduleLaterBtn = document.getElementById('scheduleLater'); -if (scheduleLaterBtn) { - scheduleLaterBtn.addEventListener('click', createDeferredOrder); -} +// Экспортируем необходимые функции для глобального доступа +window.formatMoney = formatMoney; +window.roundQuantity = roundQuantity; diff --git a/myproject/pos/static/pos/js/utils.js b/myproject/pos/static/pos/js/utils.js new file mode 100644 index 0000000..96112d2 --- /dev/null +++ b/myproject/pos/static/pos/js/utils.js @@ -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': '', + 'max_only': '', + 'exclusive': '' + }; + 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} Ответ от сервера + */ +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; + } +} diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 279bd07..6f34947 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -769,6 +769,6 @@ - + {% endblock %}