update
This commit is contained in:
parent
acb588425a
commit
48fa86916b
|
|
@ -1,7 +1,4 @@
|
||||||
// invoice-view-init.js — Bootstrap-Script (type="module")
|
// 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 { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
||||||
import './payment-modal.js';
|
import './payment-modal.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// invoice-view.js — ES Module für die Invoice View
|
// invoice-view.js — ES Module v4
|
||||||
// v3: UTC date fix, Draft filter, persistent settings, PDF disabled for drafts
|
// Fixes: No Paid for drafts, payment modal, UTC dates, persistent settings
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// State
|
|
||||||
// ============================================================
|
|
||||||
let invoices = [];
|
let invoices = [];
|
||||||
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
|
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
|
||||||
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
|
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
|
||||||
|
|
@ -12,19 +9,14 @@ let groupBy = localStorage.getItem('inv_groupBy') || 'none';
|
||||||
const OVERDUE_DAYS = 30;
|
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) {
|
function parseLocalDate(dateStr) {
|
||||||
if (!dateStr) return null;
|
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 str = String(dateStr).split('T')[0];
|
||||||
const parts = str.split('-');
|
const parts = str.split('-');
|
||||||
if (parts.length !== 3) return null;
|
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]));
|
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,17 +24,13 @@ function formatDate(date) {
|
||||||
if (!date) return '—';
|
if (!date) return '—';
|
||||||
const d = parseLocalDate(date);
|
const d = parseLocalDate(date);
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
const year = d.getFullYear();
|
|
||||||
return `${month}/${day}/${year}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysSince(date) {
|
function daysSince(date) {
|
||||||
const d = parseLocalDate(date);
|
const d = parseLocalDate(date);
|
||||||
if (!d) return 0;
|
if (!d) return 0;
|
||||||
const now = new Date();
|
const now = new Date(); now.setHours(0, 0, 0, 0);
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((now - d) / 86400000);
|
return Math.floor((now - d) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,32 +54,18 @@ function getWeekRange(year, weekNum) {
|
||||||
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
||||||
const sunday = new Date(monday);
|
const sunday = new Date(monday);
|
||||||
sunday.setDate(monday.getDate() + 6);
|
sunday.setDate(monday.getDate() + 6);
|
||||||
const fmt = (d) => {
|
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dy = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${m}/${dy}/${d.getFullYear()}`;
|
|
||||||
};
|
|
||||||
return { start: fmt(monday), end: fmt(sunday) };
|
return { start: fmt(monday), end: fmt(sunday) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMonthName(monthIndex) {
|
function getMonthName(i) {
|
||||||
return ['January','February','March','April','May','June',
|
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
|
||||||
'July','August','September','October','November','December'][monthIndex];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPaid(inv) {
|
function isPaid(inv) { return !!inv.paid_date; }
|
||||||
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() {
|
function saveSettings() {
|
||||||
localStorage.setItem('inv_filterStatus', filterStatus);
|
localStorage.setItem('inv_filterStatus', filterStatus);
|
||||||
localStorage.setItem('inv_groupBy', groupBy);
|
localStorage.setItem('inv_groupBy', groupBy);
|
||||||
|
|
@ -99,7 +73,7 @@ function saveSettings() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Data Loading
|
// Data
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function loadInvoices() {
|
export async function loadInvoices() {
|
||||||
|
|
@ -107,94 +81,59 @@ export async function loadInvoices() {
|
||||||
const response = await fetch('/api/invoices');
|
const response = await fetch('/api/invoices');
|
||||||
invoices = await response.json();
|
invoices = await response.json();
|
||||||
renderInvoiceView();
|
renderInvoiceView();
|
||||||
} catch (error) {
|
} catch (error) { console.error('Error loading invoices:', error); }
|
||||||
console.error('Error loading invoices:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInvoicesData() {
|
export function getInvoicesData() { return invoices; }
|
||||||
return invoices;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Filtering & Sorting & Grouping
|
// Filter / Sort / Group
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function getFilteredInvoices() {
|
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()) {
|
if (filterCustomer.trim()) {
|
||||||
const search = filterCustomer.toLowerCase();
|
const s = filterCustomer.toLowerCase();
|
||||||
filtered = filtered.filter(inv =>
|
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
|
||||||
(inv.customer_name || '').toLowerCase().includes(search)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
|
||||||
// Sortierung: neueste zuerst
|
return f;
|
||||||
filtered.sort((a, b) => {
|
|
||||||
const da = parseLocalDate(a.invoice_date);
|
|
||||||
const db = parseLocalDate(b.invoice_date);
|
|
||||||
return (db || 0) - (da || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupInvoices(filtered) {
|
function groupInvoices(filtered) {
|
||||||
if (groupBy === 'none') return null;
|
if (groupBy === 'none') return null;
|
||||||
|
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
||||||
filtered.forEach(inv => {
|
filtered.forEach(inv => {
|
||||||
const d = parseLocalDate(inv.invoice_date);
|
const d = parseLocalDate(inv.invoice_date);
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
let key, label;
|
let key, label;
|
||||||
|
|
||||||
if (groupBy === 'week') {
|
if (groupBy === 'week') {
|
||||||
const wk = getWeekNumber(inv.invoice_date);
|
const wk = getWeekNumber(inv.invoice_date);
|
||||||
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
||||||
const range = getWeekRange(wk.year, wk.week);
|
const range = getWeekRange(wk.year, wk.week);
|
||||||
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
||||||
} else if (groupBy === 'month') {
|
} else {
|
||||||
const month = d.getMonth();
|
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
|
||||||
const year = d.getFullYear();
|
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
|
||||||
key = `${year}-${String(month).padStart(2, '0')}`;
|
|
||||||
label = `${getMonthName(month)} ${year}`;
|
|
||||||
}
|
}
|
||||||
|
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
|
||||||
if (!groups.has(key)) {
|
const g = groups.get(key);
|
||||||
groups.set(key, { label, invoices: [], total: 0 });
|
g.invoices.push(inv);
|
||||||
}
|
g.total += parseFloat(inv.total) || 0;
|
||||||
const group = groups.get(key);
|
|
||||||
group.invoices.push(inv);
|
|
||||||
group.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])));
|
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Rendering
|
// Render
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function renderInvoiceRow(invoice) {
|
function renderInvoiceRow(invoice) {
|
||||||
|
|
@ -203,76 +142,55 @@ function renderInvoiceRow(invoice) {
|
||||||
const overdue = isOverdue(invoice);
|
const overdue = isOverdue(invoice);
|
||||||
const draft = isDraft(invoice);
|
const draft = isDraft(invoice);
|
||||||
|
|
||||||
// Invoice Number Display
|
|
||||||
const invNumDisplay = invoice.invoice_number
|
const invNumDisplay = invoice.invoice_number
|
||||||
? invoice.invoice_number
|
? invoice.invoice_number
|
||||||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||||
|
|
||||||
// Status Badge
|
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
if (paid) {
|
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>`;
|
||||||
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>`;
|
||||||
} 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 = '—';
|
let sendDateDisplay = '—';
|
||||||
if (invoice.scheduled_send_date) {
|
if (invoice.scheduled_send_date) {
|
||||||
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
||||||
const today = new Date();
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
||||||
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
||||||
|
|
||||||
if (!paid) {
|
if (!paid) {
|
||||||
if (daysUntil < 0) {
|
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||||||
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 === 0) {
|
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
||||||
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) ---
|
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
|
||||||
|
|
||||||
// Edit
|
|
||||||
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||||
|
|
||||||
// QBO
|
|
||||||
const qboBtn = hasQbo
|
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>`
|
? `<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>`;
|
: `<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
|
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>`;
|
: `<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>`;
|
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
|
// PAYMENT BUTTON — NUR wenn in QBO. Drafts bekommen KEINEN Button.
|
||||||
let paidBtn;
|
let paidBtn = '';
|
||||||
if (paid) {
|
if (paid) {
|
||||||
paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
|
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>`;
|
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 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' : '';
|
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${rowClass}">
|
<tr class="${rowClass}">
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
||||||
${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 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">${formatDate(invoice.invoice_date)}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</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">
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
|
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupHeader(label) {
|
function renderGroupHeader(label) {
|
||||||
return `
|
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
|
||||||
<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) {
|
function renderGroupFooter(total, count) {
|
||||||
return `
|
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
|
||||||
<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 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 class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderInvoiceView() {
|
export function renderInvoiceView() {
|
||||||
const tbody = document.getElementById('invoices-list');
|
const tbody = document.getElementById('invoices-list');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const filtered = getFilteredInvoices();
|
const filtered = getFilteredInvoices();
|
||||||
const groups = groupInvoices(filtered);
|
const groups = groupInvoices(filtered);
|
||||||
|
let html = '', grandTotal = 0;
|
||||||
let html = '';
|
|
||||||
let grandTotal = 0;
|
|
||||||
|
|
||||||
if (groups) {
|
if (groups) {
|
||||||
for (const [key, group] of groups) {
|
for (const [, group] of groups) {
|
||||||
html += renderGroupHeader(group.label);
|
html += renderGroupHeader(group.label);
|
||||||
group.invoices.forEach(inv => {
|
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
|
||||||
html += renderInvoiceRow(inv);
|
|
||||||
});
|
|
||||||
html += renderGroupFooter(group.total, group.invoices.length);
|
html += renderGroupFooter(group.total, group.invoices.length);
|
||||||
grandTotal += group.total;
|
grandTotal += group.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groups.size > 1) {
|
if (groups.size > 1) {
|
||||||
html += `
|
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
|
||||||
<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 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 class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filtered.forEach(inv => {
|
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += parseFloat(inv.total) || 0; });
|
||||||
html += renderInvoiceRow(inv);
|
|
||||||
grandTotal += parseFloat(inv.total) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
if (filtered.length > 0) {
|
||||||
html += `
|
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
|
||||||
<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 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 class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
||||||
html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
|
|
||||||
const countEl = document.getElementById('invoice-count');
|
const countEl = document.getElementById('invoice-count');
|
||||||
if (countEl) countEl.textContent = filtered.length;
|
if (countEl) countEl.textContent = filtered.length;
|
||||||
|
|
||||||
updateStatusButtons();
|
updateStatusButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusButtons() {
|
function updateStatusButtons() {
|
||||||
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
||||||
const status = btn.getAttribute('data-status-filter');
|
const s = btn.getAttribute('data-status-filter');
|
||||||
if (status === filterStatus) {
|
btn.classList.toggle('bg-blue-600', s === filterStatus);
|
||||||
btn.classList.remove('bg-white', 'text-gray-600');
|
btn.classList.toggle('text-white', s === filterStatus);
|
||||||
btn.classList.add('bg-blue-600', 'text-white');
|
btn.classList.toggle('bg-white', s !== filterStatus);
|
||||||
} else {
|
btn.classList.toggle('text-gray-600', s !== filterStatus);
|
||||||
btn.classList.remove('bg-blue-600', 'text-white');
|
|
||||||
btn.classList.add('bg-white', 'text-gray-600');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
|
const overdueCount = invoices.filter(i => isOverdue(i)).length;
|
||||||
const overdueBadge = document.getElementById('overdue-badge');
|
const ob = document.getElementById('overdue-badge');
|
||||||
if (overdueBadge) {
|
if (ob) { ob.textContent = overdueCount; ob.classList.toggle('hidden', overdueCount === 0); }
|
||||||
if (overdueCount > 0) {
|
|
||||||
overdueBadge.textContent = overdueCount;
|
|
||||||
overdueBadge.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
overdueBadge.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftCount = invoices.filter(inv => isDraft(inv) && !isPaid(inv)).length;
|
const draftCount = invoices.filter(i => isDraft(i) && !isPaid(i)).length;
|
||||||
const draftBadge = document.getElementById('draft-badge');
|
const db = document.getElementById('draft-badge');
|
||||||
if (draftBadge) {
|
if (db) { db.textContent = draftCount; db.classList.toggle('hidden', draftCount === 0); }
|
||||||
if (draftCount > 0) {
|
|
||||||
draftBadge.textContent = draftCount;
|
|
||||||
draftBadge.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
draftBadge.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
|
const unpaidCount = invoices.filter(i => !isPaid(i)).length;
|
||||||
const unpaidBadge = document.getElementById('unpaid-badge');
|
const ub = document.getElementById('unpaid-badge');
|
||||||
if (unpaidBadge) {
|
if (ub) ub.textContent = unpaidCount;
|
||||||
unpaidBadge.textContent = unpaidCount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -409,84 +274,51 @@ function updateStatusButtons() {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export function injectToolbar() {
|
export function injectToolbar() {
|
||||||
const container = document.getElementById('invoice-toolbar');
|
const c = document.getElementById('invoice-toolbar');
|
||||||
if (!container) return;
|
if (!c) return;
|
||||||
|
c.innerHTML = `
|
||||||
container.innerHTML = `
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
<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">
|
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||||
<button data-status-filter="all"
|
<button data-status-filter="all" onclick="window.invoiceView.setStatus('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">
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
||||||
All
|
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span></button>
|
||||||
</button>
|
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
|
||||||
<button data-status-filter="unpaid"
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid</button>
|
||||||
onclick="window.invoiceView.setStatus('unpaid')"
|
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
|
||||||
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
|
<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>
|
<button data-status-filter="draft" onclick="window.invoiceView.setStatus('draft')"
|
||||||
<button data-status-filter="paid"
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Draft
|
||||||
onclick="window.invoiceView.setStatus('paid')"
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<div class="w-px h-8 bg-gray-300"></div>
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
|
||||||
<!-- Customer Filter -->
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
||||||
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..."
|
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
|
||||||
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">
|
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>
|
||||||
|
|
||||||
<div class="w-px h-8 bg-gray-300"></div>
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
|
||||||
<!-- Group By -->
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm font-medium text-gray-700">Group:</label>
|
<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="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
|
||||||
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
|
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
|
||||||
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice Count -->
|
|
||||||
<div class="ml-auto text-sm text-gray-500">
|
<div class="ml-auto text-sm text-gray-500">
|
||||||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
|
|
||||||
// Restore active button state
|
|
||||||
updateStatusButtons();
|
updateStatusButtons();
|
||||||
|
|
||||||
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
||||||
filterCustomer = e.target.value;
|
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
|
||||||
saveSettings();
|
|
||||||
renderInvoiceView();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
||||||
groupBy = e.target.value;
|
groupBy = e.target.value; saveSettings(); renderInvoiceView();
|
||||||
saveSettings();
|
|
||||||
renderInvoiceView();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -494,129 +326,56 @@ export function injectToolbar() {
|
||||||
// Actions
|
// Actions
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export function setStatus(status) {
|
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
|
||||||
filterStatus = status;
|
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
|
||||||
saveSettings();
|
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
|
||||||
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) {
|
export async function exportToQBO(id) {
|
||||||
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
|
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
|
||||||
|
|
||||||
const btn = event.target;
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = "⏳...";
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||||
const result = await response.json();
|
const d = await r.json();
|
||||||
|
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||||
if (response.ok) {
|
else alert(`❌ ${d.error}`);
|
||||||
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`);
|
} catch (e) { alert('Netzwerkfehler.'); }
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
alert(`❌ Fehler: ${result.error}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Netzwerkfehler beim Export.');
|
|
||||||
} finally {
|
|
||||||
btn.textContent = originalText;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetQbo(id) {
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
|
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
|
||||||
if (response.ok) {
|
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
|
||||||
loadInvoices();
|
} catch (e) { console.error(e); }
|
||||||
} else {
|
|
||||||
const err = await response.json();
|
|
||||||
alert('Error: ' + (err.error || 'Unknown'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting QBO:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markPaid(id) {
|
export async function markPaid(id) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
|
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (r.ok) loadInvoices();
|
||||||
loadInvoices();
|
} catch (e) { console.error(e); }
|
||||||
} else {
|
|
||||||
const err = await response.json();
|
|
||||||
alert('Error: ' + (err.error || 'Unknown'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking paid:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markUnpaid(id) {
|
export async function markUnpaid(id) {
|
||||||
try {
|
try { const r = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (r.ok) loadInvoices(); }
|
||||||
const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' });
|
catch (e) { console.error(e); }
|
||||||
if (response.ok) {
|
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
const err = await response.json();
|
|
||||||
alert('Error: ' + (err.error || 'Unknown'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error marking unpaid:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function edit(id) {
|
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
|
||||||
if (typeof window.openInvoiceModal === 'function') {
|
|
||||||
await window.openInvoiceModal(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
if (!confirm('Are you sure you want to delete this invoice?')) return;
|
if (!confirm('Delete this invoice?')) return;
|
||||||
try {
|
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
||||||
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
|
catch (e) { console.error(e); }
|
||||||
if (response.ok) {
|
|
||||||
loadInvoices();
|
|
||||||
} else {
|
|
||||||
alert('Error deleting invoice');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Expose to window
|
// Expose
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.invoiceView = {
|
window.invoiceView = {
|
||||||
viewPDF,
|
viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
|
||||||
viewHTML,
|
loadInvoices, renderInvoiceView, setStatus
|
||||||
exportToQBO,
|
|
||||||
resetQbo,
|
|
||||||
markPaid,
|
|
||||||
markUnpaid,
|
|
||||||
edit,
|
|
||||||
remove,
|
|
||||||
loadInvoices,
|
|
||||||
renderInvoiceView,
|
|
||||||
setStatus
|
|
||||||
};
|
};
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
// payment-modal.js — ES Module für das Payment Recording Modal
|
// 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
|
// State
|
||||||
// ============================================================
|
// ============================================================
|
||||||
let bankAccounts = [];
|
let bankAccounts = [];
|
||||||
let paymentMethods = [];
|
let paymentMethods = [];
|
||||||
let selectedInvoices = []; // Array of invoice objects
|
let selectedInvoices = [];
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
// Cache QBO reference data (nur einmal laden)
|
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -35,15 +32,12 @@ async function loadQboData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Open / Close Modal
|
// Open / Close
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function openPaymentModal(invoiceIds = []) {
|
export async function openPaymentModal(invoiceIds = []) {
|
||||||
// Lade QBO-Daten falls noch nicht geschehen
|
|
||||||
await loadQboData();
|
await loadQboData();
|
||||||
|
|
||||||
// Lade die ausgewählten Rechnungen
|
|
||||||
if (invoiceIds.length > 0) {
|
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
for (const id of invoiceIds) {
|
for (const id of invoiceIds) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -56,88 +50,54 @@ export async function openPaymentModal(invoiceIds = []) {
|
||||||
console.error('Error loading invoice:', id, e);
|
console.error('Error loading invoice:', id, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
renderModal();
|
ensureModalElement();
|
||||||
|
renderModalContent();
|
||||||
document.getElementById('payment-modal').classList.add('active');
|
document.getElementById('payment-modal').classList.add('active');
|
||||||
isOpen = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closePaymentModal() {
|
export function closePaymentModal() {
|
||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
if (modal) modal.classList.remove('active');
|
if (modal) modal.classList.remove('active');
|
||||||
isOpen = false;
|
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Add/Remove Invoices from selection
|
// DOM
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function addInvoiceToPayment(invoiceId) {
|
function ensureModalElement() {
|
||||||
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() {
|
|
||||||
let modal = document.getElementById('payment-modal');
|
let modal = document.getElementById('payment-modal');
|
||||||
|
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
modal = document.createElement('div');
|
modal = document.createElement('div');
|
||||||
modal.id = 'payment-modal';
|
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);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalContent() {
|
||||||
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
const accountOptions = bankAccounts.map(acc =>
|
const accountOptions = bankAccounts.map(acc =>
|
||||||
`<option value="${acc.id}">${acc.name}</option>`
|
`<option value="${acc.id}">${acc.name}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const methodOptions = paymentMethods
|
// Zeige Check und ACH bevorzugt, aber alle als Fallback
|
||||||
.filter(pm => ['Check', 'ACH'].includes(pm.name) || pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach'))
|
const filteredMethods = paymentMethods.filter(pm =>
|
||||||
.map(pm => `<option value="${pm.id}">${pm.name}</option>`)
|
pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach')
|
||||||
.join('');
|
);
|
||||||
|
const methodsToShow = filteredMethods.length > 0 ? filteredMethods : paymentMethods;
|
||||||
// Falls keine Filter-Treffer, alle anzeigen
|
const methodOptions = methodsToShow.map(pm =>
|
||||||
const allMethodOptions = paymentMethods.map(pm =>
|
|
||||||
`<option value="${pm.id}">${pm.name}</option>`
|
`<option value="${pm.id}">${pm.name}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
modal.innerHTML = `
|
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">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
<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">
|
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
|
@ -149,18 +109,8 @@ function renderModal() {
|
||||||
|
|
||||||
<!-- Selected Invoices -->
|
<!-- Selected Invoices -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices to pay</label>
|
<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 id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto"></div>
|
||||||
<!-- 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Details -->
|
<!-- Payment Details -->
|
||||||
|
|
@ -179,7 +129,7 @@ function renderModal() {
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
||||||
<select id="payment-method"
|
<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">
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -244,52 +194,43 @@ function renderInvoiceList() {
|
||||||
function updateTotal() {
|
function updateTotal() {
|
||||||
const totalEl = document.getElementById('payment-total');
|
const totalEl = document.getElementById('payment-total');
|
||||||
if (!totalEl) return;
|
if (!totalEl) return;
|
||||||
|
|
||||||
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
totalEl.textContent = `$${total.toFixed(2)}`;
|
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() {
|
async function submitPayment() {
|
||||||
if (selectedInvoices.length === 0) {
|
if (selectedInvoices.length === 0) return;
|
||||||
alert('Bitte mindestens eine Rechnung auswählen.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentDate = document.getElementById('payment-date').value;
|
const paymentDate = document.getElementById('payment-date').value;
|
||||||
const reference = document.getElementById('payment-reference').value;
|
const reference = document.getElementById('payment-reference').value;
|
||||||
const methodId = document.getElementById('payment-method').value;
|
const methodSelect = document.getElementById('payment-method');
|
||||||
const depositToId = document.getElementById('payment-deposit-to').value;
|
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) {
|
if (!paymentDate || !methodId || !depositToId) {
|
||||||
alert('Bitte ein Zahlungsdatum angeben.');
|
alert('Bitte alle Felder ausfüllen.');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!methodId || !depositToId) {
|
|
||||||
alert('Bitte Payment Method und Deposit To auswählen.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
const invoiceNums = selectedInvoices.map(i => i.invoice_number || `ID:${i.id}`).join(', ');
|
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?`)) {
|
if (!confirm(`Payment $${total.toFixed(2)} für #${invoiceNums} an QBO senden?`)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitBtn = document.getElementById('payment-submit-btn');
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
const origText = submitBtn.innerHTML;
|
|
||||||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
|
@ -302,7 +243,9 @@ async function submitPayment() {
|
||||||
payment_date: paymentDate,
|
payment_date: paymentDate,
|
||||||
reference_number: reference,
|
reference_number: reference,
|
||||||
payment_method_id: methodId,
|
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) {
|
if (response.ok) {
|
||||||
alert(`✅ ${result.message}`);
|
alert(`✅ ${result.message}`);
|
||||||
closePaymentModal();
|
closePaymentModal();
|
||||||
// Invoice-Liste aktualisieren
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
if (window.invoiceView) {
|
|
||||||
window.invoiceView.loadInvoices();
|
|
||||||
} else if (typeof window.loadInvoices === 'function') {
|
|
||||||
window.loadInvoices();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
alert(`❌ Fehler: ${result.error}`);
|
alert(`❌ Fehler: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
@ -324,33 +262,18 @@ async function submitPayment() {
|
||||||
console.error('Payment error:', error);
|
console.error('Payment error:', error);
|
||||||
alert('Netzwerkfehler beim Payment.');
|
alert('Netzwerkfehler beim Payment.');
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.innerHTML = origText;
|
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helper: Add by ID from input field
|
// Expose
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.paymentModal = {
|
window.paymentModal = {
|
||||||
open: openPaymentModal,
|
open: openPaymentModal,
|
||||||
close: closePaymentModal,
|
close: closePaymentModal,
|
||||||
submit: submitPayment,
|
submit: submitPayment,
|
||||||
addInvoice: addInvoiceToPayment,
|
removeInvoice: removeInvoiceFromPayment
|
||||||
removeInvoice: removeInvoiceFromPayment,
|
|
||||||
addById: addInvoiceById
|
|
||||||
};
|
};
|
||||||
114
server.js
114
server.js
|
|
@ -1612,8 +1612,8 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// QBO PAYMENT RECORDING - Server Endpoints
|
// QBO PAYMENT ENDPOINTS — In server.js einfügen
|
||||||
// In server.js einfügen (z.B. nach dem /api/qbo/import-unpaid Endpoint)
|
// Speichert Payments sowohl in lokaler DB als auch in QBO
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1626,7 +1626,6 @@ app.get('/api/qbo/accounts', async (req, res) => {
|
||||||
? 'https://quickbooks.api.intuit.com'
|
? 'https://quickbooks.api.intuit.com'
|
||||||
: 'https://sandbox-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 query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||||
const response = await makeQboApiCall({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
|
@ -1648,7 +1647,7 @@ 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) => {
|
app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
|
|
@ -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) => {
|
app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
invoice_ids, // Array von lokalen Invoice IDs
|
invoice_ids, // Array von lokalen Invoice IDs
|
||||||
payment_date, // 'YYYY-MM-DD'
|
payment_date, // 'YYYY-MM-DD'
|
||||||
reference_number, // Check-Nummer oder ACH-Referenz
|
reference_number, // Check # oder ACH Referenz
|
||||||
payment_method_id, // QBO PaymentMethod ID
|
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;
|
} = req.body;
|
||||||
|
|
||||||
if (!invoice_ids || invoice_ids.length === 0) {
|
if (!invoice_ids || invoice_ids.length === 0) {
|
||||||
|
|
@ -1708,56 +1709,41 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
WHERE i.id = ANY($1)`,
|
WHERE i.id = ANY($1)`,
|
||||||
[invoice_ids]
|
[invoice_ids]
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoicesData = invoicesResult.rows;
|
const invoicesData = invoicesResult.rows;
|
||||||
|
|
||||||
// Validierung: Alle müssen eine qbo_id haben (schon in QBO)
|
// Validierung
|
||||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||||
if (notInQbo.length > 0) {
|
if (notInQbo.length > 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Folgende Rechnungen sind noch nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
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))];
|
const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||||
if (customerIds.length > 1) {
|
if (customerIds.length > 1) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ error: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
|
||||||
error: 'Alle Rechnungen eines Payments müssen zum selben Kunden gehören.'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerQboId = customerIds[0];
|
const customerQboId = customerIds[0];
|
||||||
|
const customerId = invoicesData[0].customer_id;
|
||||||
// Gesamtbetrag berechnen
|
|
||||||
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
|
|
||||||
// QBO Payment Objekt bauen
|
// ----- QBO Payment Objekt -----
|
||||||
const payment = {
|
const payment = {
|
||||||
CustomerRef: {
|
CustomerRef: { value: customerQboId },
|
||||||
value: customerQboId
|
|
||||||
},
|
|
||||||
TotalAmt: totalAmount,
|
TotalAmt: totalAmount,
|
||||||
TxnDate: payment_date,
|
TxnDate: payment_date,
|
||||||
PaymentRefNum: reference_number || '',
|
PaymentRefNum: reference_number || '',
|
||||||
PaymentMethodRef: {
|
PaymentMethodRef: { value: payment_method_id },
|
||||||
value: payment_method_id
|
DepositToAccountRef: { value: deposit_to_account_id },
|
||||||
},
|
|
||||||
DepositToAccountRef: {
|
|
||||||
value: deposit_to_account_id
|
|
||||||
},
|
|
||||||
Line: invoicesData.map(inv => ({
|
Line: invoicesData.map(inv => ({
|
||||||
Amount: parseFloat(inv.total),
|
Amount: parseFloat(inv.total),
|
||||||
LinkedTxn: [{
|
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
||||||
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({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -1767,18 +1753,35 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
|
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
if (data.Payment) {
|
if (!data.Payment) {
|
||||||
const qboPaymentId = data.Payment.Id;
|
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
||||||
console.log(`✅ QBO Payment erstellt: ID ${qboPaymentId}`);
|
return res.status(500).json({
|
||||||
|
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.Fault?.Error?.[0]?.Message || data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Lokale Invoices als bezahlt markieren
|
const qboPaymentId = data.Payment.Id;
|
||||||
|
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||||
|
|
||||||
|
// ----- Lokal in DB speichern -----
|
||||||
await dbClient.query('BEGIN');
|
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) {
|
for (const inv of invoicesData) {
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
`UPDATE invoices
|
`INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)`,
|
||||||
SET paid_date = $1, updated_at = CURRENT_TIMESTAMP
|
[localPaymentId, inv.id, parseFloat(inv.total)]
|
||||||
WHERE id = $2`,
|
);
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
[payment_date, inv.id]
|
[payment_date, inv.id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1787,17 +1790,12 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
payment_id: localPaymentId,
|
||||||
qbo_payment_id: qboPaymentId,
|
qbo_payment_id: qboPaymentId,
|
||||||
total: totalAmount,
|
total: totalAmount,
|
||||||
invoices_paid: invoicesData.length,
|
invoices_paid: invoicesData.length,
|
||||||
message: `Payment $${totalAmount.toFixed(2)} erfolgreich in QBO erfasst (ID: ${qboPaymentId}).`
|
message: `Payment $${totalAmount.toFixed(2)} erfasst (QBO: ${qboPaymentId}, Lokal: ${localPaymentId}).`
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.fault?.error?.[0]?.message || data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await dbClient.query('ROLLBACK').catch(() => {});
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue