469 lines
19 KiB
JavaScript
469 lines
19 KiB
JavaScript
// payment-modal.js — ES Module v2
|
||
// Supports: Multi-invoice, partial payments, unapplied (downpayments), editable amounts
|
||
|
||
let bankAccounts = [];
|
||
let paymentMethods = [];
|
||
let selectedInvoices = []; // { invoice, payAmount }
|
||
let dataLoaded = false;
|
||
let paymentMode = 'invoice'; // 'invoice' | 'unapplied'
|
||
|
||
// ============================================================
|
||
// Load QBO Data
|
||
// ============================================================
|
||
|
||
async function loadQboData() {
|
||
if (dataLoaded) return;
|
||
try {
|
||
const [accRes, pmRes] = await Promise.all([
|
||
fetch('/api/qbo/accounts'),
|
||
fetch('/api/qbo/payment-methods')
|
||
]);
|
||
if (accRes.ok) bankAccounts = await accRes.json();
|
||
if (pmRes.ok) paymentMethods = await pmRes.json();
|
||
dataLoaded = true;
|
||
} catch (e) { console.error('Error loading QBO data:', e); }
|
||
}
|
||
|
||
// ============================================================
|
||
// Open / Close
|
||
// ============================================================
|
||
|
||
export async function openPaymentModal(invoiceIds = [], mode = 'invoice') {
|
||
await loadQboData();
|
||
paymentMode = mode;
|
||
selectedInvoices = [];
|
||
|
||
for (const id of invoiceIds) {
|
||
try {
|
||
const res = await fetch(`/api/invoices/${id}`);
|
||
const data = await res.json();
|
||
if (data.invoice) {
|
||
selectedInvoices.push({
|
||
invoice: data.invoice,
|
||
payAmount: parseFloat(data.invoice.total)
|
||
});
|
||
}
|
||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||
}
|
||
|
||
ensureModalElement();
|
||
renderModalContent();
|
||
document.getElementById('payment-modal').classList.add('active');
|
||
}
|
||
|
||
export function openDownpaymentModal() {
|
||
openPaymentModal([], 'unapplied');
|
||
}
|
||
|
||
export function closePaymentModal() {
|
||
const modal = document.getElementById('payment-modal');
|
||
if (modal) modal.classList.remove('active');
|
||
selectedInvoices = [];
|
||
}
|
||
|
||
// ============================================================
|
||
// Add / Remove Invoices
|
||
// ============================================================
|
||
|
||
async function addInvoiceById() {
|
||
const input = document.getElementById('payment-add-invoice-id');
|
||
const searchVal = input.value.trim();
|
||
if (!searchVal) return;
|
||
|
||
// Suche nach Invoice-Nummer oder ID
|
||
try {
|
||
const res = await fetch('/api/invoices');
|
||
const allInvoices = await res.json();
|
||
|
||
const match = allInvoices.find(inv =>
|
||
String(inv.id) === searchVal ||
|
||
String(inv.invoice_number) === searchVal
|
||
);
|
||
|
||
if (!match) {
|
||
alert(`Keine Rechnung mit Nr/ID "${searchVal}" gefunden.`);
|
||
return;
|
||
}
|
||
|
||
// Validierungen
|
||
if (!match.qbo_id) {
|
||
alert('Diese Rechnung ist noch nicht in QBO.');
|
||
return;
|
||
}
|
||
if (match.paid_date) {
|
||
alert('Diese Rechnung ist bereits bezahlt.');
|
||
return;
|
||
}
|
||
if (selectedInvoices.find(si => si.invoice.id === match.id)) {
|
||
alert('Diese Rechnung ist bereits in der Liste.');
|
||
return;
|
||
}
|
||
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||
alert('Alle Rechnungen müssen zum selben Kunden gehören.');
|
||
return;
|
||
}
|
||
|
||
// Details laden
|
||
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||
const detailData = await detailRes.json();
|
||
|
||
selectedInvoices.push({
|
||
invoice: detailData.invoice,
|
||
payAmount: parseFloat(detailData.invoice.total)
|
||
});
|
||
|
||
renderInvoiceList();
|
||
updateTotal();
|
||
input.value = '';
|
||
} catch (e) {
|
||
console.error('Error adding invoice:', e);
|
||
alert('Fehler beim Suchen.');
|
||
}
|
||
}
|
||
|
||
function removeInvoice(invoiceId) {
|
||
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||
renderInvoiceList();
|
||
updateTotal();
|
||
}
|
||
|
||
function updatePayAmount(invoiceId, newAmount) {
|
||
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||
if (si) {
|
||
const val = parseFloat(newAmount) || 0;
|
||
const max = parseFloat(si.invoice.total);
|
||
si.payAmount = Math.min(val, max); // Nicht mehr als Total
|
||
}
|
||
updateTotal();
|
||
}
|
||
|
||
// ============================================================
|
||
// DOM
|
||
// ============================================================
|
||
|
||
function ensureModalElement() {
|
||
let modal = document.getElementById('payment-modal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'payment-modal';
|
||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||
document.body.appendChild(modal);
|
||
}
|
||
}
|
||
|
||
function renderModalContent() {
|
||
const modal = document.getElementById('payment-modal');
|
||
if (!modal) return;
|
||
|
||
const accountOptions = bankAccounts.map(acc =>
|
||
`<option value="${acc.id}">${acc.name}</option>`
|
||
).join('');
|
||
|
||
const filteredMethods = paymentMethods.filter(pm =>
|
||
pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach')
|
||
);
|
||
const methodsToShow = filteredMethods.length > 0 ? filteredMethods : paymentMethods;
|
||
const methodOptions = methodsToShow.map(pm =>
|
||
`<option value="${pm.id}">${pm.name}</option>`
|
||
).join('');
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const isUnapplied = paymentMode === 'unapplied';
|
||
const title = isUnapplied ? '💰 Record Downpayment' : '💰 Record Payment';
|
||
|
||
modal.innerHTML = `
|
||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-2xl font-bold text-gray-800">${title}</h2>
|
||
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mode Tabs -->
|
||
<div class="flex gap-2 mb-4">
|
||
<button onclick="window.paymentModal.switchMode('invoice')"
|
||
class="px-4 py-2 rounded-md text-sm font-medium ${!isUnapplied ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||
Against Invoice(s)
|
||
</button>
|
||
<button onclick="window.paymentModal.switchMode('unapplied')"
|
||
class="px-4 py-2 rounded-md text-sm font-medium ${isUnapplied ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||
Downpayment (Unapplied)
|
||
</button>
|
||
</div>
|
||
|
||
${isUnapplied ? renderUnappliedSection() : renderInvoiceSection()}
|
||
|
||
<!-- Payment Details -->
|
||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||
<input type="date" id="payment-date" value="${today}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
|
||
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
||
<select id="payment-method"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||
${methodOptions}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||
<select id="payment-deposit-to"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||
${accountOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Total -->
|
||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex justify-end space-x-3">
|
||
<button onclick="window.paymentModal.close()"
|
||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||
💰 Record Payment in QBO
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (!isUnapplied) {
|
||
renderInvoiceList();
|
||
}
|
||
updateTotal();
|
||
}
|
||
|
||
function renderInvoiceSection() {
|
||
return `
|
||
<div class="mb-6">
|
||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
|
||
<div class="mt-2 flex items-center gap-2">
|
||
<input type="text" id="payment-add-invoice-id" placeholder="Invoice # oder ID..."
|
||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
|
||
<button onclick="window.paymentModal.addById()"
|
||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderUnappliedSection() {
|
||
return `
|
||
<div class="mb-6">
|
||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||
<p class="text-sm text-yellow-800">
|
||
<strong>Downpayment / Vorabzahlung:</strong> Das Geld wird als Kundenguthaben in QBO verbucht
|
||
und kann später einer Rechnung zugeordnet werden.
|
||
</p>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||
<select id="payment-customer" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm">
|
||
<option value="">-- Kunde wählen --</option>
|
||
${(window.customers || []).filter(c => c.qbo_id).map(c =>
|
||
`<option value="${c.id}" data-qbo-id="${c.qbo_id}">${c.name}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Amount</label>
|
||
<input type="number" id="payment-unapplied-amount" step="0.01" min="0" placeholder="0.00"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||
oninput="window.paymentModal.updateTotal()">
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderInvoiceList() {
|
||
const container = document.getElementById('payment-invoice-list');
|
||
if (!container) return;
|
||
|
||
if (selectedInvoices.length === 0) {
|
||
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">Keine Rechnungen — bitte unten hinzufügen</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = selectedInvoices.map(si => {
|
||
const inv = si.invoice;
|
||
const total = parseFloat(inv.total);
|
||
const isPartial = si.payAmount < total;
|
||
|
||
return `
|
||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||
<div class="flex-1">
|
||
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||
<span class="text-gray-500 text-sm ml-2">${inv.customer_name || ''}</span>
|
||
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||
${isPartial ? '<span class="text-xs text-yellow-600 ml-2 font-semibold">Partial</span>' : ''}
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-gray-500 text-sm">$</span>
|
||
<input type="number" step="0.01" min="0.01" max="${total}"
|
||
value="${si.payAmount.toFixed(2)}"
|
||
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
||
class="w-28 px-2 py-1 border border-gray-300 rounded text-sm text-right font-semibold
|
||
${isPartial ? 'bg-yellow-50 border-yellow-300' : ''}">
|
||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateTotal() {
|
||
const totalEl = document.getElementById('payment-total');
|
||
if (!totalEl) return;
|
||
|
||
let total = 0;
|
||
if (paymentMode === 'unapplied') {
|
||
const amountInput = document.getElementById('payment-unapplied-amount');
|
||
total = parseFloat(amountInput?.value) || 0;
|
||
} else {
|
||
total = selectedInvoices.reduce((sum, si) => sum + si.payAmount, 0);
|
||
}
|
||
|
||
totalEl.textContent = `$${total.toFixed(2)}`;
|
||
}
|
||
|
||
// ============================================================
|
||
// Submit
|
||
// ============================================================
|
||
|
||
async function submitPayment() {
|
||
const paymentDate = document.getElementById('payment-date').value;
|
||
const reference = document.getElementById('payment-reference').value;
|
||
const methodSelect = document.getElementById('payment-method');
|
||
const depositSelect = document.getElementById('payment-deposit-to');
|
||
const methodId = methodSelect.value;
|
||
const methodName = methodSelect.options[methodSelect.selectedIndex]?.text || '';
|
||
const depositToId = depositSelect.value;
|
||
const depositToName = depositSelect.options[depositSelect.selectedIndex]?.text || '';
|
||
|
||
if (!paymentDate || !methodId || !depositToId) {
|
||
alert('Bitte alle Felder ausfüllen.');
|
||
return;
|
||
}
|
||
|
||
let body;
|
||
|
||
if (paymentMode === 'unapplied') {
|
||
// --- Downpayment ---
|
||
const custSelect = document.getElementById('payment-customer');
|
||
const customerId = custSelect?.value;
|
||
const customerQboId = custSelect?.selectedOptions[0]?.getAttribute('data-qbo-id');
|
||
const amount = parseFloat(document.getElementById('payment-unapplied-amount')?.value) || 0;
|
||
|
||
if (!customerId || !customerQboId) { alert('Bitte Kunde wählen.'); return; }
|
||
if (amount <= 0) { alert('Bitte Betrag eingeben.'); return; }
|
||
|
||
body = {
|
||
mode: 'unapplied',
|
||
customer_id: parseInt(customerId),
|
||
customer_qbo_id: customerQboId,
|
||
total_amount: amount,
|
||
payment_date: paymentDate,
|
||
reference_number: reference,
|
||
payment_method_id: methodId,
|
||
payment_method_name: methodName,
|
||
deposit_to_account_id: depositToId,
|
||
deposit_to_account_name: depositToName
|
||
};
|
||
|
||
if (!confirm(`Downpayment $${amount.toFixed(2)} an QBO senden?`)) return;
|
||
|
||
} else {
|
||
// --- Normal / Partial / Multi ---
|
||
if (selectedInvoices.length === 0) { alert('Bitte Rechnungen hinzufügen.'); return; }
|
||
|
||
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||
|
||
let msg = `Payment $${total.toFixed(2)} für ${nums} an QBO senden?`;
|
||
if (hasPartial) msg += '\n⚠️ Enthält Teilzahlung(en).';
|
||
if (!confirm(msg)) return;
|
||
|
||
body = {
|
||
mode: 'invoice',
|
||
invoice_payments: selectedInvoices.map(si => ({
|
||
invoice_id: si.invoice.id,
|
||
amount: si.payAmount
|
||
})),
|
||
payment_date: paymentDate,
|
||
reference_number: reference,
|
||
payment_method_id: methodId,
|
||
payment_method_name: methodName,
|
||
deposit_to_account_id: depositToId,
|
||
deposit_to_account_name: depositToName
|
||
};
|
||
}
|
||
|
||
const submitBtn = document.getElementById('payment-submit-btn');
|
||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||
submitBtn.disabled = true;
|
||
if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...');
|
||
|
||
try {
|
||
const response = await fetch('/api/qbo/record-payment', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (response.ok) {
|
||
alert(`✅ ${result.message}`);
|
||
closePaymentModal();
|
||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||
} else {
|
||
alert(`❌ Fehler: ${result.error}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Payment error:', error);
|
||
alert('Netzwerkfehler.');
|
||
} finally {
|
||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||
submitBtn.disabled = false;
|
||
if (typeof hideSpinner === 'function') hideSpinner();
|
||
}
|
||
}
|
||
|
||
function switchMode(mode) {
|
||
paymentMode = mode;
|
||
renderModalContent();
|
||
}
|
||
|
||
// ============================================================
|
||
// Expose
|
||
// ============================================================
|
||
|
||
window.paymentModal = {
|
||
open: openPaymentModal,
|
||
openDownpayment: openDownpaymentModal,
|
||
close: closePaymentModal,
|
||
submit: submitPayment,
|
||
addById: addInvoiceById,
|
||
removeInvoice: removeInvoice,
|
||
updateAmount: updatePayAmount,
|
||
updateTotal: updateTotal,
|
||
switchMode: switchMode
|
||
}; |