customer module + features
This commit is contained in:
parent
5e63adfee8
commit
9ebfd9b8c3
176
public/app.js
176
public/app.js
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
125
server.js
125
server.js
|
|
@ -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
|
||||
const {
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue