update
This commit is contained in:
331
public/app.js
331
public/app.js
@@ -18,15 +18,21 @@ function customerSearch(type) {
|
||||
highlighted: 0,
|
||||
|
||||
get filteredCustomers() {
|
||||
// Wenn Suche leer ist: ALLE Kunden zurückgeben (kein .slice mehr!)
|
||||
if (!this.search) {
|
||||
return customers.slice(0, 50); // Show first 50 if no search
|
||||
return customers;
|
||||
}
|
||||
|
||||
const searchLower = this.search.toLowerCase();
|
||||
|
||||
// Filtern: Sucht im Namen, Line1, Stadt oder Account Nummer
|
||||
// Auch hier: Kein .slice mehr, damit alle Ergebnisse (z.B. alle mit 'C') angezeigt werden
|
||||
return customers.filter(c =>
|
||||
c.name.toLowerCase().includes(searchLower) ||
|
||||
c.city.toLowerCase().includes(searchLower) ||
|
||||
(c.name || '').toLowerCase().includes(searchLower) ||
|
||||
(c.line1 || '').toLowerCase().includes(searchLower) ||
|
||||
(c.city || '').toLowerCase().includes(searchLower) ||
|
||||
(c.account_number && c.account_number.includes(searchLower))
|
||||
).slice(0, 50); // Limit to 50 results
|
||||
);
|
||||
},
|
||||
|
||||
selectCustomer(customer) {
|
||||
@@ -197,17 +203,32 @@ async function loadCustomers() {
|
||||
|
||||
function renderCustomers() {
|
||||
const tbody = document.getElementById('customers-list');
|
||||
tbody.innerHTML = customers.map(customer => `
|
||||
tbody.innerHTML = customers.map(customer => {
|
||||
// Logik: Line 1-4 zusammenbauen
|
||||
// filter(Boolean) entfernt null/undefined/leere Strings
|
||||
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
||||
|
||||
// City, State, Zip anhängen
|
||||
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||
|
||||
// Alles zusammenfügen
|
||||
let fullAddress = lines.join(', ');
|
||||
if (cityStateZip) {
|
||||
fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code}</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('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openCustomerModal(customerId = null) {
|
||||
@@ -218,17 +239,29 @@ function openCustomerModal(customerId = null) {
|
||||
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;
|
||||
document.getElementById('customer-street').value = customer.street;
|
||||
document.getElementById('customer-city').value = customer.city;
|
||||
document.getElementById('customer-state').value = customer.state;
|
||||
document.getElementById('customer-zip').value = customer.zip_code;
|
||||
|
||||
// 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');
|
||||
@@ -244,11 +277,21 @@ async function handleCustomerSubmit(e) {
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('customer-name').value,
|
||||
street: document.getElementById('customer-street').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
|
||||
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 {
|
||||
@@ -398,81 +441,66 @@ function addQuoteItem(item = null) {
|
||||
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||
itemDiv.id = `quote-item-${itemId}`;
|
||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||
|
||||
// Get preview text
|
||||
|
||||
// Preview Text logic
|
||||
const previewQty = item ? item.quantity : '';
|
||||
const previewAmount = item ? item.amount : '$0.00';
|
||||
let previewDesc = 'New item';
|
||||
if (item && item.description) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = item.description;
|
||||
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
||||
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||
}
|
||||
// Preview Type Logic (NEU)
|
||||
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<!-- Accordion Header -->
|
||||
<div class="flex items-center p-4">
|
||||
<!-- Move Buttons (Left) - Outside accordion click area -->
|
||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="moveQuoteItemUp(${itemId})"
|
||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
|
||||
title="Move Up">
|
||||
↑
|
||||
</button>
|
||||
<button type="button" onclick="moveQuoteItemDown(${itemId})"
|
||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
|
||||
title="Move Down">
|
||||
↓
|
||||
</button>
|
||||
<button type="button" onclick="moveQuoteItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||
<button type="button" onclick="moveQuoteItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||
</div>
|
||||
|
||||
<!-- Accordion Toggle & Content (Center) -->
|
||||
|
||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
|
||||
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||
|
||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (Right) - Outside accordion click area -->
|
||||
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();"
|
||||
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
||||
×
|
||||
</button>
|
||||
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Accordion Content -->
|
||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||
<div class="grid grid-cols-12 gap-3 items-start">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
||||
value="${item ? item.quantity : ''}"
|
||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateItemPreview(this.closest('[id^=quote-item-]'), ${itemId})">
|
||||
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||
<div data-item="${itemId}" data-field="description"
|
||||
class="quote-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||
style="min-height: 60px;">
|
||||
</div>
|
||||
<div data-item="${itemId}" data-field="description" class="quote-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||
<input type="text" data-item="${itemId}" data-field="rate"
|
||||
value="${item ? item.rate : ''}"
|
||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input type="text" data-item="${itemId}" data-field="amount"
|
||||
value="${item ? item.amount : ''}"
|
||||
class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,31 +508,18 @@ function addQuoteItem(item = null) {
|
||||
|
||||
itemsDiv.appendChild(itemDiv);
|
||||
|
||||
// Initialize Quill editor
|
||||
// Quill Init
|
||||
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (item && item.description) {
|
||||
quill.root.innerHTML = item.description;
|
||||
}
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateItemPreview(itemDiv, itemId);
|
||||
updateQuoteTotals();
|
||||
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
|
||||
});
|
||||
if (item && item.description) quill.root.innerHTML = item.description;
|
||||
|
||||
quill.on('text-change', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// Auto-calculate amount and update preview
|
||||
// Auto-calculate logic
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
@@ -521,10 +536,7 @@ function addQuoteItem(item = null) {
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', () => {
|
||||
updateItemPreview(itemDiv, itemId);
|
||||
updateQuoteTotals();
|
||||
});
|
||||
amountInput.addEventListener('input', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
|
||||
|
||||
updateItemPreview(itemDiv, itemId);
|
||||
updateQuoteTotals();
|
||||
@@ -533,18 +545,25 @@ function addQuoteItem(item = null) {
|
||||
function updateItemPreview(itemDiv, itemId) {
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
|
||||
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||
|
||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
|
||||
|
||||
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||
|
||||
// NEU: Update Type Label
|
||||
if (typePreview && typeInput) {
|
||||
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||
}
|
||||
|
||||
if (descPreview && editorDiv.quillInstance) {
|
||||
const plainText = editorDiv.quillInstance.getText().trim();
|
||||
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
||||
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||
descPreview.textContent = preview || 'New item';
|
||||
}
|
||||
}
|
||||
@@ -612,13 +631,14 @@ function getQuoteItems() {
|
||||
|
||||
const item = {
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
// NEU: ID holen
|
||||
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
};
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -741,6 +761,9 @@ function renderInvoices() {
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||
<button onclick="exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">
|
||||
QBO Export
|
||||
</button>
|
||||
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
@@ -822,81 +845,66 @@ function addInvoiceItem(item = null) {
|
||||
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||
itemDiv.id = `invoice-item-${itemId}`;
|
||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||
|
||||
// Get preview text
|
||||
|
||||
// Preview Text logic
|
||||
const previewQty = item ? item.quantity : '';
|
||||
const previewAmount = item ? item.amount : '$0.00';
|
||||
let previewDesc = 'New item';
|
||||
if (item && item.description) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = item.description;
|
||||
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
||||
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||
}
|
||||
// Preview Type Logic
|
||||
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<!-- Accordion Header -->
|
||||
<div class="flex items-center p-4">
|
||||
<!-- Move Buttons (Left) - Outside accordion click area -->
|
||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="moveInvoiceItemUp(${itemId})"
|
||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
|
||||
title="Move Up">
|
||||
↑
|
||||
</button>
|
||||
<button type="button" onclick="moveInvoiceItemDown(${itemId})"
|
||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
|
||||
title="Move Down">
|
||||
↓
|
||||
</button>
|
||||
<button type="button" onclick="moveInvoiceItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||
<button type="button" onclick="moveInvoiceItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||
</div>
|
||||
|
||||
<!-- Accordion Toggle & Content (Center) -->
|
||||
|
||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
|
||||
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||
|
||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (Right) - Outside accordion click area -->
|
||||
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();"
|
||||
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
||||
×
|
||||
</button>
|
||||
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Accordion Content -->
|
||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||
<div class="grid grid-cols-12 gap-3 items-start">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
||||
value="${item ? item.quantity : ''}"
|
||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateInvoiceItemPreview(this.closest('[id^=invoice-item-]'), ${itemId})">
|
||||
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||
<div data-item="${itemId}" data-field="description"
|
||||
class="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||
style="min-height: 60px;">
|
||||
</div>
|
||||
<div data-item="${itemId}" data-field="description" class="invoice-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||
<input type="text" data-item="${itemId}" data-field="rate"
|
||||
value="${item ? item.rate : ''}"
|
||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input type="text" data-item="${itemId}" data-field="amount"
|
||||
value="${item ? item.amount : ''}"
|
||||
class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -904,31 +912,18 @@ function addInvoiceItem(item = null) {
|
||||
|
||||
itemsDiv.appendChild(itemDiv);
|
||||
|
||||
// Initialize Quill editor
|
||||
// Quill Init (wie vorher)
|
||||
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (item && item.description) {
|
||||
quill.root.innerHTML = item.description;
|
||||
}
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateInvoiceItemPreview(itemDiv, itemId);
|
||||
updateInvoiceTotals();
|
||||
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
|
||||
});
|
||||
if (item && item.description) quill.root.innerHTML = item.description;
|
||||
|
||||
quill.on('text-change', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// Auto-calculate amount and update preview
|
||||
// Auto-calculate logic (wie vorher)
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
@@ -945,10 +940,7 @@ function addInvoiceItem(item = null) {
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', () => {
|
||||
updateInvoiceItemPreview(itemDiv, itemId);
|
||||
updateInvoiceTotals();
|
||||
});
|
||||
amountInput.addEventListener('input', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
|
||||
|
||||
updateInvoiceItemPreview(itemDiv, itemId);
|
||||
updateInvoiceTotals();
|
||||
@@ -957,18 +949,25 @@ function addInvoiceItem(item = null) {
|
||||
function updateInvoiceItemPreview(itemDiv, itemId) {
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
|
||||
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||
|
||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
|
||||
|
||||
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||
|
||||
// NEU: Update Type Label
|
||||
if (typePreview && typeInput) {
|
||||
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||
}
|
||||
|
||||
if (descPreview && editorDiv.quillInstance) {
|
||||
const plainText = editorDiv.quillInstance.getText().trim();
|
||||
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
||||
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||
descPreview.textContent = preview || 'New item';
|
||||
}
|
||||
}
|
||||
@@ -1025,19 +1024,18 @@ function getInvoiceItems() {
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('.invoice-item-description-editor');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : '';
|
||||
|
||||
const item = {
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
// NEU: ID holen
|
||||
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
};
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1113,4 +1111,33 @@ async function deleteInvoice(id) {
|
||||
|
||||
function viewInvoicePDF(id) {
|
||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||
}
|
||||
|
||||
async function exportToQBO(id) {
|
||||
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
|
||||
|
||||
// UI Feedback (einfach, aber wirksam)
|
||||
const btn = event.target; // Der geklickte Button
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = "⏳...";
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}`);
|
||||
// Optional: Liste neu laden um Status zu aktualisieren
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Fehler: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Netzwerkfehler beim Export.');
|
||||
} finally {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -169,41 +169,74 @@
|
||||
<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">
|
||||
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">Street Address</label>
|
||||
<input type="text" id="customer-street" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<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">
|
||||
<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" required
|
||||
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-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" required 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">
|
||||
<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" required
|
||||
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-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">
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user