1521 lines
63 KiB
JavaScript
1521 lines
63 KiB
JavaScript
// 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 und Export-Button in der Kundenliste
|
||
// =====================================================
|
||
|
||
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
|
||
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 `
|
||
<tr>
|
||
<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 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>
|
||
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// --- 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; |