FEATURE: Новый построчный UI для смешанной оплаты в POS

Полностью переработан интерфейс смешанной оплаты:

НОВЫЙ 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-03 23:30:12 +03:00
parent 8e6394fb71
commit 0bbc0f6633

View File

@@ -44,6 +44,8 @@ export class PaymentWidget {
return;
}
if (this.mode === 'single') {
// SINGLE MODE - старый UI (не трогаем)
container.innerHTML = `
<!-- Способы оплаты -->
<div class="mb-2">
@@ -53,8 +55,6 @@ export class PaymentWidget {
</div>
</div>
${this.mode === 'mixed' ? this.renderPaymentsList() : ''}
<!-- Сумма -->
<div class="mb-2">
<label class="form-label fw-semibold small mb-1">Сумма</label>
@@ -70,18 +70,26 @@ export class PaymentWidget {
</div>
<small class="text-muted" id="${this.containerId}-hint"></small>
</div>
<!-- Кнопка добавить (только для mixed mode) -->
${this.mode === 'mixed' ? `
<div class="d-flex gap-2">
<button type="button"
class="btn btn-sm btn-outline-primary"
id="${this.containerId}-add-btn">
<i class="bi bi-plus-circle"></i> Добавить платеж
</button>
</div>
` : ''}
`;
} else {
// MIXED MODE - новый UI с построчным добавлением
container.innerHTML = `
<div id="${this.containerId}-payment-rows"></div>
<div class="mb-2 text-center">
<small class="text-muted" id="${this.containerId}-remaining-hint">
Осталось оплатить: ${this.order.amount_due.toFixed(2)} руб.
</small>
</div>
<button type="button"
class="btn btn-sm btn-outline-primary w-100"
id="${this.containerId}-add-row-btn">
<i class="bi bi-plus-circle"></i> Добавить еще часть платежа
</button>
`;
// Добавляем первую строку автоматически
this.addPaymentRow();
}
}
renderPaymentMethods() {
@@ -105,35 +113,182 @@ export class PaymentWidget {
}).join('');
}
renderPaymentsList() {
// 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();
}
renderPaymentRows() {
const container = document.getElementById(`${this.containerId}-payment-rows`);
if (!container) return;
container.innerHTML = this.payments.map((payment, index) => {
if (payment.fixed) {
// Зафиксированная строка
return `
<div class="mb-2">
<label class="form-label fw-semibold small mb-1">Добавленные платежи</label>
<div id="${this.containerId}-payments-list"
class="border rounded p-2 bg-light"
style="min-height: 50px; max-height: 150px; overflow-y: auto; font-size: 0.9rem;">
<small class="text-muted">Платежи не добавлены</small>
<div class="payment-row mb-2 p-2 border rounded bg-light" data-index="${index}">
<div class="d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-check-circle-fill text-success"></i>
<strong>${payment.method_name}:</strong>
<span class="text-success">${payment.amount.toFixed(2)} руб.</span>
</span>
<button type="button"
class="btn btn-sm btn-outline-danger remove-payment-row"
data-index="${index}">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`;
} else {
// Редактируемая строка
const methodsOptions = this.paymentMethods.map(m =>
`<option value="${m.id}"
data-code="${m.code}"
data-name="${m.name}"
${payment.method_id == m.id ? 'selected' : ''}>
${m.name}
${m.code === 'account_balance' && this.customer ?
` (${this.customer.wallet_balance.toFixed(2)} руб.)` : ''}
</option>`
).join('');
return `
<div class="payment-row mb-2 p-2 border rounded" data-index="${index}">
<div class="row g-2 align-items-center">
<div class="col-5">
<select class="form-select form-select-sm payment-method-select"
data-index="${index}">
<option value="">Выберите способ</option>
${methodsOptions}
</select>
</div>
<div class="col-4">
<div class="input-group input-group-sm">
<input type="number"
class="form-control payment-amount-input"
data-index="${index}"
value="${payment.amount.toFixed(2)}"
step="0.01"
min="0.01">
<span class="input-group-text">руб.</span>
</div>
<small class="text-danger payment-error"
data-index="${index}"
style="display: none;"></small>
</div>
<div class="col-3 text-end">
<button type="button"
class="btn btn-sm btn-success confirm-payment-row me-1"
data-index="${index}">
<i class="bi bi-check-lg"></i>
</button>
<button type="button"
class="btn btn-sm btn-outline-danger remove-payment-row"
data-index="${index}">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`;
}
}).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;
}
}
});
});
// Обработчик изменения суммы
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)
if (this.mode === 'mixed') {
const addBtn = document.getElementById(`${this.containerId}-add-btn`);
if (addBtn) {
addBtn.addEventListener('click', () => this.addPayment());
}
}
// Отправка
// Кнопка "Оплатить" удалена - используется только основная кнопка "Подтвердить продажу" в модалке
// Для 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
});
// Обновляем 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)} руб.` : 'Оплачено полностью';
// Проверка кошелька
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;
}
}
removePayment(index) {
// Все ОК - фиксируем
errorEl.style.display = 'none';
payment.fixed = true;
this.renderPaymentRows();
}
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)} руб.` : '';
}
updatePaymentsList() {
const listContainer = document.getElementById(`${this.containerId}-payments-list`);
if (!listContainer) return;
// Если удалили все строки - добавить одну пустую
if (this.payments.length === 0) {
listContainer.innerHTML = '<small class="text-muted">Платежи не добавлены</small>';
return;
this.addPaymentRow();
} else {
this.renderPaymentRows();
}
}
listContainer.innerHTML = this.payments.map((p, index) => `
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 ${index < this.payments.length - 1 ? 'border-bottom' : ''}">
<div style="font-size: 0.9rem;">
<strong>${p.method_name}</strong>
<span class="text-success ms-2">${p.amount.toFixed(2)} руб.</span>
</div>
<button type="button"
class="btn btn-sm btn-outline-danger remove-payment-btn"
data-index="${index}"
style="font-size: 0.75rem; padding: 0.1rem 0.3rem;">
<i class="bi bi-trash"></i>
</button>
</div>
`).join('');
updateRemainingHint() {
const hintEl = document.getElementById(`${this.containerId}-remaining-hint`);
const addRowBtn = document.getElementById(`${this.containerId}-add-row-btn`);
if (!hintEl) return;
// Прикрепляем обработчики удаления
listContainer.querySelectorAll('.remove-payment-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.index);
this.removePayment(index);
});
});
const totalPaid = this.getTotalPayments();
const remaining = this.order.amount_due - totalPaid;
if (remaining <= 0) {
hintEl.innerHTML = '<strong class="text-success">✅ Оплачено полностью: ' +
this.order.amount_due.toFixed(2) + ' руб.</strong>';
// СКРЫТЬ кнопку добавления
if (addRowBtn) addRowBtn.style.display = 'none';
} else {
hintEl.textContent = 'Осталось оплатить: ' + remaining.toFixed(2) + ' руб.';
// ПОКАЗАТЬ кнопку добавления
if (addRowBtn) addRowBtn.style.display = 'block';
}
}
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,11 +478,13 @@ export class PaymentWidget {
notes: ''
}];
} else {
// Смешанная оплата
paymentsData = this.payments.map(p => ({
// Смешанная оплата - берем только зафиксированные
paymentsData = this.payments
.filter(p => p.fixed)
.map(p => ({
payment_method: p.method_code,
amount: p.amount,
notes: p.notes || ''
notes: ''
}));
}