From 0bbc0f6633019eba885a8a6ac1b0253fd80df95f Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 3 Dec 2025 23:30:12 +0300 Subject: [PATCH] =?UTF-8?q?FEATURE:=20=D0=9D=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?UI=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BC=D0=B5=D1=88=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B2=20POS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полностью переработан интерфейс смешанной оплаты: НОВЫЙ UI: - Построчное добавление платежей с галочкой подтверждения - Каждая строка: [Способ ▼] [Сумма] [✓] [🗑️] - Зафиксированные платежи показываются отдельно с зеленой галочкой - Кнопка "+ Добавить еще часть платежа" (скрывается при остатке = 0) - Индикатор "Осталось оплатить" / "Оплачено полностью" ФУНКЦИОНАЛЬНОСТЬ: ✅ Автоподстановка остаточной суммы при выборе способа оплаты ✅ Автофокус на поле суммы после выбора способа ✅ Inline валидация с красными подсказками под полем ✅ Проверка превышения остатка и баланса кошелька ✅ Удаление любого платежа (зафиксированного или нет) ✅ Автодобавление пустой строки при удалении всех платежей ИЗМЕНЕНИЯ В КОДЕ: + Новые методы: addPaymentRow(), renderPaymentRows(), attachPaymentRowEvents() + Новые методы: confirmPaymentRow(), removePaymentRow(), updateRemainingHint() ~ Обновлены: render(), getTotalPayments(), validate(), submit(), attachEvents() ~ getTotalPayments() теперь учитывает только зафиксированные платежи (fixed: true) - Удалены старые методы: addPayment(), removePayment(), updatePaymentsList(), renderPaymentsList() SINGLE MODE остался без изменений. Проблема решена: больше невозможно "забыть" добавить последний платеж, так как каждый платеж фиксируется галочкой явно. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../orders/static/orders/js/payment_widget.js | 423 ++++++++++++------ 1 file changed, 284 insertions(+), 139 deletions(-) diff --git a/myproject/orders/static/orders/js/payment_widget.js b/myproject/orders/static/orders/js/payment_widget.js index f25ac12..fb0af07 100644 --- a/myproject/orders/static/orders/js/payment_widget.js +++ b/myproject/orders/static/orders/js/payment_widget.js @@ -44,44 +44,52 @@ export class PaymentWidget { return; } - container.innerHTML = ` - -
- -
- ${this.renderPaymentMethods()} + if (this.mode === 'single') { + // SINGLE MODE - старый UI (не трогаем) + container.innerHTML = ` + +
+ +
+ ${this.renderPaymentMethods()} +
-
- ${this.mode === 'mixed' ? this.renderPaymentsList() : ''} - - -
- -
- - руб. + +
+ +
+ + руб. +
+ +
+ `; + } else { + // MIXED MODE - новый UI с построчным добавлением + container.innerHTML = ` +
+
+ + Осталось оплатить: ${this.order.amount_due.toFixed(2)} руб. +
- -
- - - ${this.mode === 'mixed' ? ` -
-
- ` : ''} - `; + `; + + // Добавляем первую строку автоматически + this.addPaymentRow(); + } } renderPaymentMethods() { @@ -105,35 +113,182 @@ export class PaymentWidget { }).join(''); } - renderPaymentsList() { - return ` -
- -
- Платежи не добавлены -
-
- `; + // renderPaymentsList() УДАЛЕН - больше не используется в новом UI + + // ========== НОВЫЕ МЕТОДЫ ДЛЯ MIXED MODE ========== + + addPaymentRow() { + const rowId = Date.now(); // уникальный ID для строки + const remaining = this.order.amount_due - this.getTotalPayments(); + + const row = { + id: rowId, + method_id: null, + method_code: null, + method_name: null, + amount: remaining, + fixed: false + }; + + this.payments.push(row); + this.renderPaymentRows(); } - attachEvents() { - // Выбор способа оплаты - document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(btn => { - btn.addEventListener('click', () => this.selectPaymentMethod(btn)); + renderPaymentRows() { + const container = document.getElementById(`${this.containerId}-payment-rows`); + if (!container) return; + + container.innerHTML = this.payments.map((payment, index) => { + if (payment.fixed) { + // Зафиксированная строка + return ` +
+
+ + + ${payment.method_name}: + ${payment.amount.toFixed(2)} руб. + + +
+
+ `; + } else { + // Редактируемая строка + const methodsOptions = this.paymentMethods.map(m => + `` + ).join(''); + + return ` +
+
+
+ +
+
+
+ + руб. +
+ +
+
+ + +
+
+
+ `; + } + }).join(''); + + // Обновляем подсказку об остатке + this.updateRemainingHint(); + + // Прикрепляем обработчики событий + this.attachPaymentRowEvents(); + } + + attachPaymentRowEvents() { + // Обработчик выбора способа оплаты + document.querySelectorAll('.payment-method-select').forEach(select => { + select.addEventListener('change', (e) => { + const index = parseInt(e.target.dataset.index); + const option = e.target.selectedOptions[0]; + + if (option.value) { + this.payments[index].method_id = option.value; + this.payments[index].method_code = option.dataset.code; + this.payments[index].method_name = option.dataset.name; + + // Автофокус на поле суммы + const amountInput = document.querySelector(`.payment-amount-input[data-index="${index}"]`); + if (amountInput) amountInput.focus(); + + // Если кошелек - ограничить сумму + if (option.dataset.code === 'account_balance' && this.customer) { + const remaining = this.order.amount_due - this.getTotalPayments(); + const maxAmount = Math.min(remaining, this.customer.wallet_balance); + amountInput.value = maxAmount.toFixed(2); + amountInput.max = maxAmount; + } + } + }); }); - // Добавить платеж (mixed mode) - if (this.mode === 'mixed') { - const addBtn = document.getElementById(`${this.containerId}-add-btn`); - if (addBtn) { - addBtn.addEventListener('click', () => this.addPayment()); - } + // Обработчик изменения суммы + document.querySelectorAll('.payment-amount-input').forEach(input => { + input.addEventListener('input', (e) => { + const index = parseInt(e.target.dataset.index); + this.payments[index].amount = parseFloat(e.target.value) || 0; + }); + }); + + // Обработчик подтверждения (галочка) + document.querySelectorAll('.confirm-payment-row').forEach(btn => { + btn.addEventListener('click', (e) => { + const index = parseInt(e.target.closest('button').dataset.index); + this.confirmPaymentRow(index); + }); + }); + + // Обработчик удаления (корзина) + document.querySelectorAll('.remove-payment-row').forEach(btn => { + btn.addEventListener('click', (e) => { + const index = parseInt(e.target.closest('button').dataset.index); + this.removePaymentRow(index); + }); + }); + + // Обработчик кнопки "Добавить еще" + const addRowBtn = document.getElementById(`${this.containerId}-add-row-btn`); + if (addRowBtn) { + addRowBtn.addEventListener('click', () => this.addPaymentRow()); + } + } + + // ========== КОНЕЦ НОВЫХ МЕТОДОВ ========== + + attachEvents() { + // Выбор способа оплаты (только для single mode) + if (this.mode === 'single') { + document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(btn => { + btn.addEventListener('click', () => this.selectPaymentMethod(btn)); + }); } - // Отправка - // Кнопка "Оплатить" удалена - используется только основная кнопка "Подтвердить продажу" в модалке + // Для mixed mode обработчики событий прикрепляются в attachPaymentRowEvents() + // который вызывается из renderPaymentRows() } selectPaymentMethod(btnElement) { @@ -170,102 +325,88 @@ export class PaymentWidget { } } - addPayment() { - if (!this.selectedMethod) { - alert('Выберите способ оплаты'); + // СТАРЫЕ МЕТОДЫ addPayment(), removePayment() и updatePaymentsList() УДАЛЕНЫ + // Теперь используются новые методы для построчного UI + + confirmPaymentRow(index) { + const payment = this.payments[index]; + const errorEl = document.querySelector(`.payment-error[data-index="${index}"]`); + + // Валидация + if (!payment.method_id) { + errorEl.textContent = 'Выберите способ оплаты'; + errorEl.style.display = 'block'; return; } - const amountInput = document.getElementById(`${this.containerId}-amount`); - - const amount = parseFloat(amountInput.value); - - if (amount <= 0 || isNaN(amount)) { - alert('Введите сумму больше 0'); + if (!payment.amount || payment.amount <= 0) { + errorEl.textContent = 'Введите сумму больше 0'; + errorEl.style.display = 'block'; return; } - // Проверка не превышает ли общая сумма amount_due - const currentTotal = this.getTotalPayments(); - if (currentTotal + amount > this.order.amount_due) { - alert(`Общая сумма платежей (${(currentTotal + amount).toFixed(2)}) превышает сумму к оплате (${this.order.amount_due.toFixed(2)})`); + // Проверка превышения остатка + const totalOtherPayments = this.payments + .filter((p, i) => i !== index && p.fixed) + .reduce((sum, p) => sum + p.amount, 0); + + if (totalOtherPayments + payment.amount > this.order.amount_due) { + const maxAmount = this.order.amount_due - totalOtherPayments; + errorEl.textContent = `Максимум: ${maxAmount.toFixed(2)} руб.`; + errorEl.style.display = 'block'; return; } - // Добавляем в список - this.payments.push({ - method_id: this.selectedMethod.id, - method_code: this.selectedMethod.code, - method_name: this.selectedMethod.name, - amount: amount, - notes: '' // Примечания не используются в POS - }); + // Проверка кошелька + if (payment.method_code === 'account_balance' && this.customer) { + if (payment.amount > this.customer.wallet_balance) { + errorEl.textContent = `Недостаточно средств (${this.customer.wallet_balance.toFixed(2)} руб.)`; + errorEl.style.display = 'block'; + return; + } + } - // Обновляем UI - this.updatePaymentsList(); - - // Сбрасываем форму - const remaining = this.order.amount_due - this.getTotalPayments(); - amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00'; - - // Снимаем выделение способа - document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => { - b.classList.remove('active'); - }); - this.selectedMethod = null; - - const hint = document.getElementById(`${this.containerId}-hint`); - hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : 'Оплачено полностью'; + // Все ОК - фиксируем + errorEl.style.display = 'none'; + payment.fixed = true; + this.renderPaymentRows(); } - removePayment(index) { + removePaymentRow(index) { this.payments.splice(index, 1); - this.updatePaymentsList(); - // Обновляем подсказку - const amountInput = document.getElementById(`${this.containerId}-amount`); - const remaining = this.order.amount_due - this.getTotalPayments(); - amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00'; - - const hint = document.getElementById(`${this.containerId}-hint`); - hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : ''; + // Если удалили все строки - добавить одну пустую + if (this.payments.length === 0) { + this.addPaymentRow(); + } else { + this.renderPaymentRows(); + } } - updatePaymentsList() { - const listContainer = document.getElementById(`${this.containerId}-payments-list`); - if (!listContainer) return; + updateRemainingHint() { + const hintEl = document.getElementById(`${this.containerId}-remaining-hint`); + const addRowBtn = document.getElementById(`${this.containerId}-add-row-btn`); + if (!hintEl) return; - if (this.payments.length === 0) { - listContainer.innerHTML = 'Платежи не добавлены'; - return; + const totalPaid = this.getTotalPayments(); + const remaining = this.order.amount_due - totalPaid; + + if (remaining <= 0) { + hintEl.innerHTML = '✅ Оплачено полностью: ' + + this.order.amount_due.toFixed(2) + ' руб.'; + // СКРЫТЬ кнопку добавления + if (addRowBtn) addRowBtn.style.display = 'none'; + } else { + hintEl.textContent = 'Осталось оплатить: ' + remaining.toFixed(2) + ' руб.'; + // ПОКАЗАТЬ кнопку добавления + if (addRowBtn) addRowBtn.style.display = 'block'; } - - listContainer.innerHTML = this.payments.map((p, index) => ` -
-
- ${p.method_name} - ${p.amount.toFixed(2)} руб. -
- -
- `).join(''); - - // Прикрепляем обработчики удаления - listContainer.querySelectorAll('.remove-payment-btn').forEach(btn => { - btn.addEventListener('click', () => { - const index = parseInt(btn.dataset.index); - this.removePayment(index); - }); - }); } getTotalPayments() { - return this.payments.reduce((sum, p) => sum + p.amount, 0); + return this.payments + .filter(p => p.fixed) // Считаем только зафиксированные! + .reduce((sum, p) => sum + p.amount, 0); } validate() { @@ -295,7 +436,9 @@ export class PaymentWidget { } else { // Смешанная оплата - if (this.payments.length === 0) { + const fixedPayments = this.payments.filter(p => p.fixed); + + if (fixedPayments.length === 0) { throw new Error('Добавьте хотя бы один платеж'); } @@ -307,7 +450,7 @@ export class PaymentWidget { // Проверка кошелька if (this.customer) { - const walletPayments = this.payments.filter(p => p.method_code === 'account_balance'); + const walletPayments = fixedPayments.filter(p => p.method_code === 'account_balance'); const walletTotal = walletPayments.reduce((sum, p) => sum + p.amount, 0); if (walletTotal > this.customer.wallet_balance) { @@ -335,12 +478,14 @@ export class PaymentWidget { notes: '' }]; } else { - // Смешанная оплата - paymentsData = this.payments.map(p => ({ - payment_method: p.method_code, - amount: p.amount, - notes: p.notes || '' - })); + // Смешанная оплата - берем только зафиксированные + paymentsData = this.payments + .filter(p => p.fixed) + .map(p => ({ + payment_method: p.method_code, + amount: p.amount, + notes: '' + })); } this.onSubmit(paymentsData);