1. Убран параметр версии ?v=4 из подключения terminal.js (для разработки не нужен) 2. Исправлена логика автоподстановки суммы в PaymentWidget: - Теперь при выборе способа оплаты подставляется ОСТАТОЧНАЯ сумма - Остаток = amount_due - уже добавленные платежи - Добавлена подсказка "Осталось оплатить: X руб." Пример: - Заказ на 30 руб. - Добавили платеж 10 руб. наличными - Выбираем картой → автоматически подставится 20 руб. (а не 30!) Это предотвращает ошибки и переплаты при смешанной оплате. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
366 lines
14 KiB
JavaScript
366 lines
14 KiB
JavaScript
/**
|
||
* PaymentWidget - переиспользуемый виджет для оплаты заказа.
|
||
* Поддерживает одиночную и смешанную оплату.
|
||
*
|
||
* @example
|
||
* const widget = new PaymentWidget({
|
||
* containerId: 'paymentContainer',
|
||
* mode: 'single', // или 'mixed'
|
||
* order: { id: 1, total: 5000, amount_due: 5000 },
|
||
* customer: { id: 1, wallet_balance: 1000 },
|
||
* paymentMethods: [
|
||
* { id: 1, code: 'account_balance', name: 'С баланса счёта' },
|
||
* { id: 2, code: 'cash', name: 'Наличными' },
|
||
* ...
|
||
* ],
|
||
* onSubmit: (payments) => { console.log(payments); }
|
||
* });
|
||
*/
|
||
export class PaymentWidget {
|
||
constructor(options = {}) {
|
||
this.containerId = options.containerId;
|
||
this.mode = options.mode || 'single'; // 'single' | 'mixed'
|
||
this.order = options.order;
|
||
this.customer = options.customer;
|
||
this.paymentMethods = options.paymentMethods || [];
|
||
this.onSubmit = options.onSubmit || (() => {});
|
||
|
||
// Внутреннее состояние
|
||
this.payments = []; // [{ method_id, method_code, method_name, amount, notes }]
|
||
this.selectedMethod = null; // Для single mode
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.render();
|
||
this.attachEvents();
|
||
}
|
||
|
||
render() {
|
||
const container = document.getElementById(this.containerId);
|
||
if (!container) {
|
||
console.error(`Container #${this.containerId} not found`);
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<!-- Способы оплаты -->
|
||
<div class="mb-2">
|
||
<label class="form-label fw-semibold small mb-1">Способ оплаты</label>
|
||
<div class="d-flex flex-wrap gap-1" id="${this.containerId}-methods">
|
||
${this.renderPaymentMethods()}
|
||
</div>
|
||
</div>
|
||
|
||
${this.mode === 'mixed' ? this.renderPaymentsList() : ''}
|
||
|
||
<!-- Сумма -->
|
||
<div class="mb-2">
|
||
<label class="form-label fw-semibold small mb-1">Сумма</label>
|
||
<div class="input-group input-group-sm">
|
||
<input type="number"
|
||
class="form-control form-control-sm"
|
||
id="${this.containerId}-amount"
|
||
step="0.01"
|
||
min="0.01"
|
||
value="${this.order.amount_due}"
|
||
placeholder="0.00">
|
||
<span class="input-group-text">руб.</span>
|
||
</div>
|
||
<small class="text-muted" id="${this.containerId}-hint"></small>
|
||
</div>
|
||
|
||
<!-- Примечание -->
|
||
<div class="mb-2">
|
||
<label class="form-label small mb-1">Примечание</label>
|
||
<input type="text"
|
||
class="form-control form-control-sm"
|
||
id="${this.containerId}-notes"
|
||
placeholder="Опционально">
|
||
</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>
|
||
` : ''}
|
||
`;
|
||
}
|
||
|
||
renderPaymentMethods() {
|
||
return this.paymentMethods.map(method => {
|
||
const showWallet = method.code === 'account_balance' && this.customer;
|
||
return `
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-primary payment-method-btn"
|
||
data-method-id="${method.id}"
|
||
data-method-code="${method.code}"
|
||
data-method-name="${method.name}"
|
||
style="font-size: 0.85rem;">
|
||
${method.name}
|
||
${showWallet ? `
|
||
<span class="badge bg-info ms-1" style="font-size: 0.75rem;">
|
||
${this.customer.wallet_balance.toFixed(2)}
|
||
</span>
|
||
` : ''}
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
renderPaymentsList() {
|
||
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>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
attachEvents() {
|
||
// Выбор способа оплаты
|
||
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());
|
||
}
|
||
}
|
||
|
||
// Отправка
|
||
// Кнопка "Оплатить" удалена - используется только основная кнопка "Подтвердить продажу" в модалке
|
||
}
|
||
|
||
selectPaymentMethod(btnElement) {
|
||
// Снимаем выделение
|
||
document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => {
|
||
b.classList.remove('active');
|
||
});
|
||
|
||
// Выделяем выбранный
|
||
btnElement.classList.add('active');
|
||
|
||
this.selectedMethod = {
|
||
id: btnElement.dataset.methodId,
|
||
code: btnElement.dataset.methodCode,
|
||
name: btnElement.dataset.methodName
|
||
};
|
||
|
||
// Валидация для кошелька
|
||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||
const hint = document.getElementById(`${this.containerId}-hint`);
|
||
|
||
// Вычисляем остаток с учетом уже добавленных платежей
|
||
const remaining = this.order.amount_due - this.getTotalPayments();
|
||
|
||
if (this.selectedMethod.code === 'account_balance' && this.customer) {
|
||
const maxAmount = Math.min(remaining, this.customer.wallet_balance);
|
||
amountInput.max = maxAmount;
|
||
amountInput.value = maxAmount.toFixed(2);
|
||
hint.textContent = `Макс: ${maxAmount.toFixed(2)} руб. | Осталось: ${remaining.toFixed(2)} руб.`;
|
||
} else {
|
||
amountInput.removeAttribute('max');
|
||
amountInput.value = remaining.toFixed(2);
|
||
hint.textContent = `Осталось оплатить: ${remaining.toFixed(2)} руб.`;
|
||
}
|
||
}
|
||
|
||
addPayment() {
|
||
if (!this.selectedMethod) {
|
||
alert('Выберите способ оплаты');
|
||
return;
|
||
}
|
||
|
||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||
const notesInput = document.getElementById(`${this.containerId}-notes`);
|
||
|
||
const amount = parseFloat(amountInput.value);
|
||
|
||
if (amount <= 0 || isNaN(amount)) {
|
||
alert('Введите сумму больше 0');
|
||
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)})`);
|
||
return;
|
||
}
|
||
|
||
// Добавляем в список
|
||
this.payments.push({
|
||
method_id: this.selectedMethod.id,
|
||
method_code: this.selectedMethod.code,
|
||
method_name: this.selectedMethod.name,
|
||
amount: amount,
|
||
notes: notesInput.value.trim()
|
||
});
|
||
|
||
// Обновляем UI
|
||
this.updatePaymentsList();
|
||
|
||
// Сбрасываем форму
|
||
const remaining = this.order.amount_due - this.getTotalPayments();
|
||
amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00';
|
||
notesInput.value = '';
|
||
|
||
// Снимаем выделение способа
|
||
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)} руб.` : 'Оплачено полностью';
|
||
}
|
||
|
||
removePayment(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;
|
||
}
|
||
|
||
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>
|
||
${p.notes ? `<br><small class="text-muted">${p.notes}</small>` : ''}
|
||
</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('');
|
||
|
||
// Прикрепляем обработчики удаления
|
||
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);
|
||
}
|
||
|
||
validate() {
|
||
if (this.mode === 'single') {
|
||
// Одиночная оплата
|
||
if (!this.selectedMethod) {
|
||
throw new Error('Выберите способ оплаты');
|
||
}
|
||
|
||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||
const amount = parseFloat(amountInput.value);
|
||
|
||
if (amount <= 0 || isNaN(amount)) {
|
||
throw new Error('Введите сумму больше 0');
|
||
}
|
||
|
||
if (amount > this.order.amount_due) {
|
||
throw new Error(`Сумма превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`);
|
||
}
|
||
|
||
// Проверка кошелька
|
||
if (this.selectedMethod.code === 'account_balance' && this.customer) {
|
||
if (amount > this.customer.wallet_balance) {
|
||
throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`);
|
||
}
|
||
}
|
||
|
||
} else {
|
||
// Смешанная оплата
|
||
if (this.payments.length === 0) {
|
||
throw new Error('Добавьте хотя бы один платеж');
|
||
}
|
||
|
||
const total = this.getTotalPayments();
|
||
|
||
if (total > this.order.amount_due) {
|
||
throw new Error(`Сумма платежей превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`);
|
||
}
|
||
|
||
// Проверка кошелька
|
||
if (this.customer) {
|
||
const walletPayments = this.payments.filter(p => p.method_code === 'account_balance');
|
||
const walletTotal = walletPayments.reduce((sum, p) => sum + p.amount, 0);
|
||
|
||
if (walletTotal > this.customer.wallet_balance) {
|
||
throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
submit() {
|
||
try {
|
||
this.validate();
|
||
|
||
let paymentsData;
|
||
|
||
if (this.mode === 'single') {
|
||
// Одиночная оплата
|
||
const amountInput = document.getElementById(`${this.containerId}-amount`);
|
||
const notesInput = document.getElementById(`${this.containerId}-notes`);
|
||
|
||
paymentsData = [{
|
||
payment_method: this.selectedMethod.code,
|
||
amount: parseFloat(amountInput.value),
|
||
notes: notesInput.value.trim()
|
||
}];
|
||
} else {
|
||
// Смешанная оплата
|
||
paymentsData = this.payments.map(p => ({
|
||
payment_method: p.method_code,
|
||
amount: p.amount,
|
||
notes: p.notes
|
||
}));
|
||
}
|
||
|
||
this.onSubmit(paymentsData);
|
||
|
||
} catch (error) {
|
||
alert(error.message);
|
||
}
|
||
}
|
||
}
|