405 lines
19 KiB
JavaScript
405 lines
19 KiB
JavaScript
// 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; |