This commit is contained in:
Andreas Knuth 2026-02-18 10:09:57 -06:00
parent acb588425a
commit 48fa86916b
4 changed files with 276 additions and 575 deletions

View File

@ -1,7 +1,4 @@
// invoice-view-init.js — Bootstrap-Script (type="module")
// Wird in index.html als <script type="module"> geladen.
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
import './payment-modal.js';

View File

@ -1,9 +1,6 @@
// invoice-view.js — ES Module für die Invoice View
// v3: UTC date fix, Draft filter, persistent settings, PDF disabled for drafts
// invoice-view.js — ES Module v4
// Fixes: No Paid for drafts, payment modal, UTC dates, persistent settings
// ============================================================
// State
// ============================================================
let invoices = [];
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
@ -12,19 +9,14 @@ let groupBy = localStorage.getItem('inv_groupBy') || 'none';
const OVERDUE_DAYS = 30;
// ============================================================
// Helpers
// Date Helpers — KEIN new Date('YYYY-MM-DD') wegen UTC-Bug!
// ============================================================
// KRITISCHER FIX: Datum-String 'YYYY-MM-DD' OHNE Timezone-Conversion parsen
// new Date('2026-02-16') interpretiert als UTC → in CST wird's der 15.
// Stattdessen: manuell parsen oder 'T12:00:00' anhängen
function parseLocalDate(dateStr) {
if (!dateStr) return null;
// Wenn es schon ein T enthält (ISO mit Zeit), den Datumsteil nehmen
const str = String(dateStr).split('T')[0];
const parts = str.split('-');
if (parts.length !== 3) return null;
// Monat ist 0-basiert in JS
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
@ -32,17 +24,13 @@ function formatDate(date) {
if (!date) return '—';
const d = parseLocalDate(date);
if (!d) return '—';
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
}
function daysSince(date) {
const d = parseLocalDate(date);
if (!d) return 0;
const now = new Date();
now.setHours(0, 0, 0, 0);
const now = new Date(); now.setHours(0, 0, 0, 0);
return Math.floor((now - d) / 86400000);
}
@ -66,32 +54,18 @@ function getWeekRange(year, weekNum) {
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const fmt = (d) => {
const m = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
return `${m}/${dy}/${d.getFullYear()}`;
};
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
return { start: fmt(monday), end: fmt(sunday) };
}
function getMonthName(monthIndex) {
return ['January','February','March','April','May','June',
'July','August','September','October','November','December'][monthIndex];
function getMonthName(i) {
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
}
function isPaid(inv) {
return !!inv.paid_date;
}
function isPaid(inv) { return !!inv.paid_date; }
function isDraft(inv) { return !inv.qbo_id; }
function isOverdue(inv) { return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
function isDraft(inv) {
return !inv.qbo_id;
}
function isOverdue(inv) {
return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS;
}
// Save state to localStorage
function saveSettings() {
localStorage.setItem('inv_filterStatus', filterStatus);
localStorage.setItem('inv_groupBy', groupBy);
@ -99,7 +73,7 @@ function saveSettings() {
}
// ============================================================
// Data Loading
// Data
// ============================================================
export async function loadInvoices() {
@ -107,94 +81,59 @@ export async function loadInvoices() {
const response = await fetch('/api/invoices');
invoices = await response.json();
renderInvoiceView();
} catch (error) {
console.error('Error loading invoices:', error);
}
} catch (error) { console.error('Error loading invoices:', error); }
}
export function getInvoicesData() {
return invoices;
}
export function getInvoicesData() { return invoices; }
// ============================================================
// Filtering & Sorting & Grouping
// Filter / Sort / Group
// ============================================================
function getFilteredInvoices() {
let filtered = [...invoices];
let f = [...invoices];
if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i));
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
else if (filterStatus === 'draft') f = f.filter(i => isDraft(i) && !isPaid(i));
// Status Filter
if (filterStatus === 'unpaid') {
filtered = filtered.filter(inv => !isPaid(inv));
} else if (filterStatus === 'paid') {
filtered = filtered.filter(inv => isPaid(inv));
} else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv));
} else if (filterStatus === 'draft') {
filtered = filtered.filter(inv => isDraft(inv) && !isPaid(inv));
}
// Customer Filter
if (filterCustomer.trim()) {
const search = filterCustomer.toLowerCase();
filtered = filtered.filter(inv =>
(inv.customer_name || '').toLowerCase().includes(search)
);
const s = filterCustomer.toLowerCase();
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
}
// Sortierung: neueste zuerst
filtered.sort((a, b) => {
const da = parseLocalDate(a.invoice_date);
const db = parseLocalDate(b.invoice_date);
return (db || 0) - (da || 0);
});
return filtered;
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
return f;
}
function groupInvoices(filtered) {
if (groupBy === 'none') return null;
const groups = new Map();
filtered.forEach(inv => {
const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label;
if (groupBy === 'week') {
const wk = getWeekNumber(inv.invoice_date);
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
const range = getWeekRange(wk.year, wk.week);
label = `Week ${wk.week}, ${wk.year} (${range.start} ${range.end})`;
} else if (groupBy === 'month') {
const month = d.getMonth();
const year = d.getFullYear();
key = `${year}-${String(month).padStart(2, '0')}`;
label = `${getMonthName(month)} ${year}`;
} else {
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
}
if (!groups.has(key)) {
groups.set(key, { label, invoices: [], total: 0 });
}
const group = groups.get(key);
group.invoices.push(inv);
group.total += parseFloat(inv.total) || 0;
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
const g = groups.get(key);
g.invoices.push(inv);
g.total += parseFloat(inv.total) || 0;
});
for (const group of groups.values()) {
group.invoices.sort((a, b) => {
const da = parseLocalDate(a.invoice_date);
const db = parseLocalDate(b.invoice_date);
return (db || 0) - (da || 0);
});
for (const g of groups.values()) {
g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
}
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
}
// ============================================================
// Rendering
// Render
// ============================================================
function renderInvoiceRow(invoice) {
@ -203,76 +142,55 @@ function renderInvoiceRow(invoice) {
const overdue = isOverdue(invoice);
const draft = isDraft(invoice);
// Invoice Number Display
const invNumDisplay = invoice.invoice_number
? invoice.invoice_number
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
// Status Badge
let statusBadge = '';
if (paid) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
} else if (overdue) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
}
if (paid) statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
else if (overdue) statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
// Send Date display
// Send Date
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const today = new Date(); today.setHours(0, 0, 0, 0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date);
if (!paid) {
if (daysUntil < 0) {
sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
} else if (daysUntil === 0) {
sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
} else if (daysUntil <= 3) {
sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
}
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
}
}
// --- ACTION BUTTONS (Reihenfolge: Edit | QBO | PDF HTML | Paid | Del) ---
// Edit
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
// QBO
const qboBtn = hasQbo
? `<span class="text-gray-400 text-xs cursor-pointer" title="In QBO (ID: ${invoice.qbo_id}) — Click to reset" onclick="window.invoiceView.resetQbo(${invoice.id})">✓ QBO</span>`
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
// PDF + HTML — PDF deaktiviert wenn Draft (keine Rechnungsnummer)
const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export verfügbar">PDF</span>`
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>`
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
// Paid/Unpaid — wenn in QBO: Payment-Modal öffnen, sonst lokal markieren
let paidBtn;
// PAYMENT BUTTON — NUR wenn in QBO. Drafts bekommen KEINEN Button.
let paidBtn = '';
if (paid) {
paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
} else if (hasQbo && window.paymentModal) {
} else if (hasQbo) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
} else {
paidBtn = `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid (local)">💰 Paid</button>`;
}
// Kein Button für Drafts (!hasQbo && !paid)
// Delete
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return `
<tr class="${rowClass}">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${invNumDisplay} ${statusBadge}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
@ -281,127 +199,74 @@ function renderInvoiceRow(invoice) {
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
</td>
</tr>
`;
</tr>`;
}
function renderGroupHeader(label) {
return `
<tr class="bg-blue-50">
<td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">
📅 ${label}
</td>
</tr>
`;
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
}
function renderGroupFooter(total, count) {
return `
<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
<td></td>
</tr>
`;
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
}
export function renderInvoiceView() {
const tbody = document.getElementById('invoices-list');
if (!tbody) return;
const filtered = getFilteredInvoices();
const groups = groupInvoices(filtered);
let html = '';
let grandTotal = 0;
let html = '', grandTotal = 0;
if (groups) {
for (const [key, group] of groups) {
for (const [, group] of groups) {
html += renderGroupHeader(group.label);
group.invoices.forEach(inv => {
html += renderInvoiceRow(inv);
});
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
html += renderGroupFooter(group.total, group.invoices.length);
grandTotal += group.total;
}
if (groups.size > 1) {
html += `
<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
<td></td>
</tr>
`;
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
} else {
filtered.forEach(inv => {
html += renderInvoiceRow(inv);
grandTotal += parseFloat(inv.total) || 0;
});
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += parseFloat(inv.total) || 0; });
if (filtered.length > 0) {
html += `
<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<td></td>
</tr>
`;
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
}
if (filtered.length === 0) {
html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
}
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
tbody.innerHTML = html;
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
updateStatusButtons();
}
function updateStatusButtons() {
document.querySelectorAll('[data-status-filter]').forEach(btn => {
const status = btn.getAttribute('data-status-filter');
if (status === filterStatus) {
btn.classList.remove('bg-white', 'text-gray-600');
btn.classList.add('bg-blue-600', 'text-white');
} else {
btn.classList.remove('bg-blue-600', 'text-white');
btn.classList.add('bg-white', 'text-gray-600');
}
const s = btn.getAttribute('data-status-filter');
btn.classList.toggle('bg-blue-600', s === filterStatus);
btn.classList.toggle('text-white', s === filterStatus);
btn.classList.toggle('bg-white', s !== filterStatus);
btn.classList.toggle('text-gray-600', s !== filterStatus);
});
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
const overdueBadge = document.getElementById('overdue-badge');
if (overdueBadge) {
if (overdueCount > 0) {
overdueBadge.textContent = overdueCount;
overdueBadge.classList.remove('hidden');
} else {
overdueBadge.classList.add('hidden');
}
}
const overdueCount = invoices.filter(i => isOverdue(i)).length;
const ob = document.getElementById('overdue-badge');
if (ob) { ob.textContent = overdueCount; ob.classList.toggle('hidden', overdueCount === 0); }
const draftCount = invoices.filter(inv => isDraft(inv) && !isPaid(inv)).length;
const draftBadge = document.getElementById('draft-badge');
if (draftBadge) {
if (draftCount > 0) {
draftBadge.textContent = draftCount;
draftBadge.classList.remove('hidden');
} else {
draftBadge.classList.add('hidden');
}
}
const draftCount = invoices.filter(i => isDraft(i) && !isPaid(i)).length;
const db = document.getElementById('draft-badge');
if (db) { db.textContent = draftCount; db.classList.toggle('hidden', draftCount === 0); }
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) {
unpaidBadge.textContent = unpaidCount;
}
const unpaidCount = invoices.filter(i => !isPaid(i)).length;
const ub = document.getElementById('unpaid-badge');
if (ub) ub.textContent = unpaidCount;
}
// ============================================================
@ -409,84 +274,51 @@ function updateStatusButtons() {
// ============================================================
export function injectToolbar() {
const container = document.getElementById('invoice-toolbar');
if (!container) return;
container.innerHTML = `
const c = document.getElementById('invoice-toolbar');
if (!c) return;
c.innerHTML = `
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<!-- Status Filter Buttons -->
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
<button data-status-filter="all"
onclick="window.invoiceView.setStatus('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
All
</button>
<button data-status-filter="unpaid"
onclick="window.invoiceView.setStatus('unpaid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
</button>
<button data-status-filter="paid"
onclick="window.invoiceView.setStatus('paid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Paid
</button>
<button data-status-filter="overdue"
onclick="window.invoiceView.setStatus('overdue')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Overdue
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span>
</button>
<button data-status-filter="draft"
onclick="window.invoiceView.setStatus('draft')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Draft
<span id="draft-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span>
</button>
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span></button>
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid</button>
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="draft" onclick="window.invoiceView.setStatus('draft')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Draft
<span id="draft-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<!-- Customer Filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Customer:</label>
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..."
value="${filterCustomer}"
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="w-px h-8 bg-gray-300"></div>
<!-- Group By -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Group:</label>
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white focus:ring-blue-500 focus:border-blue-500">
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
</select>
</div>
<!-- Invoice Count -->
<div class="ml-auto text-sm text-gray-500">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
</div>
</div>
`;
</div>`;
// Restore active button state
updateStatusButtons();
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value;
saveSettings();
renderInvoiceView();
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
});
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
groupBy = e.target.value;
saveSettings();
renderInvoiceView();
groupBy = e.target.value; saveSettings(); renderInvoiceView();
});
}
@ -494,129 +326,56 @@ export function injectToolbar() {
// Actions
// ============================================================
export function setStatus(status) {
filterStatus = status;
saveSettings();
renderInvoiceView();
}
export function viewPDF(id) {
window.open(`/api/invoices/${id}/pdf`, '_blank');
}
export function viewHTML(id) {
window.open(`/api/invoices/${id}/html`, '_blank');
}
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
export async function exportToQBO(id) {
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = "⏳...";
btn.disabled = true;
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
try {
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`);
loadInvoices();
} else {
alert(`❌ Fehler: ${result.error}`);
}
} catch (error) {
console.error(error);
alert('Netzwerkfehler beim Export.');
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const d = await r.json();
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
else alert(`${d.error}`);
} catch (e) { alert('Netzwerkfehler.'); }
}
export async function resetQbo(id) {
if (!confirm('QBO-Verknüpfung zurücksetzen?\nDie Rechnung muss zuerst in QBO gelöscht werden!')) return;
if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return;
try {
const response = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error resetting QBO:', error);
}
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
} catch (e) { console.error(e); }
}
export async function markPaid(id) {
try {
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
});
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error marking paid:', error);
}
if (r.ok) loadInvoices();
} catch (e) { console.error(e); }
}
export async function markUnpaid(id) {
try {
const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' });
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error marking unpaid:', error);
}
try { const r = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (r.ok) loadInvoices(); }
catch (e) { console.error(e); }
}
export async function edit(id) {
if (typeof window.openInvoiceModal === 'function') {
await window.openInvoiceModal(id);
}
}
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
export async function remove(id) {
if (!confirm('Are you sure you want to delete this invoice?')) return;
try {
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
if (response.ok) {
loadInvoices();
} else {
alert('Error deleting invoice');
}
} catch (error) {
console.error('Error:', error);
}
if (!confirm('Delete this invoice?')) return;
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
catch (e) { console.error(e); }
}
// ============================================================
// Expose to window
// Expose
// ============================================================
window.invoiceView = {
viewPDF,
viewHTML,
exportToQBO,
resetQbo,
markPaid,
markUnpaid,
edit,
remove,
loadInvoices,
renderInvoiceView,
setStatus
viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
loadInvoices, renderInvoiceView, setStatus
};

View File

@ -1,15 +1,12 @@
// payment-modal.js — ES Module für das Payment Recording Modal
// Ermöglicht: Auswahl mehrerer Rechnungen, Check/ACH, Deposit To Konto
// Fixes: Correct CSS class 'modal', local DB payment storage
// ============================================================
// State
// ============================================================
let bankAccounts = [];
let paymentMethods = [];
let selectedInvoices = []; // Array of invoice objects
let isOpen = false;
// Cache QBO reference data (nur einmal laden)
let selectedInvoices = [];
let dataLoaded = false;
// ============================================================
@ -35,109 +32,72 @@ async function loadQboData() {
}
// ============================================================
// Open / Close Modal
// Open / Close
// ============================================================
export async function openPaymentModal(invoiceIds = []) {
// Lade QBO-Daten falls noch nicht geschehen
await loadQboData();
// Lade die ausgewählten Rechnungen
if (invoiceIds.length > 0) {
selectedInvoices = [];
for (const id of invoiceIds) {
try {
const res = await fetch(`/api/invoices/${id}`);
const data = await res.json();
if (data.invoice) {
selectedInvoices.push(data.invoice);
}
} catch (e) {
console.error('Error loading invoice:', id, e);
selectedInvoices = [];
for (const id of invoiceIds) {
try {
const res = await fetch(`/api/invoices/${id}`);
const data = await res.json();
if (data.invoice) {
selectedInvoices.push(data.invoice);
}
} catch (e) {
console.error('Error loading invoice:', id, e);
}
}
renderModal();
ensureModalElement();
renderModalContent();
document.getElementById('payment-modal').classList.add('active');
isOpen = true;
}
export function closePaymentModal() {
const modal = document.getElementById('payment-modal');
if (modal) modal.classList.remove('active');
isOpen = false;
selectedInvoices = [];
}
// ============================================================
// Add/Remove Invoices from selection
// DOM
// ============================================================
export async function addInvoiceToPayment(invoiceId) {
if (selectedInvoices.find(inv => inv.id === invoiceId)) return; // already selected
try {
const res = await fetch(`/api/invoices/${invoiceId}`);
const data = await res.json();
if (data.invoice) {
// Validierung: Muss QBO-verknüpft sein
if (!data.invoice.qbo_id) {
alert('Diese Rechnung ist noch nicht in QBO. Bitte erst exportieren.');
return;
}
// Validierung: Alle müssen zum selben Kunden gehören
if (selectedInvoices.length > 0 && data.invoice.customer_id !== selectedInvoices[0].customer_id) {
alert('Alle Rechnungen eines Payments müssen zum selben Kunden gehören.');
return;
}
selectedInvoices.push(data.invoice);
renderInvoiceList();
updateTotal();
}
} catch (e) {
console.error('Error adding invoice:', e);
}
}
function removeInvoiceFromPayment(invoiceId) {
selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId);
renderInvoiceList();
updateTotal();
}
// ============================================================
// Rendering
// ============================================================
function renderModal() {
function ensureModalElement() {
let modal = document.getElementById('payment-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'payment-modal';
modal.className = 'modal-overlay';
// Verwende GLEICHE Klasse wie die existierenden Modals
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 methodOptions = paymentMethods
.filter(pm => ['Check', 'ACH'].includes(pm.name) || pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach'))
.map(pm => `<option value="${pm.id}">${pm.name}</option>`)
.join('');
// Falls keine Filter-Treffer, alle anzeigen
const allMethodOptions = paymentMethods.map(pm =>
// Zeige Check und ACH bevorzugt, aber alle als Fallback
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];
modal.innerHTML = `
<div class="modal-content" style="max-width: 700px;">
<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-6">
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
@ -149,18 +109,8 @@ function renderModal() {
<!-- Selected Invoices -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices to pay</label>
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto">
<!-- Wird dynamisch gefüllt -->
</div>
<div class="mt-2 flex items-center gap-2">
<input type="number" id="payment-add-invoice-id" placeholder="Invoice ID hinzufügen..."
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<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>
<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-48 overflow-y-auto"></div>
</div>
<!-- Payment Details -->
@ -179,7 +129,7 @@ function renderModal() {
<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 || allMethodOptions}
${methodOptions}
</select>
</div>
<div>
@ -244,52 +194,43 @@ function renderInvoiceList() {
function updateTotal() {
const totalEl = document.getElementById('payment-total');
if (!totalEl) return;
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
totalEl.textContent = `$${total.toFixed(2)}`;
// Submit-Button deaktivieren wenn keine Rechnungen
const submitBtn = document.getElementById('payment-submit-btn');
if (submitBtn) {
submitBtn.disabled = selectedInvoices.length === 0;
submitBtn.classList.toggle('opacity-50', selectedInvoices.length === 0);
}
}
function removeInvoiceFromPayment(invoiceId) {
selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId);
renderInvoiceList();
updateTotal();
}
// ============================================================
// Submit Payment
// Submit
// ============================================================
async function submitPayment() {
if (selectedInvoices.length === 0) {
alert('Bitte mindestens eine Rechnung auswählen.');
return;
}
if (selectedInvoices.length === 0) return;
const paymentDate = document.getElementById('payment-date').value;
const reference = document.getElementById('payment-reference').value;
const methodId = document.getElementById('payment-method').value;
const depositToId = document.getElementById('payment-deposit-to').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) {
alert('Bitte ein Zahlungsdatum angeben.');
return;
}
if (!methodId || !depositToId) {
alert('Bitte Payment Method und Deposit To auswählen.');
if (!paymentDate || !methodId || !depositToId) {
alert('Bitte alle Felder ausfüllen.');
return;
}
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
const invoiceNums = selectedInvoices.map(i => i.invoice_number || `ID:${i.id}`).join(', ');
if (!confirm(`Payment von $${total.toFixed(2)} für Rechnung(en) ${invoiceNums} an QBO senden?`)) {
return;
}
if (!confirm(`Payment $${total.toFixed(2)} für #${invoiceNums} an QBO senden?`)) return;
const submitBtn = document.getElementById('payment-submit-btn');
const origText = submitBtn.innerHTML;
submitBtn.innerHTML = '⏳ Wird gesendet...';
submitBtn.disabled = true;
@ -302,7 +243,9 @@ async function submitPayment() {
payment_date: paymentDate,
reference_number: reference,
payment_method_id: methodId,
deposit_to_account_id: depositToId
payment_method_name: methodName,
deposit_to_account_id: depositToId,
deposit_to_account_name: depositToName
})
});
@ -311,12 +254,7 @@ async function submitPayment() {
if (response.ok) {
alert(`${result.message}`);
closePaymentModal();
// Invoice-Liste aktualisieren
if (window.invoiceView) {
window.invoiceView.loadInvoices();
} else if (typeof window.loadInvoices === 'function') {
window.loadInvoices();
}
if (window.invoiceView) window.invoiceView.loadInvoices();
} else {
alert(`❌ Fehler: ${result.error}`);
}
@ -324,33 +262,18 @@ async function submitPayment() {
console.error('Payment error:', error);
alert('Netzwerkfehler beim Payment.');
} finally {
submitBtn.innerHTML = origText;
submitBtn.innerHTML = '💰 Record Payment in QBO';
submitBtn.disabled = false;
}
}
// ============================================================
// Helper: Add by ID from input field
// ============================================================
async function addInvoiceById() {
const input = document.getElementById('payment-add-invoice-id');
const id = parseInt(input.value);
if (!id) return;
await addInvoiceToPayment(id);
input.value = '';
}
// ============================================================
// Expose to window
// Expose
// ============================================================
window.paymentModal = {
open: openPaymentModal,
close: closePaymentModal,
submit: submitPayment,
addInvoice: addInvoiceToPayment,
removeInvoice: removeInvoiceFromPayment,
addById: addInvoiceById
removeInvoice: removeInvoiceFromPayment
};

166
server.js
View File

@ -1612,8 +1612,8 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
});
// =====================================================
// QBO PAYMENT RECORDING - Server Endpoints
// In server.js einfügen (z.B. nach dem /api/qbo/import-unpaid Endpoint)
// QBO PAYMENT ENDPOINTS — In server.js einfügen
// Speichert Payments sowohl in lokaler DB als auch in QBO
// =====================================================
@ -1622,11 +1622,10 @@ app.get('/api/qbo/accounts', async (req, res) => {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// Nur Bank-Konten abfragen
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
@ -1648,13 +1647,13 @@ app.get('/api/qbo/accounts', async (req, res) => {
});
// --- 2. Payment Methods aus QBO laden (für Check/ACH Dropdown) ---
// --- 2. Payment Methods aus QBO laden ---
app.get('/api/qbo/payment-methods', async (req, res) => {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
@ -1677,14 +1676,16 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
});
// --- 3. Payment in QBO erstellen (ein Check für 1..n Invoices) ---
// --- 3. Payment erstellen: Lokal + QBO ---
app.post('/api/qbo/record-payment', async (req, res) => {
const {
const {
invoice_ids, // Array von lokalen Invoice IDs
payment_date, // 'YYYY-MM-DD'
reference_number, // Check-Nummer oder ACH-Referenz
reference_number, // Check # oder ACH Referenz
payment_method_id, // QBO PaymentMethod ID
deposit_to_account_id // QBO Bank Account ID
payment_method_name, // 'Check' oder 'ACH' (für lokale DB)
deposit_to_account_id, // QBO Bank Account ID
deposit_to_account_name // Bankname (für lokale DB)
} = req.body;
if (!invoice_ids || invoice_ids.length === 0) {
@ -1692,72 +1693,57 @@ app.post('/api/qbo/record-payment', async (req, res) => {
}
const dbClient = await pool.connect();
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// Lokale Invoices laden
const invoicesResult = await dbClient.query(
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = ANY($1)`,
[invoice_ids]
);
const invoicesData = invoicesResult.rows;
// Validierung: Alle müssen eine qbo_id haben (schon in QBO)
// Validierung
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
if (notInQbo.length > 0) {
return res.status(400).json({
error: `Folgende Rechnungen sind noch nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
return res.status(400).json({
error: `Nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
});
}
// Validierung: Alle müssen denselben Kunden haben
const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
if (customerIds.length > 1) {
return res.status(400).json({
error: 'Alle Rechnungen eines Payments müssen zum selben Kunden gehören.'
});
return res.status(400).json({ error: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
}
const customerQboId = customerIds[0];
// Gesamtbetrag berechnen
const customerId = invoicesData[0].customer_id;
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
// QBO Payment Objekt bauen
// ----- QBO Payment Objekt -----
const payment = {
CustomerRef: {
value: customerQboId
},
CustomerRef: { value: customerQboId },
TotalAmt: totalAmount,
TxnDate: payment_date,
PaymentRefNum: reference_number || '',
PaymentMethodRef: {
value: payment_method_id
},
DepositToAccountRef: {
value: deposit_to_account_id
},
PaymentMethodRef: { value: payment_method_id },
DepositToAccountRef: { value: deposit_to_account_id },
Line: invoicesData.map(inv => ({
Amount: parseFloat(inv.total),
LinkedTxn: [{
TxnId: inv.qbo_id,
TxnType: 'Invoice'
}]
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
}))
};
console.log(`💰 Erstelle QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en), Kunde: ${invoicesData[0].customer_name}`);
console.log(`💰 QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en)`);
// Payment an QBO senden
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
@ -1767,38 +1753,50 @@ app.post('/api/qbo/record-payment', async (req, res) => {
const data = response.getJson ? response.getJson() : response.json;
if (data.Payment) {
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment erstellt: ID ${qboPaymentId}`);
// Lokale Invoices als bezahlt markieren
await dbClient.query('BEGIN');
for (const inv of invoicesData) {
await dbClient.query(
`UPDATE invoices
SET paid_date = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[payment_date, inv.id]
);
}
await dbClient.query('COMMIT');
res.json({
success: true,
qbo_payment_id: qboPaymentId,
total: totalAmount,
invoices_paid: invoicesData.length,
message: `Payment $${totalAmount.toFixed(2)} erfolgreich in QBO erfasst (ID: ${qboPaymentId}).`
});
} else {
if (!data.Payment) {
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
res.status(500).json({
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.fault?.error?.[0]?.message || data)
return res.status(500).json({
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.Fault?.Error?.[0]?.Message || data)
});
}
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
// ----- Lokal in DB speichern -----
await dbClient.query('BEGIN');
// Payment-Datensatz
const paymentResult = await dbClient.query(
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[payment_date, reference_number || null, payment_method_name || 'Check', deposit_to_account_name || '', totalAmount, customerId, qboPaymentId]
);
const localPaymentId = paymentResult.rows[0].id;
// Invoices mit Payment verknüpfen + als bezahlt markieren
for (const inv of invoicesData) {
await dbClient.query(
`INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)`,
[localPaymentId, inv.id, parseFloat(inv.total)]
);
await dbClient.query(
`UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
[payment_date, inv.id]
);
}
await dbClient.query('COMMIT');
res.json({
success: true,
payment_id: localPaymentId,
qbo_payment_id: qboPaymentId,
total: totalAmount,
invoices_paid: invoicesData.length,
message: `Payment $${totalAmount.toFixed(2)} erfasst (QBO: ${qboPaymentId}, Lokal: ${localPaymentId}).`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Payment Error:', error);
@ -1809,6 +1807,30 @@ app.post('/api/qbo/record-payment', async (req, res) => {
});
// --- 4. Lokale Payments auflisten (optional, für spätere Übersicht) ---
app.get('/api/payments', async (req, res) => {
try {
const result = await pool.query(`
SELECT p.*, c.name as customer_name,
json_agg(json_build_object(
'invoice_id', pi.invoice_id,
'amount', pi.amount,
'invoice_number', i.invoice_number
)) as invoices
FROM payments p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
LEFT JOIN invoices i ON i.id = pi.invoice_id
GROUP BY p.id, c.name
ORDER BY p.payment_date DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching payments:', error);
res.status(500).json({ error: 'Error fetching payments' });
}
});