refactor(orders): extract unified transaction form to reusable module
Moved payment/refund form logic from order_form.html to a dedicated unified_transaction_form.js module for better code organization. Changes: - Created unified_transaction_form.js with initUnifiedTransactionForm() (~233 lines) - Dual mode: payment and refund switching - Dynamic form action and field names - Payment method selection with validation - Wallet balance limits for account_balance method - Amount constraints based on mode - Real-time UI updates and validation - Updated order_form.html: - Added unified_transaction_form.js include - Added initialization call with Django template data - Removed inline transaction form code (~175 lines) - Passes URLs and amounts via options Benefits: - Cleaner template (175 lines removed) - Reusable transaction form logic - Easier to test and maintain - Configurable via options or data-attributes - No duplication between payment/refund modes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
218
myproject/orders/static/orders/js/unified_transaction_form.js
Normal file
218
myproject/orders/static/orders/js/unified_transaction_form.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Unified Transaction Form Module
|
||||
* Переиспользуемый модуль для формы платежей/возвратов
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Инициализирует унифицированную форму транзакций
|
||||
* @param {Element|string} formElement - DOM элемент формы или селектор
|
||||
* @param {Object} options - Настройки
|
||||
* @param {string} options.paymentUrl - URL для платежей
|
||||
* @param {string} options.refundUrl - URL для возвратов
|
||||
* @param {number} options.amountDue - Сумма к оплате
|
||||
* @param {number} options.amountPaid - Сумма оплаченного
|
||||
* @param {number} options.walletBalance - Баланс кошелька клиента
|
||||
*/
|
||||
function initUnifiedTransactionForm(formElement, options) {
|
||||
var form = typeof formElement === 'string'
|
||||
? document.querySelector(formElement)
|
||||
: formElement;
|
||||
|
||||
if (!form) {
|
||||
console.error('[UnifiedTransactionForm] Form not found');
|
||||
return;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
|
||||
// URLs из опций или data-атрибутов
|
||||
var paymentUrl = options.paymentUrl || form.dataset.paymentUrl;
|
||||
var refundUrl = options.refundUrl || form.dataset.refundUrl;
|
||||
|
||||
// Данные из опций или data-атрибутов
|
||||
var amountDue = options.amountDue !== undefined
|
||||
? parseFloat(options.amountDue)
|
||||
: parseFloat(form.dataset.amountDue || 0);
|
||||
|
||||
var amountPaid = options.amountPaid !== undefined
|
||||
? parseFloat(options.amountPaid)
|
||||
: parseFloat(form.dataset.amountPaid || 0);
|
||||
|
||||
var walletBalance = options.walletBalance !== undefined
|
||||
? parseFloat(options.walletBalance)
|
||||
: parseFloat(form.dataset.walletBalance || 0);
|
||||
|
||||
// Элементы формы
|
||||
var modeButtons = form.querySelectorAll('#mode-toggle [data-mode]');
|
||||
var methodButtons = form.querySelectorAll('.method-btn');
|
||||
var methodHidden = form.querySelector('#unified-method-id');
|
||||
|
||||
var amountInput = form.querySelector('#unified-amount');
|
||||
var amountHint = form.querySelector('#amount-hint');
|
||||
var amountMaxSpan = form.querySelector('#amount-max');
|
||||
var walletEmptyHint = form.querySelector('#wallet-empty-hint');
|
||||
|
||||
var refundReasonWrap = form.querySelector('#refund-reason-wrap');
|
||||
var refundReasonInput = form.querySelector('#refund-reason');
|
||||
var notesInput = form.querySelector('#unified-notes');
|
||||
var submitBtn = form.querySelector('#unified-submit');
|
||||
|
||||
// Состояние
|
||||
var currentMode = 'payment'; // режим по умолчанию — Платёж
|
||||
var currentMethodCode = null;
|
||||
|
||||
// Применить режим
|
||||
function applyMode() {
|
||||
if (currentMode === 'payment') {
|
||||
form.action = paymentUrl;
|
||||
methodHidden.name = 'payment_method';
|
||||
amountInput.name = 'amount';
|
||||
notesInput.name = 'notes';
|
||||
refundReasonWrap.style.display = 'none';
|
||||
refundReasonInput.removeAttribute('name');
|
||||
refundReasonInput.removeAttribute('required');
|
||||
|
||||
submitBtn.classList.remove('btn-warning', 'text-dark');
|
||||
submitBtn.classList.add('btn-success');
|
||||
submitBtn.innerHTML = '<i class="bi bi-check-lg"></i> Оплатить';
|
||||
|
||||
updateLimitsForPayment();
|
||||
} else {
|
||||
form.action = refundUrl;
|
||||
methodHidden.name = 'refund_payment_method';
|
||||
amountInput.name = 'refund_amount';
|
||||
notesInput.name = 'refund_notes';
|
||||
refundReasonWrap.style.display = 'block';
|
||||
refundReasonInput.name = 'refund_reason';
|
||||
refundReasonInput.setAttribute('required', 'required');
|
||||
|
||||
submitBtn.classList.remove('btn-success');
|
||||
submitBtn.classList.add('btn-warning', 'text-dark');
|
||||
submitBtn.innerHTML = '<i class="bi bi-arrow-return-left"></i> Вернуть средства';
|
||||
|
||||
updateLimitsForRefund();
|
||||
}
|
||||
}
|
||||
|
||||
// Лимиты для платежа (кошелёк ограничивается балансом и суммой к оплате)
|
||||
function updateLimitsForPayment() {
|
||||
if (currentMethodCode === 'account_balance') {
|
||||
var max = Math.min(amountDue, walletBalance);
|
||||
amountInput.max = max;
|
||||
amountHint.style.display = 'inline';
|
||||
amountMaxSpan.textContent = (max || 0).toFixed(2);
|
||||
} else {
|
||||
amountInput.removeAttribute('max');
|
||||
amountHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Лимиты для возврата (всегда ограничиваем суммой оплаченного)
|
||||
function updateLimitsForRefund() {
|
||||
amountInput.max = amountPaid || 0;
|
||||
amountHint.style.display = 'inline';
|
||||
amountMaxSpan.textContent = (amountPaid || 0).toFixed(2);
|
||||
}
|
||||
|
||||
// Выбор режима
|
||||
modeButtons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
modeButtons.forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.dataset.mode;
|
||||
|
||||
// Сбрасываем выбор способа и сумму при смене режима
|
||||
methodButtons.forEach(function(b) { b.classList.remove('active'); });
|
||||
methodHidden.value = '';
|
||||
currentMethodCode = null;
|
||||
amountInput.value = currentMode === 'payment' ? (amountDue || '') : '';
|
||||
|
||||
applyMode();
|
||||
});
|
||||
});
|
||||
|
||||
// Выбор способа
|
||||
methodButtons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var methodCode = btn.dataset.code;
|
||||
|
||||
// Скрываем подсказку
|
||||
walletEmptyHint.style.display = 'none';
|
||||
|
||||
// Проверяем баланс кошелька
|
||||
if (currentMode === 'payment' && methodCode === 'account_balance' && walletBalance === 0) {
|
||||
walletEmptyHint.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Активируем выбранный способ
|
||||
methodButtons.forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
methodHidden.value = btn.dataset.id;
|
||||
currentMethodCode = methodCode;
|
||||
|
||||
// Для кошелька подставляем максимальную сумму
|
||||
if (currentMode === 'payment' && methodCode === 'account_balance') {
|
||||
amountInput.value = Math.min(amountDue, walletBalance).toFixed(2);
|
||||
}
|
||||
|
||||
// Обновляем лимиты
|
||||
currentMode === 'payment' ? updateLimitsForPayment() : updateLimitsForRefund();
|
||||
});
|
||||
});
|
||||
|
||||
// Инициализация начального режима
|
||||
if (modeButtons.length > 0) {
|
||||
modeButtons[0].classList.add('active');
|
||||
}
|
||||
amountInput.value = amountDue || '';
|
||||
applyMode();
|
||||
|
||||
// Валидация перед отправкой формы
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Проверяем выбран ли способ оплаты
|
||||
if (!methodHidden.value) {
|
||||
e.preventDefault();
|
||||
|
||||
// Подсвечиваем блок со способами оплаты
|
||||
var methodsContainer = form.querySelector('#unified-methods');
|
||||
methodsContainer.classList.add('border', 'border-danger', 'rounded', 'p-2');
|
||||
|
||||
// Показываем подсказку
|
||||
var existingHint = form.querySelector('#method-required-hint');
|
||||
if (existingHint) {
|
||||
existingHint.remove();
|
||||
}
|
||||
|
||||
var hint = document.createElement('small');
|
||||
hint.id = 'method-required-hint';
|
||||
hint.className = 'text-danger d-block mt-1';
|
||||
var messageText = currentMode === 'payment' ? 'способ оплаты' : 'способ возврата';
|
||||
hint.innerHTML = '<i class="bi bi-exclamation-circle"></i> Пожалуйста, выберите ' + messageText;
|
||||
methodsContainer.parentElement.appendChild(hint);
|
||||
|
||||
// Убираем подсветку при выборе
|
||||
var removeValidation = function() {
|
||||
methodsContainer.classList.remove('border', 'border-danger', 'rounded', 'p-2');
|
||||
var hintEl = form.querySelector('#method-required-hint');
|
||||
if (hintEl) hintEl.remove();
|
||||
};
|
||||
|
||||
methodButtons.forEach(function(btn) {
|
||||
btn.addEventListener('click', removeValidation, { once: true });
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[UnifiedTransactionForm] Initialized for form:', form.id || form);
|
||||
}
|
||||
|
||||
// Экспорт
|
||||
window.initUnifiedTransactionForm = initUnifiedTransactionForm;
|
||||
|
||||
})(window);
|
||||
@@ -883,186 +883,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Unified Transaction Form -->
|
||||
<script src="{% static 'orders/js/unified_transaction_form.js' %}"></script>
|
||||
{% if order.pk %}
|
||||
<script>
|
||||
// Унифицированная форма платежа/возврата
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('unified-transaction-form');
|
||||
if (!form) return; // Форма есть только для существующих заказов
|
||||
|
||||
{% if order.pk %}
|
||||
// URLs для смены action
|
||||
const paymentUrl = "{% url 'orders:transaction-add-payment' order.order_number %}";
|
||||
const refundUrl = "{% url 'orders:transaction-add-refund' order.order_number %}";
|
||||
|
||||
// Элементы
|
||||
const modeButtons = document.querySelectorAll('#mode-toggle [data-mode]');
|
||||
const methodButtons = document.querySelectorAll('.method-btn');
|
||||
const methodHidden = document.getElementById('unified-method-id');
|
||||
|
||||
const amountInput = document.getElementById('unified-amount');
|
||||
const amountHint = document.getElementById('amount-hint');
|
||||
const amountMaxSpan = document.getElementById('amount-max');
|
||||
const walletEmptyHint = document.getElementById('wallet-empty-hint');
|
||||
|
||||
const refundReasonWrap = document.getElementById('refund-reason-wrap');
|
||||
const refundReasonInput = document.getElementById('refund-reason');
|
||||
const notesInput = document.getElementById('unified-notes');
|
||||
const submitBtn = document.getElementById('unified-submit');
|
||||
|
||||
// Данные
|
||||
const amountDue = {{ order.amount_due|default:0|unlocalize }};
|
||||
const amountPaid = {{ order.amount_paid|default:0|unlocalize }};
|
||||
const walletBalance = {% if order.customer %}{{ order.customer.wallet_balance|default:0|unlocalize }}{% else %}0{% endif %};
|
||||
|
||||
// Состояние
|
||||
let currentMode = 'payment'; // режим по умолчанию — Платёж
|
||||
let currentMethodCode = null;
|
||||
|
||||
// Применить режим
|
||||
function applyMode() {
|
||||
if (currentMode === 'payment') {
|
||||
form.action = paymentUrl;
|
||||
methodHidden.name = 'payment_method';
|
||||
amountInput.name = 'amount';
|
||||
notesInput.name = 'notes';
|
||||
refundReasonWrap.style.display = 'none';
|
||||
refundReasonInput.removeAttribute('name');
|
||||
refundReasonInput.removeAttribute('required');
|
||||
|
||||
submitBtn.classList.remove('btn-warning', 'text-dark');
|
||||
submitBtn.classList.add('btn-success');
|
||||
submitBtn.innerHTML = '<i class="bi bi-check-lg"></i> Оплатить';
|
||||
|
||||
updateLimitsForPayment();
|
||||
} else {
|
||||
form.action = refundUrl;
|
||||
methodHidden.name = 'refund_payment_method';
|
||||
amountInput.name = 'refund_amount';
|
||||
notesInput.name = 'refund_notes';
|
||||
refundReasonWrap.style.display = 'block';
|
||||
refundReasonInput.name = 'refund_reason';
|
||||
refundReasonInput.setAttribute('required', 'required');
|
||||
|
||||
submitBtn.classList.remove('btn-success');
|
||||
submitBtn.classList.add('btn-warning', 'text-dark');
|
||||
submitBtn.innerHTML = '<i class="bi bi-arrow-return-left"></i> Вернуть средства';
|
||||
|
||||
updateLimitsForRefund();
|
||||
}
|
||||
}
|
||||
|
||||
// Лимиты для платежа (кошелёк ограничивается балансом и суммой к оплате)
|
||||
function updateLimitsForPayment() {
|
||||
if (currentMethodCode === 'account_balance') {
|
||||
const max = Math.min(amountDue, walletBalance);
|
||||
amountInput.max = max;
|
||||
amountHint.style.display = 'inline';
|
||||
amountMaxSpan.textContent = (max || 0).toFixed(2);
|
||||
} else {
|
||||
amountInput.removeAttribute('max');
|
||||
amountHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Лимиты для возврата (всегда ограничиваем суммой оплаченного)
|
||||
function updateLimitsForRefund() {
|
||||
amountInput.max = amountPaid || 0;
|
||||
amountHint.style.display = 'inline';
|
||||
amountMaxSpan.textContent = (amountPaid || 0).toFixed(2);
|
||||
}
|
||||
|
||||
// Выбор режима
|
||||
modeButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modeButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.dataset.mode;
|
||||
|
||||
// Сбрасываем выбор способа и сумму при смене режима
|
||||
methodButtons.forEach(b => b.classList.remove('active'));
|
||||
methodHidden.value = '';
|
||||
currentMethodCode = null;
|
||||
amountInput.value = currentMode === 'payment' ? (amountDue || '') : '';
|
||||
|
||||
applyMode();
|
||||
});
|
||||
window.initUnifiedTransactionForm('#unified-transaction-form', {
|
||||
paymentUrl: "{% url 'orders:transaction-add-payment' order.order_number %}",
|
||||
refundUrl: "{% url 'orders:transaction-add-refund' order.order_number %}",
|
||||
amountDue: {{ order.amount_due|default:0|unlocalize }},
|
||||
amountPaid: {{ order.amount_paid|default:0|unlocalize }},
|
||||
walletBalance: {% if order.customer %}{{ order.customer.wallet_balance|default:0|unlocalize }}{% else %}0{% endif %}
|
||||
});
|
||||
|
||||
// Выбор способа
|
||||
methodButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const methodCode = btn.dataset.code;
|
||||
|
||||
// Скрываем подсказку
|
||||
walletEmptyHint.style.display = 'none';
|
||||
|
||||
// Проверяем баланс кошелька
|
||||
if (currentMode === 'payment' && methodCode === 'account_balance' && walletBalance === 0) {
|
||||
walletEmptyHint.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Активируем выбранный способ
|
||||
methodButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
methodHidden.value = btn.dataset.id;
|
||||
currentMethodCode = methodCode;
|
||||
|
||||
// Для кошелька подставляем максимальную сумму
|
||||
if (currentMode === 'payment' && methodCode === 'account_balance') {
|
||||
amountInput.value = Math.min(amountDue, walletBalance).toFixed(2);
|
||||
}
|
||||
|
||||
// Обновляем лимиты
|
||||
currentMode === 'payment' ? updateLimitsForPayment() : updateLimitsForRefund();
|
||||
});
|
||||
});
|
||||
|
||||
// Инициализация начального режима
|
||||
modeButtons[0].classList.add('active');
|
||||
amountInput.value = amountDue || '';
|
||||
applyMode();
|
||||
|
||||
// Валидация перед отправкой формы
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Проверяем выбран ли способ оплаты
|
||||
if (!methodHidden.value) {
|
||||
e.preventDefault();
|
||||
|
||||
// Подсвечиваем блок со способами оплаты
|
||||
const methodsContainer = document.getElementById('unified-methods');
|
||||
methodsContainer.classList.add('border', 'border-danger', 'rounded', 'p-2');
|
||||
|
||||
// Показываем подсказку
|
||||
const existingHint = document.getElementById('method-required-hint');
|
||||
if (existingHint) {
|
||||
existingHint.remove();
|
||||
}
|
||||
|
||||
const hint = document.createElement('small');
|
||||
hint.id = 'method-required-hint';
|
||||
hint.className = 'text-danger d-block mt-1';
|
||||
const messageText = currentMode === 'payment' ? 'способ оплаты' : 'способ возврата';
|
||||
hint.innerHTML = '<i class="bi bi-exclamation-circle"></i> Пожалуйста, выберите ' + messageText;
|
||||
methodsContainer.parentElement.appendChild(hint);
|
||||
|
||||
// Убираем подсветку при выборе
|
||||
const removeValidation = () => {
|
||||
methodsContainer.classList.remove('border', 'border-danger', 'rounded', 'p-2');
|
||||
const hint = document.getElementById('method-required-hint');
|
||||
if (hint) hint.remove();
|
||||
};
|
||||
|
||||
methodButtons.forEach(btn => {
|
||||
btn.addEventListener('click', removeValidation, { once: true });
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Customer Select2 Widget -->
|
||||
|
||||
Reference in New Issue
Block a user