invoice-system/public/app.js

1535 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global state
let customers = [];
let quotes = [];
let invoices = [];
let currentQuoteId = null;
let currentInvoiceId = null;
let currentCustomerId = null;
let itemCounter = 0;
let currentLogoFile = null;
// Alpine.js Customer Search Component
function customerSearch(type) {
return {
search: '',
selectedId: '',
selectedName: '',
open: false,
highlighted: 0,
get filteredCustomers() {
// Wenn Suche leer ist: ALLE Kunden zurückgeben (kein .slice mehr!)
if (!this.search) {
return customers;
}
const searchLower = this.search.toLowerCase();
// Filtern: Sucht im Namen, Line1, Stadt oder Account Nummer
// Auch hier: Kein .slice mehr, damit alle Ergebnisse (z.B. alle mit 'C') angezeigt werden
return customers.filter(c =>
(c.name || '').toLowerCase().includes(searchLower) ||
(c.line1 || '').toLowerCase().includes(searchLower) ||
(c.city || '').toLowerCase().includes(searchLower) ||
(c.account_number && c.account_number.includes(searchLower))
);
},
selectCustomer(customer) {
this.selectedId = customer.id;
this.selectedName = customer.name;
this.search = customer.name;
this.open = false;
this.highlighted = 0;
},
highlightNext() {
if (this.highlighted < this.filteredCustomers.length - 1) {
this.highlighted++;
}
},
highlightPrev() {
if (this.highlighted > 0) {
this.highlighted--;
}
},
selectHighlighted() {
if (this.filteredCustomers[this.highlighted]) {
this.selectCustomer(this.filteredCustomers[this.highlighted]);
}
},
reset() {
this.search = '';
this.selectedId = '';
this.selectedName = '';
this.open = false;
this.highlighted = 0;
}
};
}
// Make it globally available for Alpine
window.customerSearch = customerSearch;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
loadQuotes();
//loadInvoices();
setDefaultDate();
checkCurrentLogo();
loadLaborRate();
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
const savedTab = localStorage.getItem('activeTab') || 'quotes';
showTab(savedTab);
// Hash-basierte Tab-Navigation (z.B. nach OAuth Redirect /#settings)
if (window.location.hash) {
const hashTab = window.location.hash.replace('#', '');
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
showTab(hashTab);
}
}
// Setup form handlers
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit);
document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals);
document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals);
// Setup logo upload handler
document.getElementById('logo-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
currentLogoFile = file;
document.getElementById('logo-filename').textContent = file.name;
document.getElementById('upload-btn').disabled = false;
}
});
});
// Tab Management
function showTab(tabName) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800'));
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
// *** FIX 3: Tab-Auswahl persistieren ***
localStorage.setItem('activeTab', tabName);
if (tabName === 'quotes') {
loadQuotes();
} else if (tabName === 'invoices') {
loadInvoices();
} else if (tabName === 'customers') {
loadCustomers();
} else if (tabName === 'settings') {
checkCurrentLogo();
}
}
// Date helper
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
const quoteDateEl = document.getElementById('quote-date');
const invoiceDateEl = document.getElementById('invoice-date');
if (quoteDateEl) quoteDateEl.value = today;
if (invoiceDateEl) invoiceDateEl.value = today;
}
function formatDate(date) {
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
}
// Logo Management
async function checkCurrentLogo() {
try {
const response = await fetch('/api/logo-info');
if (response.ok) {
const data = await response.json();
if (data.hasLogo) {
document.getElementById('logo-preview').classList.remove('hidden');
document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now();
}
}
} catch (error) {
console.error('Error checking logo:', error);
}
}
async function uploadLogo() {
if (!currentLogoFile) {
alert('Please select a file first');
return;
}
const formData = new FormData();
formData.append('logo', currentLogoFile);
const statusDiv = document.getElementById('upload-status');
statusDiv.innerHTML = '<p class="text-blue-600">Uploading...</p>';
try {
const response = await fetch('/api/upload-logo', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
document.getElementById('logo-preview').classList.remove('hidden');
document.getElementById('logo-image').src = data.path + '?t=' + Date.now();
document.getElementById('upload-btn').disabled = true;
currentLogoFile = null;
document.getElementById('logo-filename').textContent = '';
document.getElementById('logo-upload').value = '';
} else {
const error = await response.json();
statusDiv.innerHTML = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
}
} catch (error) {
console.error('Upload error:', error);
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
}
}
// Customer Management
async function loadCustomers() {
try {
const response = await fetch('/api/customers');
customers = await response.json();
renderCustomers();
} catch (error) {
console.error('Error loading customers:', error);
alert('Error loading customers');
}
}
// --- 1. renderCustomers() — ERSETZE komplett ---
// Zeigt QBO-Status, Credit-Betrag und Downpayment-Button
function renderCustomers() {
const tbody = document.getElementById('customers-list');
tbody.innerHTML = customers.map(customer => {
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
let fullAddress = lines.join(', ');
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
// QBO Status
let qboCol;
if (customer.qbo_id) {
qboCol = `<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>`;
} else {
qboCol = `<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">QBO Export</button>`;
}
// Downpayment button (only if in QBO)
const downpayBtn = customer.qbo_id
? `<button onclick="openDownpaymentModal(${customer.id}, '${customer.qbo_id}', '${customer.name.replace(/'/g, "\\'")}')" class="text-emerald-600 hover:text-emerald-800">Downpayment</button>`
: '';
// Credit placeholder (loaded async)
const creditSpan = customer.qbo_id
? `<span id="customer-credit-${customer.id}" class="text-xs text-gray-400">...</span>`
: '';
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.name} ${qboCol} ${creditSpan}
</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 font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
${downpayBtn}
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>`;
}).join('');
// Load credits async for QBO customers
loadCustomerCredits();
}
// --- 2. Credits async laden ---
async function loadCustomerCredits() {
const qboCustomers = customers.filter(c => c.qbo_id);
for (const cust of qboCustomers) {
const span = document.getElementById(`customer-credit-${cust.id}`);
if (!span) continue;
try {
const res = await fetch(`/api/qbo/customer-credit/${cust.qbo_id}`);
const data = await res.json();
if (data.credit > 0) {
span.innerHTML = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Credit: $${data.credit.toFixed(2)}</span>`;
} else {
span.textContent = '';
}
} catch (e) {
span.textContent = '';
}
}
}
// --- 3. Downpayment Dialog ---
async function openDownpaymentModal(customerId, customerQboId, customerName) {
// Load QBO data if needed
let bankAccounts = [];
let paymentMethods = [];
try {
const [accRes, pmRes] = await Promise.all([
fetch('/api/qbo/accounts'),
fetch('/api/qbo/payment-methods')
]);
if (accRes.ok) bankAccounts = await accRes.json();
if (pmRes.ok) paymentMethods = await pmRes.json();
} catch (e) { console.error('Error loading QBO data:', e); }
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
const methods = filtered.length > 0 ? filtered : paymentMethods;
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
const today = new Date().toISOString().split('T')[0];
let modal = document.getElementById('downpayment-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'downpayment-modal';
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-lg mx-auto p-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-800">💰 Record Downpayment</h2>
<button onclick="closeDownpaymentModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p class="text-sm text-blue-800">
<strong>Customer:</strong> ${customerName}<br>
This will record an unapplied payment (credit) on the customer's QBO account.
</p>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Amount</label>
<input type="number" id="dp-amount" step="0.01" min="0.01" placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 text-lg font-semibold">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
<input type="date" id="dp-date" value="${today}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reference #</label>
<input type="text" id="dp-reference" placeholder="Check # or ACH ref"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select id="dp-method" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white">${methodOptions}</select>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
<select id="dp-deposit" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white">${accountOptions}</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeDownpaymentModal()" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
<button onclick="submitDownpayment(${customerId}, '${customerQboId}')" id="dp-submit-btn"
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
💰 Record Downpayment
</button>
</div>
</div>`;
modal.classList.add('active');
document.getElementById('dp-amount').focus();
}
function closeDownpaymentModal() {
const modal = document.getElementById('downpayment-modal');
if (modal) modal.classList.remove('active');
}
async function submitDownpayment(customerId, customerQboId) {
const amount = parseFloat(document.getElementById('dp-amount').value);
const date = document.getElementById('dp-date').value;
const ref = document.getElementById('dp-reference').value;
const methodSelect = document.getElementById('dp-method');
const depositSelect = document.getElementById('dp-deposit');
if (!amount || amount <= 0) { alert('Please enter an amount.'); return; }
if (!date || !methodSelect.value || !depositSelect.value) { alert('Please fill in all fields.'); return; }
if (!confirm(`Record downpayment of $${amount.toFixed(2)}?`)) return;
const btn = document.getElementById('dp-submit-btn');
btn.innerHTML = '⏳ Processing...';
btn.disabled = true;
if (typeof showSpinner === 'function') showSpinner('Recording downpayment in QBO...');
try {
const response = await fetch('/api/qbo/record-downpayment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: customerId,
customer_qbo_id: customerQboId,
amount: amount,
payment_date: date,
reference_number: ref,
payment_method_id: methodSelect.value,
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
deposit_to_account_id: depositSelect.value,
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
})
});
const result = await response.json();
if (response.ok) {
alert(`${result.message}`);
closeDownpaymentModal();
renderCustomers(); // Refresh credit display
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (e) {
alert('Network error.');
} finally {
btn.innerHTML = '💰 Record Downpayment';
btn.disabled = false;
if (typeof hideSpinner === 'function') hideSpinner();
}
}
function openCustomerModal(customerId = null) {
currentCustomerId = customerId;
const modal = document.getElementById('customer-modal');
const title = document.getElementById('customer-modal-title');
if (customerId) {
title.textContent = 'Edit Customer';
const customer = customers.find(c => c.id === customerId);
document.getElementById('customer-id').value = customer.id;
document.getElementById('customer-name').value = customer.name;
// Neue Felder befüllen
document.getElementById('customer-line1').value = customer.line1 || '';
document.getElementById('customer-line2').value = customer.line2 || '';
document.getElementById('customer-line3').value = customer.line3 || '';
document.getElementById('customer-line4').value = customer.line4 || '';
document.getElementById('customer-city').value = customer.city || '';
document.getElementById('customer-state').value = customer.state || '';
document.getElementById('customer-zip').value = customer.zip_code || '';
document.getElementById('customer-account').value = customer.account_number || '';
document.getElementById('customer-email').value = customer.email || '';
document.getElementById('customer-phone').value = customer.phone || '';
document.getElementById('customer-taxable').checked = customer.taxable !== false;
} else {
title.textContent = 'New Customer';
document.getElementById('customer-form').reset();
document.getElementById('customer-id').value = '';
document.getElementById('customer-taxable').checked = true;
}
modal.classList.add('active');
}
function closeCustomerModal() {
document.getElementById('customer-modal').classList.remove('active');
currentCustomerId = null;
}
async function handleCustomerSubmit(e) {
e.preventDefault();
const data = {
name: document.getElementById('customer-name').value,
// Neue Felder auslesen
line1: document.getElementById('customer-line1').value,
line2: document.getElementById('customer-line2').value,
line3: document.getElementById('customer-line3').value,
line4: document.getElementById('customer-line4').value,
city: document.getElementById('customer-city').value,
state: document.getElementById('customer-state').value.toUpperCase(),
zip_code: document.getElementById('customer-zip').value,
account_number: document.getElementById('customer-account').value,
email: document.getElementById('customer-email')?.value || '',
phone: document.getElementById('customer-phone')?.value || '',
phone2: '', // Erstmal leer lassen, falls kein Feld im Formular ist
taxable: document.getElementById('customer-taxable')?.checked ?? true
};
try {
const customerId = document.getElementById('customer-id').value;
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
const method = customerId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeCustomerModal();
loadCustomers();
} else {
alert('Error saving customer');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving customer');
}
}
async function editCustomer(id) {
openCustomerModal(id);
}
async function deleteCustomer(id) {
if (!confirm('Are you sure you want to delete this customer?')) return;
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
if (response.ok) {
loadCustomers();
} else {
alert('Error deleting customer');
}
} catch (error) {
console.error('Error:', error);
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
async function loadQuotes() {
try {
const response = await fetch('/api/quotes');
quotes = await response.json();
renderQuotes();
} catch (error) {
console.error('Error loading quotes:', error);
alert('Error loading quotes');
}
}
function renderQuotes() {
const tbody = document.getElementById('quotes-list');
tbody.innerHTML = quotes.map(quote => {
const total = quote.has_tbd ? `$${parseFloat(quote.total).toFixed(2)}*` : `$${parseFloat(quote.total).toFixed(2)}`;
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="viewQuotePDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="convertQuoteToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
<button onclick="editQuote(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteQuote(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`;
}).join('');
}
async function openQuoteModal(quoteId = null) {
currentQuoteId = quoteId;
const modal = document.getElementById('quote-modal');
const title = document.getElementById('quote-modal-title');
if (quoteId) {
title.textContent = 'Edit Quote';
const response = await fetch(`/api/quotes/${quoteId}`);
const data = await response.json();
// Set customer in Alpine component
const customer = customers.find(c => c.id === data.quote.customer_id);
if (customer) {
// Find the Alpine component and update it
const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
if (customerInput) {
// Trigger Alpine to update
customerInput.value = customer.name;
customerInput.dispatchEvent(new Event('input'));
// Set the values directly on the Alpine component
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
if (alpineData) {
alpineData.search = customer.name;
alpineData.selectedId = customer.id;
alpineData.selectedName = customer.name;
}
}
}
document.getElementById('quote-customer').value = data.quote.customer_id;
const dateOnly = data.quote.quote_date.split('T')[0];
document.getElementById('quote-date').value = dateOnly;
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
// Load items
document.getElementById('quote-items').innerHTML = '';
itemCounter = 0;
data.items.forEach(item => {
addQuoteItem(item);
});
updateQuoteTotals();
} else {
title.textContent = 'New Quote';
document.getElementById('quote-form').reset();
document.getElementById('quote-items').innerHTML = '';
itemCounter = 0;
setDefaultDate();
// Add one default item
addQuoteItem();
}
modal.classList.add('active');
}
function closeQuoteModal() {
document.getElementById('quote-modal').classList.remove('active');
currentQuoteId = null;
}
function addQuoteItem(item = null) {
const itemId = itemCounter++;
const itemsDiv = document.getElementById('quote-items');
const itemDiv = document.createElement('div');
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
itemDiv.id = `quote-item-${itemId}`;
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
// Preview Text logic
const previewQty = item ? item.quantity : '';
const previewAmount = item ? item.amount : '$0.00';
let previewDesc = 'New item';
if (item && item.description) {
const temp = document.createElement('div');
temp.innerHTML = item.description;
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
}
// Preview Type Logic (NEU)
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
itemDiv.innerHTML = `
<div class="flex items-center p-4">
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
<button type="button" onclick="moveQuoteItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
<button type="button" onclick="moveQuoteItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
</div>
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
</div>
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
</div>
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
<div class="grid grid-cols-12 gap-3 items-start">
<div class="col-span-1">
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-2">
<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="updateItemPreview(this.closest('[id^=quote-item-]'), ${itemId})">
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
</select>
</div>
<div class="col-span-4">
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
<div data-item="${itemId}" data-field="description" class="quote-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-3">
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
</div>
</div>
`;
itemsDiv.appendChild(itemDiv);
// Quill Init
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
const quill = new Quill(editorDiv, {
theme: 'snow',
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
});
if (item && item.description) quill.root.innerHTML = item.description;
quill.on('text-change', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
editorDiv.quillInstance = quill;
// Auto-calculate logic
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const rateInput = itemDiv.querySelector('[data-field="rate"]');
const amountInput = itemDiv.querySelector('[data-field="amount"]');
const calculateAmount = () => {
if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') {
const qty = parseFloat(qtyInput.value) || 0;
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
amountInput.addEventListener('input', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
}
function updateItemPreview(itemDiv, itemId) {
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const amountInput = itemDiv.querySelector('[data-field="amount"]');
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
const descPreview = itemDiv.querySelector('.item-desc-preview');
const amountPreview = itemDiv.querySelector('.item-amount-preview');
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
// NEU: Update Type Label
if (typePreview && typeInput) {
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
}
if (descPreview && editorDiv.quillInstance) {
const plainText = editorDiv.quillInstance.getText().trim();
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
descPreview.textContent = preview || 'New item';
}
}
function removeQuoteItem(itemId) {
document.getElementById(`quote-item-${itemId}`).remove();
updateQuoteTotals();
}
function moveQuoteItemUp(itemId) {
const item = document.getElementById(`quote-item-${itemId}`);
const prevItem = item.previousElementSibling;
if (prevItem) {
item.parentNode.insertBefore(item, prevItem);
updateQuoteTotals();
}
}
function moveQuoteItemDown(itemId) {
const item = document.getElementById(`quote-item-${itemId}`);
const nextItem = item.nextElementSibling;
if (nextItem) {
item.parentNode.insertBefore(nextItem, item);
updateQuoteTotals();
}
}
function updateQuoteTotals() {
const items = getQuoteItems();
const taxExempt = document.getElementById('quote-tax-exempt').checked;
let subtotal = 0;
let hasTbd = false;
items.forEach(item => {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
hasTbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
subtotal += amount;
}
});
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
const total = subtotal + taxAmount;
document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`;
document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`;
document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
}
function getQuoteItems() {
const items = [];
const itemDivs = document.querySelectorAll('#quote-items > div');
itemDivs.forEach(div => {
const descEditor = div.querySelector('.quote-item-description-editor');
const descriptionHTML = descEditor && descEditor.quillInstance
? descEditor.quillInstance.root.innerHTML
: '';
const item = {
quantity: div.querySelector('[data-field="quantity"]').value,
// NEU: ID holen
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
description: descriptionHTML,
rate: div.querySelector('[data-field="rate"]').value,
amount: div.querySelector('[data-field="amount"]').value
};
items.push(item);
});
return items;
}
async function handleQuoteSubmit(e) {
e.preventDefault();
const items = getQuoteItems();
if (items.length === 0) {
alert('Please add at least one item');
return;
}
const data = {
customer_id: parseInt(document.getElementById('quote-customer').value),
quote_date: document.getElementById('quote-date').value,
tax_exempt: document.getElementById('quote-tax-exempt').checked,
items: items
};
try {
const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
const method = currentQuoteId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeQuoteModal();
loadQuotes();
} else {
alert('Error saving quote');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving quote');
}
}
async function editQuote(id) {
await openQuoteModal(id);
}
async function deleteQuote(id) {
if (!confirm('Are you sure you want to delete this quote?')) return;
try {
const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' });
if (response.ok) {
loadQuotes();
} else {
alert('Error deleting quote');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting quote');
}
}
function viewQuotePDF(id) {
window.open(`/api/quotes/${id}/pdf`, '_blank');
}
async function convertQuoteToInvoice(quoteId) {
if (!confirm('Convert this quote to an invoice?')) return;
try {
const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, {
method: 'POST'
});
if (response.ok) {
const invoice = await response.json();
alert(`Invoice ${invoice.invoice_number} created successfully!`);
loadInvoices();
showTab('invoices');
} else {
const error = await response.json();
alert(error.error || 'Error converting quote to invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error converting quote to invoice');
}
}
// Invoice Management - Same accordion pattern
async function fetchNextInvoiceNumber() {
try {
const response = await fetch('/api/invoices/next-number');
const data = await response.json();
document.getElementById('invoice-number').value = data.next_number;
} catch (error) {
console.error('Error fetching next invoice number:', error);
}
}
async function loadInvoices() {
// Wird vom invoice-view.js Modul überschrieben (window.loadInvoices)
// Dieser Fallback lädt die Daten falls das Modul noch nicht geladen ist
try {
const response = await fetch('/api/invoices');
invoices = await response.json();
// Falls das Modul geladen ist, nutze dessen Renderer
if (window.invoiceView) {
window.invoiceView.renderInvoiceView();
} else {
renderInvoices();
}
} catch (error) {
console.error('Error loading invoices:', error);
}
}
function renderInvoices() {
if (window.invoiceView) {
window.invoiceView.renderInvoiceView();
return;
}
// Minimaler Fallback falls Modul nicht geladen
const tbody = document.getElementById('invoices-list');
tbody.innerHTML = invoices.map(invoice => `
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 text-sm font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 text-sm">Loading module...</td>
</tr>
`).join('');
}
async function openInvoiceModal(invoiceId = null) {
currentInvoiceId = invoiceId;
const modal = document.getElementById('invoice-modal');
const title = document.getElementById('invoice-modal-title');
if (invoiceId) {
title.textContent = 'Edit Invoice';
const response = await fetch(`/api/invoices/${invoiceId}`);
const data = await response.json();
// Set customer in Alpine component
const customer = customers.find(c => c.id === data.invoice.customer_id);
if (customer) {
const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
if (customerInput) {
customerInput.value = customer.name;
customerInput.dispatchEvent(new Event('input'));
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
if (alpineData) {
alpineData.search = customer.name;
alpineData.selectedId = customer.id;
alpineData.selectedName = customer.name;
}
}
}
document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
document.getElementById('invoice-customer').value = data.invoice.customer_id;
const dateOnly = data.invoice.invoice_date.split('T')[0];
document.getElementById('invoice-date').value = dateOnly;
document.getElementById('invoice-terms').value = data.invoice.terms;
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
// Scheduled Send Date
const sendDateEl = document.getElementById('invoice-send-date');
if (sendDateEl) {
sendDateEl.value = data.invoice.scheduled_send_date
? data.invoice.scheduled_send_date.split('T')[0]
: '';
}
// Load items
document.getElementById('invoice-items').innerHTML = '';
itemCounter = 0;
data.items.forEach(item => {
addInvoiceItem(item);
});
updateInvoiceTotals();
} else {
title.textContent = 'New Invoice';
document.getElementById('invoice-form').reset();
document.getElementById('invoice-items').innerHTML = '';
document.getElementById('invoice-terms').value = 'Net 30';
document.getElementById('invoice-number').value = ''; // Leer lassen!
document.getElementById('invoice-send-date').value = '';
itemCounter = 0;
setDefaultDate();
// KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO
// Add one default item
addInvoiceItem();
}
modal.classList.add('active');
}
function closeInvoiceModal() {
document.getElementById('invoice-modal').classList.remove('active');
currentInvoiceId = null;
}
function addInvoiceItem(item = null) {
const itemId = itemCounter++;
const itemsDiv = document.getElementById('invoice-items');
const itemDiv = document.createElement('div');
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
itemDiv.id = `invoice-item-${itemId}`;
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
// Preview Text logic
const previewQty = item ? item.quantity : '';
const previewAmount = item ? item.amount : '$0.00';
let previewDesc = 'New item';
if (item && item.description) {
const temp = document.createElement('div');
temp.innerHTML = item.description;
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
}
// Preview Type Logic
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
itemDiv.innerHTML = `
<div class="flex items-center p-4">
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
<button type="button" onclick="moveInvoiceItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
<button type="button" onclick="moveInvoiceItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
</div>
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
</div>
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
</div>
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
<div class="grid grid-cols-12 gap-3 items-start">
<div class="col-span-1">
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-2">
<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="handleTypeChange(this, ${itemId})">
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
</select>
</div>
<div class="col-span-4">
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
<div data-item="${itemId}" data-field="description" class="invoice-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="col-span-3">
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
</div>
</div>
</div>
`;
itemsDiv.appendChild(itemDiv);
// Quill Init (wie vorher)
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
const quill = new Quill(editorDiv, {
theme: 'snow',
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
});
if (item && item.description) quill.root.innerHTML = item.description;
quill.on('text-change', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
editorDiv.quillInstance = quill;
// Auto-calculate logic (wie vorher)
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const rateInput = itemDiv.querySelector('[data-field="rate"]');
const amountInput = itemDiv.querySelector('[data-field="amount"]');
const calculateAmount = () => {
if (qtyInput.value && rateInput.value) {
const qty = parseFloat(qtyInput.value) || 0;
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
amountInput.addEventListener('input', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
}
function updateInvoiceItemPreview(itemDiv, itemId) {
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const amountInput = itemDiv.querySelector('[data-field="amount"]');
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
const descPreview = itemDiv.querySelector('.item-desc-preview');
const amountPreview = itemDiv.querySelector('.item-amount-preview');
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
// NEU: Update Type Label
if (typePreview && typeInput) {
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
}
if (descPreview && editorDiv.quillInstance) {
const plainText = editorDiv.quillInstance.getText().trim();
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
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) {
document.getElementById(`invoice-item-${itemId}`).remove();
updateInvoiceTotals();
}
function moveInvoiceItemUp(itemId) {
const item = document.getElementById(`invoice-item-${itemId}`);
const prevItem = item.previousElementSibling;
if (prevItem) {
item.parentNode.insertBefore(item, prevItem);
updateInvoiceTotals();
}
}
function moveInvoiceItemDown(itemId) {
const item = document.getElementById(`invoice-item-${itemId}`);
const nextItem = item.nextElementSibling;
if (nextItem) {
item.parentNode.insertBefore(nextItem, item);
updateInvoiceTotals();
}
}
function updateInvoiceTotals() {
const items = getInvoiceItems();
const taxExempt = document.getElementById('invoice-tax-exempt').checked;
let subtotal = 0;
items.forEach(item => {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
subtotal += amount;
});
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
const total = subtotal + taxAmount;
document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
}
function getInvoiceItems() {
const items = [];
const itemDivs = document.querySelectorAll('#invoice-items > div');
itemDivs.forEach(div => {
const descEditor = div.querySelector('.invoice-item-description-editor');
const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : '';
const item = {
quantity: div.querySelector('[data-field="quantity"]').value,
// NEU: ID holen
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
description: descriptionHTML,
rate: div.querySelector('[data-field="rate"]').value,
amount: div.querySelector('[data-field="amount"]').value
};
items.push(item);
});
return items;
}
async function handleInvoiceSubmit(e) {
e.preventDefault();
const items = getInvoiceItems();
if (items.length === 0) {
alert('Please add at least one item');
return;
}
const invoiceNumber = document.getElementById('invoice-number').value.trim();
// Invoice Number ist jetzt OPTIONAL
// Wenn angegeben, muss sie numerisch sein
if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) {
alert('Invalid invoice number. Must be a numeric value or left empty.');
return;
}
const data = {
invoice_number: invoiceNumber || null, // null wenn leer
customer_id: parseInt(document.getElementById('invoice-customer').value),
invoice_date: document.getElementById('invoice-date').value,
terms: document.getElementById('invoice-terms').value,
auth_code: document.getElementById('invoice-authorization').value,
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
scheduled_send_date: document.getElementById('invoice-send-date').value || null,
items: items
};
try {
let response;
if (currentInvoiceId) {
response = await fetch(`/api/invoices/${currentInvoiceId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (response.ok) {
closeInvoiceModal();
loadInvoices();
} else {
alert(result.error || 'Error saving invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving invoice');
}
}
async function editInvoice(id) {
await openInvoiceModal(id);
}
async function deleteInvoice(id) {
if (!confirm('Are you sure you want to delete this invoice?')) return;
try {
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
if (response.ok) {
loadInvoices();
} else {
alert('Error deleting invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting invoice');
}
}
function viewInvoicePDF(id) {
if (window.invoiceView) {
window.invoiceView.viewPDF(id);
} else {
window.open(`/api/invoices/${id}/pdf`, '_blank');
}
}
async function checkQboOverdue() {
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
const resultDiv = document.getElementById('qbo-result');
const tbody = document.getElementById('qbo-result-list');
// UI Loading State
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Connecting to QBO...';
btn.disabled = true;
resultDiv.classList.add('hidden');
tbody.innerHTML = '';
try {
const response = await fetch('/api/qbo/overdue');
const invoices = await response.json();
if (response.ok) {
resultDiv.classList.remove('hidden');
if (invoices.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
} else {
tbody.innerHTML = invoices.map(inv => `
<tr>
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
</tr>
`).join('');
}
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
} else {
throw new Error(invoices.error || 'Unknown error');
}
} catch (error) {
console.error('QBO Test Error:', error);
alert('❌ Connection Test Failed: ' + error.message);
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
resultDiv.classList.remove('hidden');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
async function importFromQBO() {
if (!confirm(
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
'• Bereits importierte werden übersprungen\n' +
'• Nur Kunden die lokal verknüpft sind\n\n' +
'Fortfahren?'
)) return;
const btn = document.querySelector('button[onclick="importFromQBO()"]');
const resultDiv = document.getElementById('qbo-import-result');
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Importiere aus QBO...';
btn.disabled = true;
resultDiv.classList.add('hidden');
try {
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
const result = await response.json();
resultDiv.classList.remove('hidden');
if (response.ok) {
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
html += `<ul class="text-sm text-gray-700 space-y-1">`;
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
if (result.skipped > 0) {
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
}
if (result.skippedNoCustomer > 0) {
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
}
html += `</ul></div>`;
resultDiv.innerHTML = html;
// Invoice-Liste aktualisieren
if (result.imported > 0) {
loadInvoices();
}
} else {
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
<p class="text-sm text-red-600 mt-1">${result.error}</p>
</div>`;
}
} catch (error) {
console.error('Import Error:', error);
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-600">Netzwerkfehler beim Import.</p>
</div>`;
} finally {
btn.innerHTML = originalText;
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;