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>
|
</script>
|
||||||
|
|
||||||
<script>
|
<!-- Unified Transaction Form -->
|
||||||
// Унифицированная форма платежа/возврата
|
<script src="{% static 'orders/js/unified_transaction_form.js' %}"></script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const form = document.getElementById('unified-transaction-form');
|
|
||||||
if (!form) return; // Форма есть только для существующих заказов
|
|
||||||
|
|
||||||
{% if order.pk %}
|
{% if order.pk %}
|
||||||
// URLs для смены action
|
<script>
|
||||||
const paymentUrl = "{% url 'orders:transaction-add-payment' order.order_number %}";
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const refundUrl = "{% url 'orders:transaction-add-refund' order.order_number %}";
|
window.initUnifiedTransactionForm('#unified-transaction-form', {
|
||||||
|
paymentUrl: "{% url 'orders:transaction-add-payment' order.order_number %}",
|
||||||
// Элементы
|
refundUrl: "{% url 'orders:transaction-add-refund' order.order_number %}",
|
||||||
const modeButtons = document.querySelectorAll('#mode-toggle [data-mode]');
|
amountDue: {{ order.amount_due|default:0|unlocalize }},
|
||||||
const methodButtons = document.querySelectorAll('.method-btn');
|
amountPaid: {{ order.amount_paid|default:0|unlocalize }},
|
||||||
const methodHidden = document.getElementById('unified-method-id');
|
walletBalance: {% if order.customer %}{{ order.customer.wallet_balance|default:0|unlocalize }}{% else %}0{% endif %}
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Выбор способа
|
|
||||||
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>
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- Customer Select2 Widget -->
|
<!-- Customer Select2 Widget -->
|
||||||
|
|||||||
Reference in New Issue
Block a user