customer module + features

This commit is contained in:
Andreas Knuth 2026-02-23 14:09:52 -06:00
parent 5e63adfee8
commit 9ebfd9b8c3
4 changed files with 482 additions and 353 deletions

View File

@ -1,5 +1,5 @@
// Global state
let customers = [];
let customers = []; // shared, updated by customer-view.js
let quotes = [];
let invoices = [];
let currentQuoteId = null;
@ -204,50 +204,8 @@ async function uploadLogo() {
}
}
// Customer Management
async function loadCustomers() {
try {
const response = await fetch('/api/customers');
customers = await response.json();
renderCustomers();
} catch (error) {
console.error('Error loading customers:', error);
alert('Error loading customers');
}
}
// =====================================================
// 1. renderCustomers() — ERSETZE komplett
// Zeigt QBO-Status und Export-Button in der Kundenliste
// =====================================================
function renderCustomers() {
const tbody = document.getElementById('customers-list');
tbody.innerHTML = customers.map(customer => {
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
let fullAddress = lines.join(', ');
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
// QBO Status
const qboStatus = customer.qbo_id
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
: `<button onclick="exportCustomerToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Kunde nach QBO exportieren">QBO Export</button>`;
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.name} ${qboStatus}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>`;
}).join('');
}
// --- 2. Credits async laden ---
async function loadCustomerCredits() {
@ -406,139 +364,7 @@ async function submitDownpayment(customerId, customerQboId) {
if (typeof hideSpinner === 'function') hideSpinner();
}
}
function openCustomerModal(customerId = null) {
currentCustomerId = customerId;
const modal = document.getElementById('customer-modal');
const title = document.getElementById('customer-modal-title');
if (customerId) {
title.textContent = 'Edit Customer';
const customer = customers.find(c => c.id === customerId);
document.getElementById('customer-id').value = customer.id;
document.getElementById('customer-name').value = customer.name;
// Neue Felder befüllen
document.getElementById('customer-line1').value = customer.line1 || '';
document.getElementById('customer-line2').value = customer.line2 || '';
document.getElementById('customer-line3').value = customer.line3 || '';
document.getElementById('customer-line4').value = customer.line4 || '';
document.getElementById('customer-city').value = customer.city || '';
document.getElementById('customer-state').value = customer.state || '';
document.getElementById('customer-zip').value = customer.zip_code || '';
document.getElementById('customer-account').value = customer.account_number || '';
document.getElementById('customer-email').value = customer.email || '';
document.getElementById('customer-phone').value = customer.phone || '';
document.getElementById('customer-taxable').checked = customer.taxable !== false;
} else {
title.textContent = 'New Customer';
document.getElementById('customer-form').reset();
document.getElementById('customer-id').value = '';
document.getElementById('customer-taxable').checked = true;
}
modal.classList.add('active');
}
function closeCustomerModal() {
document.getElementById('customer-modal').classList.remove('active');
currentCustomerId = null;
}
async function handleCustomerSubmit(e) {
e.preventDefault();
const data = {
name: document.getElementById('customer-name').value,
// Neue Felder auslesen
line1: document.getElementById('customer-line1').value,
line2: document.getElementById('customer-line2').value,
line3: document.getElementById('customer-line3').value,
line4: document.getElementById('customer-line4').value,
city: document.getElementById('customer-city').value,
state: document.getElementById('customer-state').value.toUpperCase(),
zip_code: document.getElementById('customer-zip').value,
account_number: document.getElementById('customer-account').value,
email: document.getElementById('customer-email')?.value || '',
phone: document.getElementById('customer-phone')?.value || '',
phone2: '', // Erstmal leer lassen, falls kein Feld im Formular ist
taxable: document.getElementById('customer-taxable')?.checked ?? true
};
try {
const customerId = document.getElementById('customer-id').value;
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
const method = customerId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeCustomerModal();
loadCustomers();
} else {
alert('Error saving customer');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving customer');
}
}
async function editCustomer(id) {
openCustomerModal(id);
}
async function deleteCustomer(id) {
if (!confirm('Are you sure you want to delete this customer?')) return;
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
if (response.ok) {
loadCustomers();
} else {
alert('Error deleting customer');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting customer');
}
}
async function exportCustomerToQbo(customerId) {
const customer = customers.find(c => c.id === customerId);
if (!customer) return;
if (!confirm(`Kunde "${customer.name}" nach QuickBooks Online exportieren?`)) return;
showSpinner('Exportiere Kunde nach QBO...');
try {
const response = await fetch(`/api/customers/${customerId}/export-qbo`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ Kunde "${result.name}" erfolgreich in QBO erstellt (ID: ${result.qbo_id}).`);
// Kunden-Liste neu laden
const custResponse = await fetch('/api/customers');
customers = await custResponse.json();
renderCustomers();
} else {
alert(`❌ Fehler: ${result.error}`);
}
} catch (error) {
console.error('Error exporting customer:', error);
alert('Netzwerkfehler beim Export.');
} finally {
hideSpinner();
}
}
// Quote Management
async function loadQuotes() {
try {

399
public/customer-view.js Normal file
View File

@ -0,0 +1,399 @@
// 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();
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';
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.');
}
}
// ============================================================
// Actions
// ============================================================
export function edit(id) { openModal(id); }
export async function remove(id) {
const customer = customers.find(c => c.id === id);
if (!customer) return;
let msg = `Delete customer "${customer.name}"?`;
if (customer.qbo_id) msg += '\nThis will also deactivate the customer in QBO.';
if (!confirm(msg)) return;
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
if (response.ok) await loadCustomers();
else {
const err = await response.json();
alert(`Error: ${err.error || 'Failed to delete'}`);
}
} catch (error) {
console.error('Error:', error);
alert('Network error.');
}
}
export async function exportToQbo(id) {
const customer = customers.find(c => c.id === id);
if (!customer) return;
if (!confirm(`Export "${customer.name}" to QuickBooks Online?`)) return;
if (typeof showSpinner === 'function') showSpinner('Exporting customer to QBO...');
try {
const response = await fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ "${result.name}" exported to QBO (ID: ${result.qbo_id}).`);
await loadCustomers();
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (error) {
alert('Network error.');
} finally {
if (typeof hideSpinner === 'function') hideSpinner();
}
}
export function setQboFilter(val) {
filterQbo = val;
saveSettings();
renderCustomerView();
}
// ============================================================
// Expose
// ============================================================
window.customerView = {
loadCustomers, renderCustomerView, getCustomers,
openModal, closeModal, edit, remove, exportToQbo, setQboFilter
};
// Make customers available globally for other modules (quote/invoice dropdowns)
window.getCustomers = () => customers;

View File

@ -20,7 +20,7 @@
<body class="bg-gray-100">
<div class="min-h-screen">
<!-- Navigation -->
<nav class="bg-blue-900 text-white shadow-lg">
<nav class="bg-blue-900 text-white shadow-lg sticky top-0 z-40">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div>
@ -98,21 +98,16 @@
<!-- Customers Tab -->
<div id="customers-tab" class="tab-content hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Customers</h2>
<button onclick="openCustomerModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Customer
</button>
</div>
<div id="customer-toolbar"></div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
@ -228,106 +223,6 @@
</div>
</div>
<!-- Customer Modal -->
<div id="customer-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center">
<div class="relative mx-auto p-8 border w-full max-w-2xl shadow-lg rounded-lg bg-white">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="customer-modal-title">New Customer</h3>
<button onclick="closeCustomerModal()" class="text-gray-400 hover:text-gray-500">
<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" class="space-y-4">
<input type="hidden" id="customer-id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input type="text" id="customer-name" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="space-y-3 pt-2">
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
<input type="text" id="customer-line1" placeholder="Line 1 (Street / PO Box / Company)"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<input type="text" id="customer-line2" placeholder="Line 2"
class="w-full px-4 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="customer-line3" placeholder="Line 3"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<input type="text" id="customer-line4" placeholder="Line 4"
class="w-full px-4 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 pt-2">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" id="customer-city"
class="w-full px-4 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="customer-state" maxlength="2" placeholder="TX"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
<input type="text" id="customer-zip"
class="w-full px-4 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 Number</label>
<input type="text" id="customer-account"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</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="customer-email"
class="w-full px-4 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="customer-phone"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="pt-2">
<div class="flex items-center">
<input type="checkbox" id="customer-taxable"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="customer-taxable" class="ml-2 block text-sm text-gray-900">Taxable</label>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeCustomerModal()"
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">
Save Customer
</button>
</div>
</form>
</div>
</div>
<!-- Quote Modal -->
<div id="quote-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center z-50">
<div class="relative mx-auto p-8 border w-full max-w-6xl shadow-lg rounded-lg bg-white my-8">
@ -562,6 +457,22 @@
</div>
<script src="app.js"></script>
<script type="module">
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './customer-view.js';
// Override showTab to inject customer toolbar
const originalShowTab = window.showTab;
window.showTab = function(tab) {
originalShowTab(tab);
if (tab === 'customers') {
injectCustomerToolbar();
renderCustomerView();
}
};
// Load customers on init (needed for quote/invoice dropdowns)
loadCustomers();
</script>
<script type="module" src="invoice-view-init.js"></script>
</body>
</html>

121
server.js
View File

@ -165,23 +165,19 @@ app.get('/api/customers', async (req, res) => {
// POST /api/customers
app.post('/api/customers', async (req, res) => {
// line1 bis line4 statt street/pobox/suite
const {
name, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable
name, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
const result = await pool.query(
`INSERT INTO customers
(name, line1, line2, line3, line4, city, state,
zip_code, account_number, email, phone, phone2, taxable)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[name, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null,
account_number || null, email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true]
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null, account_number || null,
email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, remarks || null]
);
res.json(result.rows[0]);
} catch (error) {
@ -194,28 +190,27 @@ app.post('/api/customers', async (req, res) => {
app.put('/api/customers/:id', async (req, res) => {
const { id } = req.params;
const {
name, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable
name, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
// Lokal updaten
const result = await pool.query(
`UPDATE customers
SET name = $1, line1 = $2, line2 = $3, line3 = $4, line4 = $5,
city = $6, state = $7, zip_code = $8, account_number = $9, email = $10,
phone = $11, phone2 = $12, taxable = $13, updated_at = CURRENT_TIMESTAMP
WHERE id = $14
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
WHERE id = $16
RETURNING *`,
[name, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null,
account_number || null, email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, id]
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null, account_number || null,
email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, remarks || null, id]
);
const customer = result.rows[0];
// In QBO updaten falls vorhanden
// QBO Update
if (customer.qbo_id) {
try {
const oauthClient = getOAuthClient();
@ -224,7 +219,7 @@ app.put('/api/customers/:id', async (req, res) => {
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// Aktuellen SyncToken holen
// SyncToken holen
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
method: 'GET'
@ -233,7 +228,6 @@ app.put('/api/customers/:id', async (req, res) => {
const syncToken = qboData.Customer?.SyncToken;
if (syncToken !== undefined) {
// Sparse update
const updatePayload = {
Id: customer.qbo_id,
SyncToken: syncToken,
@ -242,9 +236,21 @@ app.put('/api/customers/:id', async (req, res) => {
CompanyName: name,
PrimaryEmailAddr: email ? { Address: email } : undefined,
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
Taxable: taxable !== false
Taxable: taxable !== false,
Notes: remarks || undefined
};
// Contact → GivenName / FamilyName
if (contact) {
const parts = contact.trim().split(/\s+/);
if (parts.length >= 2) {
updatePayload.GivenName = parts[0];
updatePayload.FamilyName = parts.slice(1).join(' ');
} else {
updatePayload.GivenName = parts[0];
}
}
// Adresse
const addr = {};
if (line1) addr.Line1 = line1;
@ -269,7 +275,6 @@ app.put('/api/customers/:id', async (req, res) => {
}
} catch (qboError) {
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
// Nicht abbrechen — lokales Update war erfolgreich
}
}
@ -2118,12 +2123,9 @@ app.post('/api/customers/:id/export-qbo', async (req, res) => {
try {
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
const customer = custResult.rows[0];
if (customer.qbo_id) {
return res.status(400).json({ error: `Kunde "${customer.name}" ist bereits in QBO (ID: ${customer.qbo_id}).` });
}
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
@ -2131,19 +2133,28 @@ app.post('/api/customers/:id/export-qbo', async (req, res) => {
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// QBO Customer Objekt
const qboCustomer = {
DisplayName: customer.name,
CompanyName: customer.name,
BillAddr: {},
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
// Taxable setzt man über TaxExemptionReasonId oder SalesTermRef
Taxable: customer.taxable !== false
Taxable: customer.taxable !== false,
Notes: customer.remarks || undefined
};
// Adresse aufbauen
const addr = qboCustomer.BillAddr;
// Contact
if (customer.contact) {
const parts = customer.contact.trim().split(/\s+/);
if (parts.length >= 2) {
qboCustomer.GivenName = parts[0];
qboCustomer.FamilyName = parts.slice(1).join(' ');
} else {
qboCustomer.GivenName = parts[0];
}
}
// Address
const addr = {};
if (customer.line1) addr.Line1 = customer.line1;
if (customer.line2) addr.Line2 = customer.line2;
if (customer.line3) addr.Line3 = customer.line3;
@ -2151,11 +2162,7 @@ app.post('/api/customers/:id/export-qbo', async (req, res) => {
if (customer.city) addr.City = customer.city;
if (customer.state) addr.CountrySubDivisionCode = customer.state;
if (customer.zip_code) addr.PostalCode = customer.zip_code;
// Kein leeres BillAddr senden
if (Object.keys(addr).length === 0) delete qboCustomer.BillAddr;
console.log(`📤 Exportiere Kunde "${customer.name}" nach QBO...`);
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
@ -2165,35 +2172,21 @@ app.post('/api/customers/:id/export-qbo', async (req, res) => {
});
const data = response.getJson ? response.getJson() : response.json;
const qboId = data.Customer?.Id;
if (data.Customer) {
const qboId = data.Customer.Id;
if (!qboId) throw new Error('QBO returned no ID');
// qbo_id lokal speichern
await pool.query(
'UPDATE customers SET qbo_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[qboId, id]
);
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
console.log(`✅ Kunde "${customer.name}" in QBO erstellt: ID ${qboId}`);
res.json({ success: true, qbo_id: qboId, name: customer.name });
} else {
console.error('❌ QBO Customer Fehler:', JSON.stringify(data));
// Spezieller Fehler: Name existiert schon in QBO
const errMsg = data.Fault?.Error?.[0]?.Message || JSON.stringify(data);
const errDetail = data.Fault?.Error?.[0]?.Detail || '';
res.status(500).json({ error: `QBO Fehler: ${errMsg}. ${errDetail}` });
}
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
res.json({ success: true, qbo_id: qboId, name: customer.name });
} catch (error) {
console.error(' Customer Export Error:', error);
res.status(500).json({ error: 'Export fehlgeschlagen: ' + error.message });
console.error('QBO Customer Export Error:', error);
res.status(500).json({ error: 'Export failed: ' + error.message });
}
});
// --- 2. Labor Rate aus QBO laden ---
// Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO
app.get('/api/qbo/labor-rate', async (req, res) => {