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:
2025-12-23 16:36:41 +03:00
parent 5de1ae9bb9
commit b1d5ebb6df
2 changed files with 228 additions and 175 deletions

View 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);

View File

@@ -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 -->