refactoring
This commit is contained in:
85
public/js/app.js
Normal file
85
public/js/app.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* app.js — Application Bootstrap
|
||||
*
|
||||
* This is the main entry point. All business logic has been moved to modules:
|
||||
* - js/views/quote-view.js → Quote list
|
||||
* - js/views/invoice-view.js → Invoice list (existing)
|
||||
* - js/views/settings-view.js → Logo, QBO import/test
|
||||
* - js/modals/quote-modal.js → Quote create/edit
|
||||
* - js/modals/invoice-modal.js → Invoice create/edit
|
||||
* - js/modals/payment-modal.js → Payment recording (existing)
|
||||
* - js/components/customer-search.js → Alpine dropdown
|
||||
* - js/utils/item-editor.js → Shared accordion item editor
|
||||
* - js/utils/helpers.js → formatDate, spinner
|
||||
* - js/utils/api.js → API wrapper (existing)
|
||||
*/
|
||||
|
||||
// --- Imports ---
|
||||
import { loadQuotes } from './views/quote-view.js';
|
||||
import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js';
|
||||
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js';
|
||||
import { checkCurrentLogo, initSettingsView } from './views/settings-view.js';
|
||||
import { initQuoteModal } from './modals/quote-modal.js';
|
||||
import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
|
||||
import './modals/payment-modal.js';
|
||||
import { setDefaultDate } from './utils/helpers.js';
|
||||
|
||||
// ============================================================
|
||||
// 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');
|
||||
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
|
||||
if (tabName === 'quotes') {
|
||||
loadQuotes();
|
||||
} else if (tabName === 'invoices') {
|
||||
injectInvoiceToolbar();
|
||||
loadInvoices();
|
||||
} else if (tabName === 'customers') {
|
||||
injectCustomerToolbar();
|
||||
renderCustomerView();
|
||||
} else if (tabName === 'settings') {
|
||||
checkCurrentLogo();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load shared data
|
||||
loadCustomers();
|
||||
loadLaborRate();
|
||||
setDefaultDate();
|
||||
|
||||
// Init modals (wire up form handlers)
|
||||
initQuoteModal();
|
||||
initInvoiceModal();
|
||||
initSettingsView();
|
||||
|
||||
// Restore saved tab (or default to quotes)
|
||||
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||
showTab(savedTab);
|
||||
|
||||
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||
if (window.location.hash) {
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||
showTab(hashTab);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Expose to HTML onclick handlers
|
||||
// ============================================================
|
||||
|
||||
window.showTab = showTab;
|
||||
67
public/js/components/customer-search.js
Normal file
67
public/js/components/customer-search.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* customer-search.js — Alpine.js Customer Search Component
|
||||
* Used in Quote and Invoice modals for customer dropdown
|
||||
*/
|
||||
|
||||
function customerSearch(type) {
|
||||
return {
|
||||
search: '',
|
||||
selectedId: '',
|
||||
selectedName: '',
|
||||
open: false,
|
||||
highlighted: 0,
|
||||
|
||||
get filteredCustomers() {
|
||||
const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
|
||||
if (!this.search) {
|
||||
return allCustomers;
|
||||
}
|
||||
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return allCustomers.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 globally available for Alpine x-data
|
||||
window.customerSearch = customerSearch;
|
||||
221
public/js/modals/invoice-modal.js
Normal file
221
public/js/modals/invoice-modal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* invoice-modal.js — Invoice create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoiceId = null;
|
||||
let qboLaborRate = null;
|
||||
|
||||
/**
|
||||
* Load labor rate from QBO (called once at startup)
|
||||
*/
|
||||
export 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.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getLaborRate() {
|
||||
return qboLaborRate;
|
||||
}
|
||||
|
||||
export async function openInvoiceModal(invoiceId = null) {
|
||||
currentInvoiceId = invoiceId;
|
||||
|
||||
if (invoiceId) {
|
||||
await loadInvoiceForEdit(invoiceId);
|
||||
} else {
|
||||
prepareNewInvoice();
|
||||
}
|
||||
|
||||
document.getElementById('invoice-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeInvoiceModal() {
|
||||
document.getElementById('invoice-modal').classList.remove('active');
|
||||
currentInvoiceId = null;
|
||||
}
|
||||
|
||||
async function loadInvoiceForEdit(invoiceId) {
|
||||
document.getElementById('invoice-modal-title').textContent = 'Edit Invoice';
|
||||
|
||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.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;
|
||||
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||
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;
|
||||
document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
|
||||
|
||||
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 using shared editor
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('invoice-items', {
|
||||
item,
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
});
|
||||
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function prepareNewInvoice() {
|
||||
document.getElementById('invoice-modal-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 = '';
|
||||
document.getElementById('invoice-send-date').value = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('invoice-items', {
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
}
|
||||
|
||||
export function addInvoiceItem(item = null) {
|
||||
addItem('invoice-items', {
|
||||
item,
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInvoiceTotals() {
|
||||
const items = getItems('invoice-items');
|
||||
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';
|
||||
}
|
||||
|
||||
export async function handleInvoiceSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
invoice_number: document.getElementById('invoice-number').value || null,
|
||||
customer_id: 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,
|
||||
bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
|
||||
items: getItems('invoice-items')
|
||||
};
|
||||
|
||||
if (!data.customer_id) {
|
||||
alert('Please select a customer.');
|
||||
return;
|
||||
}
|
||||
if (!data.items || data.items.length === 0) {
|
||||
alert('Please add at least one item.');
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceId = currentInvoiceId;
|
||||
const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
|
||||
const method = invoiceId ? 'PUT' : 'POST';
|
||||
|
||||
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
|
||||
if (result.qbo_doc_number) {
|
||||
console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
|
||||
} else if (result.qbo_synced) {
|
||||
console.log('✅ Invoice saved & synced to QBO');
|
||||
} else {
|
||||
console.log('✅ Invoice saved locally (QBO sync pending)');
|
||||
}
|
||||
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving invoice');
|
||||
} finally {
|
||||
hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initInvoiceModal() {
|
||||
const form = document.getElementById('invoice-form');
|
||||
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openInvoiceModal = openInvoiceModal;
|
||||
window.closeInvoiceModal = closeInvoiceModal;
|
||||
window.addInvoiceItem = addInvoiceItem;
|
||||
364
public/js/modals/payment-modal.js
Normal file
364
public/js/modals/payment-modal.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// payment-modal.js — ES Module v3 (clean)
|
||||
// Invoice payments: multi-invoice, partial, overpay
|
||||
// No downpayment functionality
|
||||
|
||||
let bankAccounts = [];
|
||||
let paymentMethods = [];
|
||||
let selectedInvoices = []; // { invoice, payAmount }
|
||||
let dataLoaded = false;
|
||||
|
||||
// ============================================================
|
||||
// Load QBO Data
|
||||
// ============================================================
|
||||
|
||||
async function loadQboData() {
|
||||
if (dataLoaded) return;
|
||||
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();
|
||||
dataLoaded = true;
|
||||
} catch (e) { console.error('Error loading QBO data:', e); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Open / Close
|
||||
// ============================================================
|
||||
|
||||
export async function openPaymentModal(invoiceIds = []) {
|
||||
await loadQboData();
|
||||
selectedInvoices = [];
|
||||
|
||||
for (const id of invoiceIds) {
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${id}`);
|
||||
const data = await res.json();
|
||||
if (data.invoice) {
|
||||
const total = parseFloat(data.invoice.total);
|
||||
const amountPaid = parseFloat(data.invoice.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: data.invoice,
|
||||
payAmount: balance > 0 ? balance : total
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||
}
|
||||
|
||||
ensureModalElement();
|
||||
renderModalContent();
|
||||
document.getElementById('payment-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closePaymentModal() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
selectedInvoices = [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Add / Remove Invoices
|
||||
// ============================================================
|
||||
|
||||
async function addInvoiceById() {
|
||||
const input = document.getElementById('payment-add-invoice-id');
|
||||
const searchVal = input.value.trim();
|
||||
if (!searchVal) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/invoices');
|
||||
const allInvoices = await res.json();
|
||||
const match = allInvoices.find(inv =>
|
||||
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
|
||||
);
|
||||
|
||||
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
|
||||
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
|
||||
if (match.paid_date) { alert('This invoice is already paid.'); return; }
|
||||
if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; }
|
||||
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||
alert('All invoices must belong to the same customer.'); return;
|
||||
}
|
||||
|
||||
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||||
const detailData = await detailRes.json();
|
||||
const detailInv = detailData.invoice;
|
||||
const detailTotal = parseFloat(detailInv.total);
|
||||
const detailPaid = parseFloat(detailInv.amount_paid) || 0;
|
||||
const detailBalance = detailTotal - detailPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: detailInv,
|
||||
payAmount: detailBalance > 0 ? detailBalance : detailTotal
|
||||
});
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
input.value = '';
|
||||
} catch (e) {
|
||||
console.error('Error adding invoice:', e);
|
||||
alert('Error searching for invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeInvoice(invoiceId) {
|
||||
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function updatePayAmount(invoiceId, newAmount) {
|
||||
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||||
if (si) {
|
||||
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
|
||||
}
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('payment-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'payment-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);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (!modal) return;
|
||||
|
||||
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];
|
||||
|
||||
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-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
|
||||
<!-- Invoice List -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||||
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
|
||||
<button onclick="window.paymentModal.addById()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||||
<input type="date" id="payment-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 # (Check / ACH)</label>
|
||||
<input type="text" id="payment-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="payment-method"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${methodOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||||
<select id="payment-deposit-to"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${accountOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||||
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="window.paymentModal.close()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||||
💰 Record Payment in QBO
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function renderInvoiceList() {
|
||||
const container = document.getElementById('payment-invoice-list');
|
||||
if (!container) return;
|
||||
|
||||
if (selectedInvoices.length === 0) {
|
||||
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = selectedInvoices.map(si => {
|
||||
const inv = si.invoice;
|
||||
const total = parseFloat(inv.total);
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
const isPartial = si.payAmount < balance;
|
||||
const isOver = si.payAmount > balance;
|
||||
|
||||
const paidInfo = amountPaid > 0
|
||||
? `<span class="text-green-600 text-xs ml-1">Paid: $${amountPaid.toFixed(2)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||||
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
|
||||
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||||
${paidInfo}
|
||||
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
|
||||
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="text-gray-500 text-sm">$</span>
|
||||
<input type="number" step="0.01" min="0.01"
|
||||
value="${si.payAmount.toFixed(2)}"
|
||||
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
||||
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
|
||||
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
|
||||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
const totalEl = document.getElementById('payment-total');
|
||||
const noteEl = document.getElementById('payment-overpay-note');
|
||||
if (!totalEl) return;
|
||||
|
||||
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
||||
|
||||
if (noteEl) {
|
||||
if (payTotal > invTotal && invTotal > 0) {
|
||||
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
|
||||
noteEl.classList.remove('hidden');
|
||||
} else {
|
||||
noteEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Submit
|
||||
// ============================================================
|
||||
|
||||
async function submitPayment() {
|
||||
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
|
||||
|
||||
const paymentDate = document.getElementById('payment-date').value;
|
||||
const reference = document.getElementById('payment-reference').value;
|
||||
const methodSelect = document.getElementById('payment-method');
|
||||
const depositSelect = document.getElementById('payment-deposit-to');
|
||||
|
||||
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
|
||||
alert('Please fill in all fields.'); return;
|
||||
}
|
||||
|
||||
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||||
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||||
const hasOverpay = total > invTotal;
|
||||
|
||||
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
|
||||
if (hasPartial) msg += '\n⚠️ Contains partial payment(s).';
|
||||
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
const submitBtn = document.getElementById('payment-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Processing...';
|
||||
submitBtn.disabled = true;
|
||||
if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/record-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
invoice_payments: selectedInvoices.map(si => ({
|
||||
invoice_id: si.invoice.id,
|
||||
amount: si.payAmount
|
||||
})),
|
||||
payment_date: paymentDate,
|
||||
reference_number: reference,
|
||||
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}`);
|
||||
closePaymentModal();
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Payment error:', e);
|
||||
alert('Network error.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.paymentModal = {
|
||||
open: openPaymentModal,
|
||||
close: closePaymentModal,
|
||||
submit: submitPayment,
|
||||
addById: addInvoiceById,
|
||||
removeInvoice: removeInvoice,
|
||||
updateAmount: updatePayAmount,
|
||||
updateTotal: updateTotal
|
||||
};
|
||||
154
public/js/modals/quote-modal.js
Normal file
154
public/js/modals/quote-modal.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* quote-modal.js — Quote create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentQuoteId = null;
|
||||
|
||||
export function openQuoteModal(quoteId = null) {
|
||||
currentQuoteId = quoteId;
|
||||
|
||||
if (quoteId) {
|
||||
loadQuoteForEdit(quoteId);
|
||||
} else {
|
||||
prepareNewQuote();
|
||||
}
|
||||
|
||||
document.getElementById('quote-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeQuoteModal() {
|
||||
document.getElementById('quote-modal').classList.remove('active');
|
||||
currentQuoteId = null;
|
||||
}
|
||||
|
||||
async function loadQuoteForEdit(quoteId) {
|
||||
document.getElementById('quote-modal-title').textContent = 'Edit Quote';
|
||||
|
||||
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#quote-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('quote-customer').value = data.quote.customer_id;
|
||||
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||
|
||||
// Load items using shared editor
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
});
|
||||
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function prepareNewQuote() {
|
||||
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function addQuoteItem(item = null) {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function updateQuoteTotals() {
|
||||
const items = getItems('quote-items');
|
||||
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';
|
||||
}
|
||||
|
||||
export async function handleQuoteSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const items = getItems('quote-items');
|
||||
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();
|
||||
if (window.quoteView) window.quoteView.loadQuotes();
|
||||
} else {
|
||||
alert('Error saving quote');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving quote');
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initQuoteModal() {
|
||||
const form = document.getElementById('quote-form');
|
||||
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openQuoteModal = openQuoteModal;
|
||||
window.closeQuoteModal = closeQuoteModal;
|
||||
window.addQuoteItem = addQuoteItem;
|
||||
48
public/js/utils/helpers.js
Normal file
48
public/js/utils/helpers.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* helpers.js — Shared UI utility functions
|
||||
* Extracted from app.js
|
||||
*/
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
|
||||
export function hideSpinner() {
|
||||
const overlay = document.getElementById('qbo-spinner');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// Keep backward compat for onclick handlers and modules using typeof check
|
||||
window.showSpinner = showSpinner;
|
||||
window.hideSpinner = hideSpinner;
|
||||
293
public/js/utils/item-editor.js
Normal file
293
public/js/utils/item-editor.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* item-editor.js — Shared accordion item editor for Quotes and Invoices
|
||||
*
|
||||
* Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function).
|
||||
*
|
||||
* Usage:
|
||||
* import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js';
|
||||
* addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 });
|
||||
*/
|
||||
|
||||
let itemCounter = 0;
|
||||
|
||||
export function resetItemCounter() {
|
||||
itemCounter = 0;
|
||||
}
|
||||
|
||||
export function getItemCounter() {
|
||||
return itemCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item row to the specified container.
|
||||
*
|
||||
* @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items')
|
||||
* @param {object} options
|
||||
* @param {object|null} options.item - Existing item data (null for new empty item)
|
||||
* @param {string} options.type - 'quote' or 'invoice'
|
||||
* @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only)
|
||||
* @param {function} options.onUpdate - Callback after any change (for recalculating totals)
|
||||
*/
|
||||
export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) {
|
||||
const itemId = itemCounter++;
|
||||
const itemsDiv = document.getElementById(containerId);
|
||||
if (!itemsDiv) return;
|
||||
|
||||
const prefix = type; // 'quote' or 'invoice'
|
||||
const cssClass = `${prefix}-item-input`;
|
||||
const editorClass = `${prefix}-item-description-editor`;
|
||||
const amountClass = `${prefix}-item-amount`;
|
||||
|
||||
// Preview defaults
|
||||
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 ? '...' : '');
|
||||
}
|
||||
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||
itemDiv.id = `${prefix}-item-${itemId}`;
|
||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<div class="flex items-center p-4">
|
||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="window.itemEditor.moveUp('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||
<button type="button" onclick="window.itemEditor.moveDown('${prefix}', ${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="window.itemEditor.remove('${prefix}', ${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="${cssClass} 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="window.itemEditor.handleTypeChange(this, '${prefix}', ${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="${editorClass} 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="${cssClass} 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="${amountClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemsDiv.appendChild(itemDiv);
|
||||
|
||||
// --- Quill Rich Text Editor ---
|
||||
const editorDiv = itemDiv.querySelector(`.${editorClass}`);
|
||||
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);
|
||||
onUpdate();
|
||||
});
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// --- Auto-calculate Amount ---
|
||||
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) {
|
||||
// Quote supports TBD
|
||||
if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') {
|
||||
// Don't auto-calculate for TBD
|
||||
} else {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||
amountInput.value = (qty * rateValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', () => {
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
});
|
||||
|
||||
// Store metadata on the div for later retrieval
|
||||
itemDiv._itemEditor = { type, laborRate, onUpdate };
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the collapsed preview bar of an item
|
||||
*/
|
||||
function updateItemPreview(itemDiv) {
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]');
|
||||
const editorDivs = itemDiv.querySelectorAll('[data-field="description"]');
|
||||
const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null;
|
||||
|
||||
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');
|
||||
|
||||
if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0';
|
||||
if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00';
|
||||
|
||||
if (typePreview && typeInput) {
|
||||
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||
}
|
||||
|
||||
if (descPreview && editorDiv && editorDiv.quillInstance) {
|
||||
const plainText = editorDiv.quillInstance.getText().trim();
|
||||
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||
descPreview.textContent = preview || 'New item';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle type change (Labor/Parts).
|
||||
* When Labor is selected and rate is empty, auto-fill with labor rate.
|
||||
*/
|
||||
export function handleTypeChange(selectEl, prefix, itemId) {
|
||||
const itemDiv = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!itemDiv) return;
|
||||
|
||||
const meta = itemDiv._itemEditor || {};
|
||||
const laborRate = meta.laborRate;
|
||||
const onUpdate = meta.onUpdate || (() => {});
|
||||
|
||||
// Auto-fill labor rate when switching to Labor and rate is empty
|
||||
if (selectEl.value === '5' && laborRate) {
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||
rateInput.value = laborRate;
|
||||
// Recalculate amount
|
||||
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 * laborRate).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items from a container as an array of objects.
|
||||
*/
|
||||
export function getItems(containerId) {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll(`#${containerId} > div`);
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('[data-field="description"]');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
|
||||
items.push({
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
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
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item by prefix and itemId
|
||||
*/
|
||||
export function removeItem(prefix, itemId) {
|
||||
const el = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!el) return;
|
||||
const meta = el._itemEditor || {};
|
||||
el.remove();
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item up
|
||||
*/
|
||||
export function moveItemUp(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const prevItem = item.previousElementSibling;
|
||||
if (prevItem) {
|
||||
item.parentNode.insertBefore(item, prevItem);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item down
|
||||
*/
|
||||
export function moveItemDown(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const nextItem = item.nextElementSibling;
|
||||
if (nextItem) {
|
||||
item.parentNode.insertBefore(nextItem, item);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose to window for onclick handlers in HTML
|
||||
// ============================================================
|
||||
|
||||
window.itemEditor = {
|
||||
moveUp: moveItemUp,
|
||||
moveDown: moveItemDown,
|
||||
remove: removeItem,
|
||||
handleTypeChange: handleTypeChange
|
||||
};
|
||||
405
public/js/views/customer-view.js
Normal file
405
public/js/views/customer-view.js
Normal 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;
|
||||
515
public/js/views/invoice-view.js
Normal file
515
public/js/views/invoice-view.js
Normal 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
|
||||
};
|
||||
101
public/js/views/quote-view.js
Normal file
101
public/js/views/quote-view.js
Normal 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
|
||||
};
|
||||
182
public/js/views/settings-view.js
Normal file
182
public/js/views/settings-view.js
Normal 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;
|
||||
Reference in New Issue
Block a user