invoice-system/public/payment-modal.js

469 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
};