update
This commit is contained in:
parent
48fa86916b
commit
b24a360fba
|
|
@ -0,0 +1,195 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// import_qbo_payment.js — Importiert ein spezifisches QBO Payment in die lokale DB
|
||||||
|
//
|
||||||
|
// Verwendung:
|
||||||
|
// node import_qbo_payment.js <QBO_PAYMENT_ID>
|
||||||
|
//
|
||||||
|
// Beispiel:
|
||||||
|
// node import_qbo_payment.js 20616
|
||||||
|
//
|
||||||
|
// Was passiert:
|
||||||
|
// 1. Payment aus QBO laden (inkl. LinkedTxn → verknüpfte Invoices)
|
||||||
|
// 2. Lokale Invoices anhand qbo_id finden
|
||||||
|
// 3. Payment in lokale payments-Tabelle schreiben
|
||||||
|
// 4. Invoices als bezahlt markieren (paid_date)
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
database: process.env.DB_NAME || 'quotes_db',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importPayment(qboPaymentId) {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
console.log(`\n🔍 Lade QBO Payment ${qboPaymentId}...`);
|
||||||
|
|
||||||
|
// 1. Payment aus QBO lesen
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment/${qboPaymentId}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const payment = data.Payment;
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
console.error('❌ Payment nicht gefunden in QBO.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Payment gefunden:`);
|
||||||
|
console.log(` Datum: ${payment.TxnDate}`);
|
||||||
|
console.log(` Betrag: $${payment.TotalAmt}`);
|
||||||
|
console.log(` Referenz: ${payment.PaymentRefNum || '(keine)'}`);
|
||||||
|
console.log(` Kunde: ${payment.CustomerRef?.name || payment.CustomerRef?.value}`);
|
||||||
|
|
||||||
|
// 2. Verknüpfte Invoices aus dem Payment extrahieren
|
||||||
|
const linkedInvoices = [];
|
||||||
|
if (payment.Line) {
|
||||||
|
for (const line of payment.Line) {
|
||||||
|
if (line.LinkedTxn) {
|
||||||
|
for (const txn of line.LinkedTxn) {
|
||||||
|
if (txn.TxnType === 'Invoice') {
|
||||||
|
linkedInvoices.push({
|
||||||
|
qbo_invoice_id: txn.TxnId,
|
||||||
|
amount: line.Amount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Verknüpfte Invoices: ${linkedInvoices.length}`);
|
||||||
|
linkedInvoices.forEach(li => {
|
||||||
|
console.log(` - QBO Invoice ID: ${li.qbo_invoice_id}, Amount: $${li.amount}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Lokale Invoices finden
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
// Kunden-ID lokal finden
|
||||||
|
const customerResult = await dbClient.query(
|
||||||
|
'SELECT id FROM customers WHERE qbo_id = $1',
|
||||||
|
[payment.CustomerRef?.value]
|
||||||
|
);
|
||||||
|
const customerId = customerResult.rows[0]?.id || null;
|
||||||
|
|
||||||
|
// PaymentMethod-Name aus QBO holen (optional)
|
||||||
|
let paymentMethodName = 'Unknown';
|
||||||
|
if (payment.PaymentMethodRef?.value) {
|
||||||
|
try {
|
||||||
|
const pmRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/paymentmethod/${payment.PaymentMethodRef.value}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||||
|
paymentMethodName = pmData.PaymentMethod?.Name || 'Unknown';
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ⚠️ PaymentMethod konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DepositTo-Account-Name aus QBO (optional)
|
||||||
|
let depositToName = '';
|
||||||
|
if (payment.DepositToAccountRef?.value) {
|
||||||
|
try {
|
||||||
|
const accRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/account/${payment.DepositToAccountRef.value}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const accData = accRes.getJson ? accRes.getJson() : accRes.json;
|
||||||
|
depositToName = accData.Account?.Name || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ⚠️ Account konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob Payment schon importiert
|
||||||
|
const existing = await dbClient.query(
|
||||||
|
'SELECT id FROM payments WHERE qbo_payment_id = $1',
|
||||||
|
[String(qboPaymentId)]
|
||||||
|
);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
console.log(`\n⚠️ Payment ${qboPaymentId} wurde bereits importiert (lokale ID: ${existing.rows[0].id}).`);
|
||||||
|
console.log(' Übersprungen.');
|
||||||
|
await dbClient.query('ROLLBACK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment lokal anlegen
|
||||||
|
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.TxnDate, payment.PaymentRefNum || null, paymentMethodName, depositToName, payment.TotalAmt, customerId, String(qboPaymentId)]
|
||||||
|
);
|
||||||
|
const localPaymentId = paymentResult.rows[0].id;
|
||||||
|
console.log(`\n💾 Lokales Payment erstellt: ID ${localPaymentId}`);
|
||||||
|
|
||||||
|
// Verknüpfte Invoices lokal finden und markieren
|
||||||
|
let matchedCount = 0;
|
||||||
|
for (const li of linkedInvoices) {
|
||||||
|
const invResult = await dbClient.query(
|
||||||
|
'SELECT id, invoice_number FROM invoices WHERE qbo_id = $1',
|
||||||
|
[li.qbo_invoice_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invResult.rows.length > 0) {
|
||||||
|
const localInv = invResult.rows[0];
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
|
||||||
|
[localPaymentId, localInv.id, li.amount]
|
||||||
|
);
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND paid_date IS NULL',
|
||||||
|
[payment.TxnDate, localInv.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` ✅ Invoice #${localInv.invoice_number || localInv.id} (QBO: ${li.qbo_invoice_id}) → bezahlt`);
|
||||||
|
matchedCount++;
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ QBO Invoice ID ${li.qbo_invoice_id} nicht in lokaler DB gefunden.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`\n✅ Import abgeschlossen: ${matchedCount}/${linkedInvoices.length} Invoices verknüpft.`);
|
||||||
|
console.log(` Payment: Lokal ID ${localPaymentId}, QBO ID ${qboPaymentId}`);
|
||||||
|
console.log(` Methode: ${paymentMethodName}, Konto: ${depositToName}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
const qboPaymentId = process.argv[2];
|
||||||
|
if (!qboPaymentId) {
|
||||||
|
console.log('Verwendung: node import_qbo_payment.js <QBO_PAYMENT_ID>');
|
||||||
|
console.log('Beispiel: node import_qbo_payment.js 20616');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
importPayment(qboPaymentId).catch(err => {
|
||||||
|
console.error('Fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
127
public/app.js
127
public/app.js
|
|
@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
//loadInvoices();
|
//loadInvoices();
|
||||||
setDefaultDate();
|
setDefaultDate();
|
||||||
checkCurrentLogo();
|
checkCurrentLogo();
|
||||||
|
loadLaborRate();
|
||||||
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
|
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
|
||||||
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||||
showTab(savedTab);
|
showTab(savedTab);
|
||||||
|
|
@ -219,30 +219,28 @@ async function loadCustomers() {
|
||||||
function renderCustomers() {
|
function renderCustomers() {
|
||||||
const tbody = document.getElementById('customers-list');
|
const tbody = document.getElementById('customers-list');
|
||||||
tbody.innerHTML = customers.map(customer => {
|
tbody.innerHTML = customers.map(customer => {
|
||||||
// Logik: Line 1-4 zusammenbauen
|
|
||||||
// filter(Boolean) entfernt null/undefined/leere Strings
|
|
||||||
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
||||||
|
|
||||||
// City, State, Zip anhängen
|
|
||||||
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||||
|
|
||||||
// Alles zusammenfügen
|
|
||||||
let fullAddress = lines.join(', ');
|
let fullAddress = lines.join(', ');
|
||||||
if (cityStateZip) {
|
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||||
fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
|
||||||
}
|
// QBO Status
|
||||||
|
const qboStatus = customer.qbo_id
|
||||||
|
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
|
||||||
|
: `<button onclick="exportCustomerToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Kunde nach QBO exportieren">QBO Export</button>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
${customer.name} ${qboStatus}
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
|
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +349,34 @@ async function deleteCustomer(id) {
|
||||||
alert('Error deleting customer');
|
alert('Error deleting customer');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function exportCustomerToQbo(customerId) {
|
||||||
|
const customer = customers.find(c => c.id === customerId);
|
||||||
|
if (!customer) return;
|
||||||
|
|
||||||
|
if (!confirm(`Kunde "${customer.name}" nach QuickBooks Online exportieren?`)) return;
|
||||||
|
|
||||||
|
showSpinner('Exportiere Kunde nach QBO...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/customers/${customerId}/export-qbo`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Kunde "${result.name}" erfolgreich in QBO erstellt (ID: ${result.qbo_id}).`);
|
||||||
|
// Kunden-Liste neu laden
|
||||||
|
const custResponse = await fetch('/api/customers');
|
||||||
|
customers = await custResponse.json();
|
||||||
|
renderCustomers();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Fehler: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting customer:', error);
|
||||||
|
alert('Netzwerkfehler beim Export.');
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Quote Management
|
// Quote Management
|
||||||
async function loadQuotes() {
|
async function loadQuotes() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -916,7 +941,7 @@ function addInvoiceItem(item = null) {
|
||||||
|
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||||
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateInvoiceItemPreview(this.closest('[id^=invoice-item-]'), ${itemId})">
|
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="handleTypeChange(this, ${itemId})">
|
||||||
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||||
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -999,7 +1024,27 @@ function updateInvoiceItemPreview(itemDiv, itemId) {
|
||||||
descPreview.textContent = preview || 'New item';
|
descPreview.textContent = preview || 'New item';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function handleTypeChange(selectEl, itemId) {
|
||||||
|
const itemDiv = selectEl.closest(`[id^=invoice-item-]`);
|
||||||
|
|
||||||
|
// Wenn Labor gewählt und Rate leer → Labor Rate eintragen
|
||||||
|
if (selectEl.value === '5' && qboLaborRate) {
|
||||||
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
|
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||||
|
rateInput.value = qboLaborRate;
|
||||||
|
// Amount neu berechnen
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
if (qtyInput.value) {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
amountInput.value = (qty * qboLaborRate).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
|
updateInvoiceTotals();
|
||||||
|
}
|
||||||
function removeInvoiceItem(itemId) {
|
function removeInvoiceItem(itemId) {
|
||||||
document.getElementById(`invoice-item-${itemId}`).remove();
|
document.getElementById(`invoice-item-${itemId}`).remove();
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
|
|
@ -1261,4 +1306,54 @@ async function importFromQBO() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// =====================================================
|
||||||
|
// 3. Labor Rate laden und in addInvoiceItem verwenden
|
||||||
|
// NEUE globale Variable + Lade-Funktion
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
let qboLaborRate = null; // Wird beim Start geladen
|
||||||
|
|
||||||
|
async function loadLaborRate() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/labor-rate');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.rate) {
|
||||||
|
qboLaborRate = data.rate;
|
||||||
|
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// =====================================================
|
||||||
|
// 5. Spinner Funktionen — NEUE Funktionen hinzufügen
|
||||||
|
// Wird bei QBO-Operationen angezeigt
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
function showSpinner(message = 'Bitte warten...') {
|
||||||
|
let overlay = document.getElementById('qbo-spinner');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'qbo-spinner';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSpinner() {
|
||||||
|
const overlay = document.getElementById('qbo-spinner');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.openInvoiceModal = openInvoiceModal;
|
window.openInvoiceModal = openInvoiceModal;
|
||||||
|
|
@ -167,9 +167,16 @@ function renderInvoiceRow(invoice) {
|
||||||
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
|
// --- 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>`;
|
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||||
|
|
||||||
const qboBtn = hasQbo
|
// QBO Button — nur aktiv wenn Kunde eine qbo_id hat
|
||||||
? `<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>`
|
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||||
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
let qboBtn;
|
||||||
|
if (hasQbo) {
|
||||||
|
qboBtn = `<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>`;
|
||||||
|
} else if (!customerHasQbo) {
|
||||||
|
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Kunde muss erst nach QBO exportiert werden">QBO ⚠</span>`;
|
||||||
|
} else {
|
||||||
|
qboBtn = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
const pdfBtn = draft
|
const pdfBtn = draft
|
||||||
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>`
|
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>`
|
||||||
|
|
@ -332,12 +339,14 @@ export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank')
|
||||||
|
|
||||||
export async function exportToQBO(id) {
|
export async function exportToQBO(id) {
|
||||||
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
|
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...');
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||||
else alert(`❌ ${d.error}`);
|
else alert(`❌ ${d.error}`);
|
||||||
} catch (e) { alert('Netzwerkfehler.'); }
|
} catch (e) { alert('Netzwerkfehler.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetQbo(id) {
|
export async function resetQbo(id) {
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ async function submitPayment() {
|
||||||
const submitBtn = document.getElementById('payment-submit-btn');
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/qbo/record-payment', {
|
const response = await fetch('/api/qbo/record-payment', {
|
||||||
|
|
@ -264,6 +265,7 @@ async function submitPayment() {
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
119
server.js
119
server.js
|
|
@ -403,7 +403,7 @@ app.delete('/api/quotes/:id', async (req, res) => {
|
||||||
app.get('/api/invoices', async (req, res) => {
|
app.get('/api/invoices', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT i.*, c.name as customer_name
|
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
ORDER BY i.created_at DESC
|
ORDER BY i.created_at DESC
|
||||||
|
|
@ -1831,8 +1831,125 @@ app.get('/api/payments', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Neue Server Endpoints — In server.js einfügen
|
||||||
|
// 1. Customer QBO Export
|
||||||
|
// 2. Labor Rate aus QBO
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
|
||||||
|
// --- 1. Kunde nach QBO exportieren ---
|
||||||
|
app.post('/api/customers/:id/export-qbo', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
|
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
|
||||||
|
const customer = custResult.rows[0];
|
||||||
|
|
||||||
|
if (customer.qbo_id) {
|
||||||
|
return res.status(400).json({ error: `Kunde "${customer.name}" ist bereits in QBO (ID: ${customer.qbo_id}).` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// QBO Customer Objekt
|
||||||
|
const qboCustomer = {
|
||||||
|
DisplayName: customer.name,
|
||||||
|
CompanyName: customer.name,
|
||||||
|
BillAddr: {},
|
||||||
|
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
|
||||||
|
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
|
||||||
|
// Taxable setzt man über TaxExemptionReasonId oder SalesTermRef
|
||||||
|
Taxable: customer.taxable !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adresse aufbauen
|
||||||
|
const addr = qboCustomer.BillAddr;
|
||||||
|
if (customer.line1) addr.Line1 = customer.line1;
|
||||||
|
if (customer.line2) addr.Line2 = customer.line2;
|
||||||
|
if (customer.line3) addr.Line3 = customer.line3;
|
||||||
|
if (customer.line4) addr.Line4 = customer.line4;
|
||||||
|
if (customer.city) addr.City = customer.city;
|
||||||
|
if (customer.state) addr.CountrySubDivisionCode = customer.state;
|
||||||
|
if (customer.zip_code) addr.PostalCode = customer.zip_code;
|
||||||
|
|
||||||
|
// Kein leeres BillAddr senden
|
||||||
|
if (Object.keys(addr).length === 0) delete qboCustomer.BillAddr;
|
||||||
|
|
||||||
|
console.log(`📤 Exportiere Kunde "${customer.name}" nach QBO...`);
|
||||||
|
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboCustomer)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (data.Customer) {
|
||||||
|
const qboId = data.Customer.Id;
|
||||||
|
|
||||||
|
// qbo_id lokal speichern
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE customers SET qbo_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[qboId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Kunde "${customer.name}" in QBO erstellt: ID ${qboId}`);
|
||||||
|
res.json({ success: true, qbo_id: qboId, name: customer.name });
|
||||||
|
} else {
|
||||||
|
console.error('❌ QBO Customer Fehler:', JSON.stringify(data));
|
||||||
|
|
||||||
|
// Spezieller Fehler: Name existiert schon in QBO
|
||||||
|
const errMsg = data.Fault?.Error?.[0]?.Message || JSON.stringify(data);
|
||||||
|
const errDetail = data.Fault?.Error?.[0]?.Detail || '';
|
||||||
|
|
||||||
|
res.status(500).json({ error: `QBO Fehler: ${errMsg}. ${errDetail}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Customer Export Error:', error);
|
||||||
|
res.status(500).json({ error: 'Export fehlgeschlagen: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- 2. Labor Rate aus QBO laden ---
|
||||||
|
// Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO
|
||||||
|
app.get('/api/qbo/labor-rate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// Item ID 5 = Labor
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/item/5`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const rate = data.Item?.UnitPrice || null;
|
||||||
|
|
||||||
|
console.log(`💰 QBO Labor Rate: $${rate}`);
|
||||||
|
res.json({ rate });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching labor rate:', error);
|
||||||
|
// Nicht kritisch — Fallback auf Frontend-Default
|
||||||
|
res.json({ rate: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Start server and browser
|
// Start server and browser
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue