refactoring

This commit is contained in:
2026-03-04 17:03:51 -06:00
parent 27ecafea5f
commit e333628f1c
16 changed files with 1222 additions and 1296 deletions

View File

@@ -0,0 +1,405 @@
// customer-view.js — ES Module
// Customer list with filtering, QBO status, email, modal with contact/remarks
let customers = [];
let filterName = localStorage.getItem('cust_filterName') || '';
let filterQbo = localStorage.getItem('cust_filterQbo') || 'all'; // all | qbo | local
// ============================================================
// Data
// ============================================================
export async function loadCustomers() {
try {
const response = await fetch('/api/customers');
customers = await response.json();
// Backward compat: quote/invoice modals use global 'customers' variable
window.customers = customers;
renderCustomerView();
} catch (error) {
console.error('Error loading customers:', error);
}
}
export function getCustomers() { return customers; }
// ============================================================
// Filter
// ============================================================
function getFilteredCustomers() {
let f = [...customers];
if (filterName.trim()) {
const s = filterName.toLowerCase();
f = f.filter(c => (c.name || '').toLowerCase().includes(s) ||
(c.contact || '').toLowerCase().includes(s) ||
(c.email || '').toLowerCase().includes(s));
}
if (filterQbo === 'qbo') f = f.filter(c => c.qbo_id);
else if (filterQbo === 'local') f = f.filter(c => !c.qbo_id);
f.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return f;
}
function saveSettings() {
localStorage.setItem('cust_filterName', filterName);
localStorage.setItem('cust_filterQbo', filterQbo);
}
// ============================================================
// Render
// ============================================================
export function renderCustomerView() {
const tbody = document.getElementById('customers-list');
if (!tbody) return;
const filtered = getFilteredCustomers();
tbody.innerHTML = filtered.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="window.customerView.exportToQbo(${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="Export customer to QBO">QBO Export</button>`;
// Contact
const contactDisplay = customer.contact
? `<span class="text-xs text-gray-400 ml-1">(${customer.contact})</span>`
: '';
// Email
const emailDisplay = customer.email
? `<a href="mailto:${customer.email}" class="text-blue-600 hover:text-blue-800 text-sm">${customer.email}</a>`
: '<span class="text-gray-300 text-sm">—</span>';
return `
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.name} ${qboStatus} ${contactDisplay}
</td>
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">${fullAddress || '—'}</td>
<td class="px-4 py-3 text-sm">${emailDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '—'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="window.customerView.edit(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="window.customerView.remove(${customer.id})" class="text-red-600 hover:text-red-900">Del</button>
</td>
</tr>`;
}).join('');
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No customers found.</td></tr>`;
}
const countEl = document.getElementById('customer-count');
if (countEl) countEl.textContent = filtered.length;
updateFilterButtons();
}
function updateFilterButtons() {
document.querySelectorAll('[data-qbo-filter]').forEach(btn => {
const s = btn.getAttribute('data-qbo-filter');
btn.classList.toggle('bg-blue-600', s === filterQbo);
btn.classList.toggle('text-white', s === filterQbo);
btn.classList.toggle('bg-white', s !== filterQbo);
btn.classList.toggle('text-gray-600', s !== filterQbo);
});
}
// ============================================================
// Toolbar
// ============================================================
export function injectToolbar() {
const c = document.getElementById('customer-toolbar');
if (!c) return;
c.innerHTML = `
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Search:</label>
<input type="text" id="customer-filter-name" placeholder="Name, contact, email..."
value="${filterName}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-56 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
<button data-qbo-filter="all" onclick="window.customerView.setQboFilter('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">All</button>
<button data-qbo-filter="qbo" onclick="window.customerView.setQboFilter('qbo')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">In QBO</button>
<button data-qbo-filter="local" onclick="window.customerView.setQboFilter('local')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors">Local Only</button>
</div>
<div class="ml-auto flex items-center gap-4">
<span class="text-sm text-gray-500">
<span id="customer-count" class="font-semibold text-gray-700">0</span> customers
</span>
<button onclick="window.customerView.openModal()"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">+ New Customer</button>
</div>
</div>`;
updateFilterButtons();
document.getElementById('customer-filter-name').addEventListener('input', (e) => {
filterName = e.target.value; saveSettings(); renderCustomerView();
});
}
// ============================================================
// Modal
// ============================================================
function ensureModalElement() {
let modal = document.getElementById('customer-modal-v2');
if (modal) return;
modal = document.createElement('div');
modal.id = 'customer-modal-v2';
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);
}
export function openModal(customerId = null) {
ensureModalElement();
const modal = document.getElementById('customer-modal-v2');
const isEdit = !!customerId;
const customer = isEdit ? customers.find(c => c.id === customerId) : null;
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900">${isEdit ? 'Edit Customer' : 'New Customer'}</h3>
<button onclick="window.customerView.closeModal()" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="customer-form-v2" class="space-y-4">
<input type="hidden" id="cf-id" value="${customer?.id || ''}">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name *</label>
<input type="text" id="cf-name" required value="${customer?.name || ''}"
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">Contact Person</label>
<input type="text" id="cf-contact" value="${customer?.contact || ''}" placeholder="First Last"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="space-y-2 pt-2">
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
<input type="text" id="cf-line1" placeholder="Line 1 (Street / PO Box)" value="${customer?.line1 || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<input type="text" id="cf-line2" placeholder="Line 2" value="${customer?.line2 || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<div class="grid grid-cols-2 gap-4">
<input type="text" id="cf-line3" placeholder="Line 3" value="${customer?.line3 || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<input type="text" id="cf-line4" placeholder="Line 4" value="${customer?.line4 || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" id="cf-city" value="${customer?.city || ''}"
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">State</label>
<input type="text" id="cf-state" maxlength="2" placeholder="TX" value="${customer?.state || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
<input type="text" id="cf-zip" value="${customer?.zip_code || ''}"
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">Account #</label>
<input type="text" id="cf-account" value="${customer?.account_number || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="flex items-end pb-2">
<div class="flex items-center">
<input type="checkbox" id="cf-taxable" ${customer?.taxable !== false ? 'checked' : ''}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="cf-taxable" class="ml-2 text-sm text-gray-700">Taxable</label>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="cf-email" value="${customer?.email || ''}"
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">Phone</label>
<input type="tel" id="cf-phone" value="${customer?.phone || ''}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
<textarea id="cf-remarks" rows="3" placeholder="Internal notes about this customer..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">${customer?.remarks || ''}</textarea>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="window.customerView.closeModal()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-semibold">Save Customer</button>
</div>
</form>
</div>`;
modal.classList.add('active');
document.getElementById('customer-form-v2').addEventListener('submit', handleSubmit);
}
export function closeModal() {
const modal = document.getElementById('customer-modal-v2');
if (modal) modal.classList.remove('active');
}
// ============================================================
// Submit
// ============================================================
async function handleSubmit(e) {
e.preventDefault();
const data = {
name: document.getElementById('cf-name').value,
contact: document.getElementById('cf-contact').value || null,
line1: document.getElementById('cf-line1').value || null,
line2: document.getElementById('cf-line2').value || null,
line3: document.getElementById('cf-line3').value || null,
line4: document.getElementById('cf-line4').value || null,
city: document.getElementById('cf-city').value || null,
state: (document.getElementById('cf-state').value || '').toUpperCase() || null,
zip_code: document.getElementById('cf-zip').value || null,
account_number: document.getElementById('cf-account').value || null,
email: document.getElementById('cf-email').value || null,
phone: document.getElementById('cf-phone').value || null,
phone2: null,
taxable: document.getElementById('cf-taxable').checked,
remarks: document.getElementById('cf-remarks').value || null
};
const customerId = document.getElementById('cf-id').value;
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
const method = customerId ? 'PUT' : 'POST';
if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...');
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeModal();
await loadCustomers();
} else {
const err = await response.json();
alert(`Error: ${err.error || 'Failed to save customer'}`);
}
} catch (error) {
console.error('Error saving customer:', error);
alert('Network error saving customer.');
} finally {
if (typeof hideSpinner === 'function') hideSpinner();
}
}
// ============================================================
// Actions
// ============================================================
export function edit(id) { openModal(id); }
export async function remove(id) {
const customer = customers.find(c => c.id === id);
if (!customer) return;
let msg = `Delete customer "${customer.name}"?`;
if (customer.qbo_id) msg += '\nThis will also deactivate the customer in QBO.';
if (!confirm(msg)) return;
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
if (response.ok) await loadCustomers();
else {
const err = await response.json();
alert(`Error: ${err.error || 'Failed to delete'}`);
}
} catch (error) {
console.error('Error:', error);
alert('Network error.');
}
}
export async function exportToQbo(id) {
const customer = customers.find(c => c.id === id);
if (!customer) return;
if (!confirm(`Export "${customer.name}" to QuickBooks Online?`)) return;
if (typeof showSpinner === 'function') showSpinner('Exporting customer to QBO...');
try {
const response = await fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ "${result.name}" exported to QBO (ID: ${result.qbo_id}).`);
await loadCustomers();
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (error) {
alert('Network error.');
} finally {
if (typeof hideSpinner === 'function') hideSpinner();
}
}
export function setQboFilter(val) {
filterQbo = val;
saveSettings();
renderCustomerView();
}
// ============================================================
// Expose
// ============================================================
window.customerView = {
loadCustomers, renderCustomerView, getCustomers,
openModal, closeModal, edit, remove, exportToQbo, setQboFilter
};
// Make customers available globally for other modules (quote/invoice dropdowns)
window.getCustomers = () => customers;

View File

@@ -0,0 +1,515 @@
// invoice-view.js — ES Module v5
// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button
let invoices = [];
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
const OVERDUE_DAYS = 30;
// ============================================================
// Date Helpers
// ============================================================
function parseLocalDate(dateStr) {
if (!dateStr) return null;
const str = String(dateStr).split('T')[0];
const parts = str.split('-');
if (parts.length !== 3) return null;
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
function formatDate(date) {
if (!date) return '—';
const d = parseLocalDate(date);
if (!d) return '—';
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
}
function formatDateTime(isoStr) {
if (!isoStr) return 'Never';
const d = new Date(isoStr);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function daysSince(date) {
const d = parseLocalDate(date);
if (!d) return 0;
const now = new Date(); now.setHours(0, 0, 0, 0);
return Math.floor((now - d) / 86400000);
}
function getWeekNumber(date) {
const d = parseLocalDate(date);
if (!d) return { year: 0, week: 0 };
const copy = new Date(d.getTime());
copy.setHours(0, 0, 0, 0);
copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7));
const week1 = new Date(copy.getFullYear(), 0, 4);
return {
year: copy.getFullYear(),
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
};
}
function getWeekRange(year, weekNum) {
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7;
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
return { start: fmt(monday), end: fmt(sunday) };
}
function getMonthName(i) {
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
}
function isPaid(inv) { return !!inv.paid_date; }
function isDraft(inv) { return !inv.qbo_id; }
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
function isPartiallyPaid(inv) {
const amountPaid = parseFloat(inv.amount_paid) || 0;
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
return !inv.paid_date && amountPaid > 0 && balance > 0;
}
function isSent(inv) {
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent';
}
function isOpen(inv) {
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent';
}
function saveSettings() {
localStorage.setItem('inv_filterStatus', filterStatus);
localStorage.setItem('inv_groupBy', groupBy);
localStorage.setItem('inv_filterCustomer', filterCustomer);
}
// ============================================================
// Data
// ============================================================
export async function loadInvoices() {
try {
const response = await fetch('/api/invoices');
invoices = await response.json();
renderInvoiceView();
loadLastSync();
} catch (error) { console.error('Error loading invoices:', error); }
}
async function loadLastSync() {
try {
const res = await fetch('/api/qbo/last-sync');
const data = await res.json();
const el = document.getElementById('last-sync-time');
if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced';
} catch (e) { /* ignore */ }
}
export function getInvoicesData() { return invoices; }
// ============================================================
// Filter / Sort / Group
// ============================================================
function getFilteredInvoices() {
let f = [...invoices];
if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i));
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i));
else if (filterStatus === 'sent') f = f.filter(i => isSent(i));
else if (filterStatus === 'open') f = f.filter(i => isOpen(i));
if (filterCustomer.trim()) {
const s = filterCustomer.toLowerCase();
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
}
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
return f;
}
// Effective amount: for unpaid/partial show balance, for paid show total
function effectiveAmount(inv) {
const total = parseFloat(inv.total) || 0;
const amountPaid = parseFloat(inv.amount_paid) || 0;
if (inv.paid_date) return total; // Paid → show full total
if (amountPaid > 0) return total - amountPaid; // Partial → show balance
return total; // Unpaid → show total
}
function groupInvoices(filtered) {
if (groupBy === 'none') return null;
const groups = new Map();
filtered.forEach(inv => {
const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label;
if (groupBy === 'week') {
const wk = getWeekNumber(inv.invoice_date);
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
const range = getWeekRange(wk.year, wk.week);
label = `Week ${wk.week}, ${wk.year} (${range.start} ${range.end})`;
} else {
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
}
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
const g = groups.get(key);
g.invoices.push(inv);
g.total += effectiveAmount(inv);
});
for (const g of groups.values()) {
g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
}
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
}
// ============================================================
// Render
// ============================================================
function renderInvoiceRow(invoice) {
const hasQbo = !!invoice.qbo_id;
const paid = isPaid(invoice);
const overdue = isOverdue(invoice);
const draft = isDraft(invoice);
const amountPaid = parseFloat(invoice.amount_paid) || 0;
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
const partial = isPartiallyPaid(invoice);
const invNumDisplay = invoice.invoice_number
? invoice.invoice_number
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
// Status Badge (left side, next to invoice number)
let statusBadge = '';
if (paid && invoice.payment_status === 'Deposited') {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
} else if (paid) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
} else if (partial) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial $${amountPaid.toFixed(2)}</span>`;
} else if (overdue) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
} else if (hasQbo && invoice.email_status === 'sent') {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
} else if (hasQbo) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
}
// Send Date
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date(); today.setHours(0, 0, 0, 0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date);
if (!paid && invoice.email_status !== 'sent') {
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
}
}
// Amount column — show balance when partially paid
let amountDisplay;
if (partial) {
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
} else {
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
}
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
const customerHasQbo = !!invoice.customer_qbo_id;
let qboBtn;
if (hasQbo) {
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
} else if (!customerHasQbo) {
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
} else {
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
}
const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
// Payment button — only for QBO invoices that are not fully paid
let paidBtn = '';
if (!paid && hasQbo) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
}
// Mark Sent button (right side) — only when open, not paid/partial
let sendBtn = '';
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
}
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
return `
<tr class="${rowClass}">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn}
</td>
</tr>`;
}
function renderGroupHeader(label) {
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
}
function renderGroupFooter(total, count) {
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
}
export function renderInvoiceView() {
const tbody = document.getElementById('invoices-list');
if (!tbody) return;
const filtered = getFilteredInvoices();
const groups = groupInvoices(filtered);
let html = '', grandTotal = 0;
if (groups) {
for (const [, group] of groups) {
html += renderGroupHeader(group.label);
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
html += renderGroupFooter(group.total, group.invoices.length);
grandTotal += group.total;
}
if (groups.size > 1) {
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
} else {
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += effectiveAmount(inv); });
if (filtered.length > 0) {
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
}
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
tbody.innerHTML = html;
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
updateStatusButtons();
}
function updateStatusButtons() {
document.querySelectorAll('[data-status-filter]').forEach(btn => {
const s = btn.getAttribute('data-status-filter');
btn.classList.toggle('bg-blue-600', s === filterStatus);
btn.classList.toggle('text-white', s === filterStatus);
btn.classList.toggle('bg-white', s !== filterStatus);
btn.classList.toggle('text-gray-600', s !== filterStatus);
});
const counts = {
unpaid: invoices.filter(i => !isPaid(i)).length,
open: invoices.filter(i => isOpen(i)).length,
sent: invoices.filter(i => isSent(i)).length,
partial: invoices.filter(i => isPartiallyPaid(i)).length,
paid: invoices.filter(i => isPaid(i)).length,
overdue: invoices.filter(i => isOverdue(i)).length
};
['unpaid', 'open', 'sent', 'partial', 'paid', 'overdue'].forEach(key => {
const el = document.getElementById(`${key}-badge`);
if (el) {
el.textContent = counts[key];
el.classList.toggle('hidden', counts[key] === 0);
}
});
}
// ============================================================
// Toolbar
// ============================================================
export function injectToolbar() {
const c = document.getElementById('invoice-toolbar');
if (!c) return;
c.innerHTML = `
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Unpaid
<span id="unpaid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="open" onclick="window.invoiceView.setStatus('open')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Open
<span id="open-badge" class="hidden absolute -top-1.5 -right-1.5 bg-orange-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="sent" onclick="window.invoiceView.setStatus('sent')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Sent
<span id="sent-badge" class="hidden absolute -top-1.5 -right-1.5 bg-cyan-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid
<span id="paid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Customer:</label>
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Group:</label>
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
</select>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
⟳ Sync from QBO
</button>
</div>
<div class="ml-auto flex items-center gap-4">
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
<span class="text-sm text-gray-500">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
</span>
</div>
</div>`;
updateStatusButtons();
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
});
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
groupBy = e.target.value; saveSettings(); renderInvoiceView();
});
}
// ============================================================
// Actions
// ============================================================
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
export async function exportToQBO(id) {
if (!confirm('Export invoice to QuickBooks Online?')) return;
if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...');
try {
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const d = await r.json();
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function syncToQBO(id) {
if (!confirm('Sync changes to QuickBooks Online?')) return;
if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...');
try {
const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' });
const d = await r.json();
if (r.ok) { alert(`${d.message}`); loadInvoices(); }
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function syncFromQBO() {
if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...');
try {
const r = await fetch('/api/qbo/sync-payments', { method: 'POST' });
const d = await r.json();
if (r.ok) {
alert(`${d.message}`);
loadInvoices();
} else {
alert(`${d.error}`);
}
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function setEmailStatus(id, status) {
const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent';
if (!confirm(`${label}?`)) return;
if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`);
try {
const r = await fetch(`/api/invoices/${id}/email-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const d = await r.json();
if (r.ok) loadInvoices();
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function resetQbo(id) {
if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return;
try {
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
} catch (e) { console.error(e); }
}
export async function markPaid(id) {
try {
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
});
if (r.ok) loadInvoices();
} catch (e) { console.error(e); }
}
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
export async function remove(id) {
if (!confirm('Delete this invoice?')) return;
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
catch (e) { console.error(e); }
}
// ============================================================
// Expose
// ============================================================
window.invoiceView = {
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
loadInvoices, renderInvoiceView, setStatus
};

View File

@@ -0,0 +1,101 @@
/**
* quote-view.js — Quote list rendering and actions
* Analog to invoice-view.js
*/
import { formatDate } from '../utils/helpers.js';
let quotes = [];
export async function loadQuotes() {
try {
const response = await fetch('/api/quotes');
quotes = await response.json();
renderQuotes();
} catch (error) {
console.error('Error loading quotes:', error);
}
}
export function getQuotesData() {
return quotes;
}
export function renderQuotes() {
const tbody = document.getElementById('quotes-list');
if (!tbody) return;
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="window.quoteView.viewPDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="window.quoteView.convertToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
<button onclick="window.quoteView.edit(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="window.quoteView.remove(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`;
}).join('');
if (quotes.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No quotes found.</td></tr>`;
}
}
export function viewPDF(id) {
window.open(`/api/quotes/${id}/pdf`, '_blank');
}
export async function edit(id) {
if (typeof window.openQuoteModal === 'function') {
await window.openQuoteModal(id);
}
}
export async function remove(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');
}
}
export async function convertToInvoice(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 || '(Draft)'} created successfully!`);
if (window.invoiceView) window.invoiceView.loadInvoices();
if (typeof window.showTab === 'function') window.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');
}
}
// Expose for onclick handlers
window.quoteView = {
loadQuotes,
renderQuotes,
viewPDF,
edit,
remove,
convertToInvoice
};

View File

@@ -0,0 +1,182 @@
/**
* settings-view.js — Logo upload, QBO import, QBO connection test
* Extracted from app.js
*/
let currentLogoFile = null;
export 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);
}
}
export 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>';
}
}
export function initSettingsView() {
const logoUpload = document.getElementById('logo-upload');
if (logoUpload) {
logoUpload.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;
}
});
}
}
export async function checkQboOverdue() {
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
const resultDiv = document.getElementById('qbo-result');
const tbody = document.getElementById('qbo-result-list');
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;
}
}
export 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;
if (result.imported > 0 && window.invoiceView) {
window.invoiceView.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;
}
}
// Expose for onclick handlers
window.uploadLogo = uploadLogo;
window.checkQboOverdue = checkQboOverdue;
window.importFromQBO = importFromQBO;