diff --git a/Dockerfile b/Dockerfile
index 93c7d41..e63c09b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,7 +23,6 @@ COPY package*.json ./
RUN npm install --omit=dev
# Copy application files
-COPY server.js ./
COPY qbo_helper.js ./
COPY src ./src
COPY public ./public
diff --git a/public/app.js b/public/app.js
deleted file mode 100644
index ba79841..0000000
--- a/public/app.js
+++ /dev/null
@@ -1,1192 +0,0 @@
-// Global state
-let customers = window.customers || [];
-let quotes = [];
-let invoices = [];
-let currentQuoteId = null;
-let currentInvoiceId = null;
-let currentCustomerId = null;
-let itemCounter = 0;
-let currentLogoFile = null;
-
-// Alpine.js Customer Search Component
-function customerSearch(type) {
- return {
- search: '',
- selectedId: '',
- selectedName: '',
- open: false,
- highlighted: 0,
-
- get filteredCustomers() {
- // Wenn Suche leer ist: ALLE Kunden zurückgeben (kein .slice mehr!)
- if (!this.search) {
- return (window.getCustomers ? window.getCustomers() : 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 (window.getCustomers ? window.getCustomers() : customers).filter(c =>
- (c.name || '').toLowerCase().includes(searchLower) ||
- (c.line1 || '').toLowerCase().includes(searchLower) ||
- (c.city || '').toLowerCase().includes(searchLower) ||
- (c.account_number && c.account_number.includes(searchLower))
- );
- },
-
- selectCustomer(customer) {
- this.selectedId = customer.id;
- this.selectedName = customer.name;
- this.search = customer.name;
- this.open = false;
- this.highlighted = 0;
- },
-
- highlightNext() {
- if (this.highlighted < this.filteredCustomers.length - 1) {
- this.highlighted++;
- }
- },
-
- highlightPrev() {
- if (this.highlighted > 0) {
- this.highlighted--;
- }
- },
-
- selectHighlighted() {
- if (this.filteredCustomers[this.highlighted]) {
- this.selectCustomer(this.filteredCustomers[this.highlighted]);
- }
- },
-
- reset() {
- this.search = '';
- this.selectedId = '';
- this.selectedName = '';
- this.open = false;
- this.highlighted = 0;
- }
- };
-}
-
-// Make it globally available for Alpine
-window.customerSearch = customerSearch;
-
-// Initialize app
-document.addEventListener('DOMContentLoaded', () => {
- loadQuotes();
- setDefaultDate();
- checkCurrentLogo();
- loadLaborRate();
- // *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
- const savedTab = localStorage.getItem('activeTab') || 'quotes';
- showTab(savedTab);
-
- // Hash-basierte Tab-Navigation (z.B. nach OAuth Redirect /#settings)
- if (window.location.hash) {
- const hashTab = window.location.hash.replace('#', '');
- if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
- showTab(hashTab);
- }
- }
-
- // Setup form handlers
- document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
- document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit);
- document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals);
- document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals);
-
- // Setup logo upload handler
- document.getElementById('logo-upload').addEventListener('change', (e) => {
- const file = e.target.files[0];
- if (file) {
- currentLogoFile = file;
- document.getElementById('logo-filename').textContent = file.name;
- document.getElementById('upload-btn').disabled = false;
- }
- });
-});
-
-// Tab Management
-function showTab(tabName) {
- document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden'));
- document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800'));
-
- document.getElementById(`${tabName}-tab`).classList.remove('hidden');
- document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
-
- // *** FIX 3: Tab-Auswahl persistieren ***
- localStorage.setItem('activeTab', tabName);
-
- if (tabName === 'quotes') {
- loadQuotes();
- } else if (tabName === 'invoices') {
- loadInvoices();
- } else if (tabName === 'customers') {
- if (window.customerView) {
- window.customerView.loadCustomers();
- }
- } else if (tabName === 'settings') {
- checkCurrentLogo();
- }
-}
-
-// Date helper
-function setDefaultDate() {
- const today = new Date().toISOString().split('T')[0];
- const quoteDateEl = document.getElementById('quote-date');
- const invoiceDateEl = document.getElementById('invoice-date');
- if (quoteDateEl) quoteDateEl.value = today;
- if (invoiceDateEl) invoiceDateEl.value = today;
-}
-
-function formatDate(date) {
- const d = new Date(date);
- const month = String(d.getMonth() + 1).padStart(2, '0');
- const day = String(d.getDate()).padStart(2, '0');
- const year = d.getFullYear();
- return `${month}/${day}/${year}`;
-}
-
-// Logo Management
-async function checkCurrentLogo() {
- try {
- const response = await fetch('/api/logo-info');
- if (response.ok) {
- const data = await response.json();
- if (data.hasLogo) {
- document.getElementById('logo-preview').classList.remove('hidden');
- document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now();
- }
- }
- } catch (error) {
- console.error('Error checking logo:', error);
- }
-}
-
-async function uploadLogo() {
- if (!currentLogoFile) {
- alert('Please select a file first');
- return;
- }
-
- const formData = new FormData();
- formData.append('logo', currentLogoFile);
-
- const statusDiv = document.getElementById('upload-status');
- statusDiv.innerHTML = '
Uploading...
';
-
- try {
- const response = await fetch('/api/upload-logo', {
- method: 'POST',
- body: formData
- });
-
- if (response.ok) {
- const data = await response.json();
- statusDiv.innerHTML = '✓ Logo uploaded successfully!
';
- document.getElementById('logo-preview').classList.remove('hidden');
- document.getElementById('logo-image').src = data.path + '?t=' + Date.now();
- document.getElementById('upload-btn').disabled = true;
- currentLogoFile = null;
- document.getElementById('logo-filename').textContent = '';
- document.getElementById('logo-upload').value = '';
- } else {
- const error = await response.json();
- statusDiv.innerHTML = `✗ Error: ${error.error}
`;
- }
- } catch (error) {
- console.error('Upload error:', error);
- statusDiv.innerHTML = '✗ Upload failed
';
- }
-}
-
-// Quote Management
-async function loadQuotes() {
- try {
- const response = await fetch('/api/quotes');
- quotes = await response.json();
- renderQuotes();
- } catch (error) {
- console.error('Error loading quotes:', error);
- alert('Error loading quotes');
- }
-}
-
-function renderQuotes() {
- const tbody = document.getElementById('quotes-list');
- tbody.innerHTML = quotes.map(quote => {
- const total = quote.has_tbd ? `$${parseFloat(quote.total).toFixed(2)}*` : `$${parseFloat(quote.total).toFixed(2)}`;
- return `
-
- | ${quote.quote_number} |
- ${quote.customer_name || 'N/A'} |
- ${formatDate(quote.quote_date)} |
- ${total} |
-
-
-
-
-
- |
-
- `;
- }).join('');
-}
-
-async function openQuoteModal(quoteId = null) {
- currentQuoteId = quoteId;
- const modal = document.getElementById('quote-modal');
- const title = document.getElementById('quote-modal-title');
-
- if (quoteId) {
- title.textContent = 'Edit Quote';
- const response = await fetch(`/api/quotes/${quoteId}`);
- const data = await response.json();
-
- // Set customer in Alpine component
- const allCust = window.getCustomers ? window.getCustomers() : customers;
- const customer = allCust.find(c => c.id === data.quote.customer_id);
- if (customer) {
- // Find the Alpine component and update it
- const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
- if (customerInput) {
- // Trigger Alpine to update
- customerInput.value = customer.name;
- customerInput.dispatchEvent(new Event('input'));
-
- // Set the values directly on the Alpine component
- const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
- if (alpineData) {
- alpineData.search = customer.name;
- alpineData.selectedId = customer.id;
- alpineData.selectedName = customer.name;
- }
- }
- }
-
- document.getElementById('quote-customer').value = data.quote.customer_id;
-
- const dateOnly = data.quote.quote_date.split('T')[0];
- document.getElementById('quote-date').value = dateOnly;
- document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
-
- // Load items
- document.getElementById('quote-items').innerHTML = '';
- itemCounter = 0;
- data.items.forEach(item => {
- addQuoteItem(item);
- });
-
- updateQuoteTotals();
- } else {
- title.textContent = 'New Quote';
- document.getElementById('quote-form').reset();
- document.getElementById('quote-items').innerHTML = '';
- itemCounter = 0;
- setDefaultDate();
-
- // Add one default item
- addQuoteItem();
- }
-
- modal.classList.add('active');
-}
-
-function closeQuoteModal() {
- document.getElementById('quote-modal').classList.remove('active');
- currentQuoteId = null;
-}
-
-function addQuoteItem(item = null) {
- const itemId = itemCounter++;
- const itemsDiv = document.getElementById('quote-items');
-
- const itemDiv = document.createElement('div');
- 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'} }`);
-
- // 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, 50) + (temp.textContent.length > 50 ? '...' : '');
- }
- // Preview Type Logic (NEU)
- const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
-
- itemDiv.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
Qty: ${previewQty}
-
${typeLabel}
-
-
${previewDesc}
-
${previewAmount}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- itemsDiv.appendChild(itemDiv);
-
- // 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(); });
- editorDiv.quillInstance = quill;
-
- // Auto-calculate logic
- const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
- const rateInput = itemDiv.querySelector('[data-field="rate"]');
- const amountInput = itemDiv.querySelector('[data-field="amount"]');
-
- const calculateAmount = () => {
- if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') {
- const qty = parseFloat(qtyInput.value) || 0;
- const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
- amountInput.value = (qty * rateValue).toFixed(2);
- }
- updateItemPreview(itemDiv, itemId);
- updateQuoteTotals();
- };
-
- qtyInput.addEventListener('input', calculateAmount);
- rateInput.addEventListener('input', calculateAmount);
- amountInput.addEventListener('input', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
-
- updateItemPreview(itemDiv, itemId);
- updateQuoteTotals();
-}
-
-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, 50) + (plainText.length > 50 ? '...' : '');
- descPreview.textContent = preview || 'New item';
- }
-}
-
-function removeQuoteItem(itemId) {
- document.getElementById(`quote-item-${itemId}`).remove();
- updateQuoteTotals();
-}
-
-function moveQuoteItemUp(itemId) {
- const item = document.getElementById(`quote-item-${itemId}`);
- const prevItem = item.previousElementSibling;
-
- if (prevItem) {
- item.parentNode.insertBefore(item, prevItem);
- updateQuoteTotals();
- }
-}
-
-function moveQuoteItemDown(itemId) {
- const item = document.getElementById(`quote-item-${itemId}`);
- const nextItem = item.nextElementSibling;
-
- if (nextItem) {
- item.parentNode.insertBefore(nextItem, item);
- updateQuoteTotals();
- }
-}
-
-function updateQuoteTotals() {
- const items = getQuoteItems();
- const taxExempt = document.getElementById('quote-tax-exempt').checked;
-
- let subtotal = 0;
- let hasTbd = false;
-
- items.forEach(item => {
- if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
- hasTbd = true;
- } else {
- const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
- subtotal += amount;
- }
- });
-
- const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
- const total = subtotal + taxAmount;
-
- document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`;
- document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
- document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`;
-
- document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
-}
-
-function getQuoteItems() {
- const items = [];
- const itemDivs = document.querySelectorAll('#quote-items > div');
-
- itemDivs.forEach(div => {
- const descEditor = div.querySelector('.quote-item-description-editor');
- 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;
-}
-
-async function handleQuoteSubmit(e) {
- e.preventDefault();
-
- const items = getQuoteItems();
-
- if (items.length === 0) {
- alert('Please add at least one item');
- return;
- }
-
- const data = {
- customer_id: parseInt(document.getElementById('quote-customer').value),
- quote_date: document.getElementById('quote-date').value,
- tax_exempt: document.getElementById('quote-tax-exempt').checked,
- items: items
- };
-
- try {
- const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
- const method = currentQuoteId ? 'PUT' : 'POST';
-
- const response = await fetch(url, {
- method,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- });
-
- if (response.ok) {
- closeQuoteModal();
- loadQuotes();
- } else {
- alert('Error saving quote');
- }
- } catch (error) {
- console.error('Error:', error);
- alert('Error saving quote');
- }
-}
-
-async function editQuote(id) {
- await openQuoteModal(id);
-}
-
-async function deleteQuote(id) {
- if (!confirm('Are you sure you want to delete this quote?')) return;
-
- try {
- const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' });
- if (response.ok) {
- loadQuotes();
- } else {
- alert('Error deleting quote');
- }
- } catch (error) {
- console.error('Error:', error);
- alert('Error deleting quote');
- }
-}
-
-function viewQuotePDF(id) {
- window.open(`/api/quotes/${id}/pdf`, '_blank');
-}
-
-async function convertQuoteToInvoice(quoteId) {
- if (!confirm('Convert this quote to an invoice?')) return;
-
- try {
- const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, {
- method: 'POST'
- });
-
- if (response.ok) {
- const invoice = await response.json();
- alert(`Invoice ${invoice.invoice_number} created successfully!`);
- loadInvoices();
- showTab('invoices');
- } else {
- const error = await response.json();
- alert(error.error || 'Error converting quote to invoice');
- }
- } catch (error) {
- console.error('Error:', error);
- alert('Error converting quote to invoice');
- }
-}
-
-// Invoice Management - Same accordion pattern
-async function fetchNextInvoiceNumber() {
- try {
- const response = await fetch('/api/invoices/next-number');
- const data = await response.json();
- document.getElementById('invoice-number').value = data.next_number;
- } catch (error) {
- console.error('Error fetching next invoice number:', error);
- }
-}
-
-async function loadInvoices() {
- // Wird vom invoice-view.js Modul überschrieben (window.loadInvoices)
- // Dieser Fallback lädt die Daten falls das Modul noch nicht geladen ist
- try {
- const response = await fetch('/api/invoices');
- invoices = await response.json();
- // Falls das Modul geladen ist, nutze dessen Renderer
- if (window.invoiceView) {
- window.invoiceView.renderInvoiceView();
- } else {
- renderInvoices();
- }
- } catch (error) {
- console.error('Error loading invoices:', error);
- }
-}
-
-function renderInvoices() {
- if (window.invoiceView) {
- window.invoiceView.renderInvoiceView();
- return;
- }
- // Minimaler Fallback falls Modul nicht geladen
- const tbody = document.getElementById('invoices-list');
- tbody.innerHTML = invoices.map(invoice => `
-
- | ${invoice.invoice_number} |
- ${invoice.customer_name || 'N/A'} |
- ${formatDate(invoice.invoice_date)} |
- ${invoice.terms} |
- $${parseFloat(invoice.total).toFixed(2)} |
- Loading module... |
-
- `).join('');
-}
-
-async function openInvoiceModal(invoiceId = null) {
- currentInvoiceId = invoiceId;
- const modal = document.getElementById('invoice-modal');
- const title = document.getElementById('invoice-modal-title');
-
- if (invoiceId) {
- title.textContent = 'Edit Invoice';
- const response = await fetch(`/api/invoices/${invoiceId}`);
- const data = await response.json();
-
- // Set customer in Alpine component
- const allCust = window.getCustomers ? window.getCustomers() : customers;
- const customer = allCust.find(c => c.id === data.invoice.customer_id);
- if (customer) {
- const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
- if (customerInput) {
- customerInput.value = customer.name;
- customerInput.dispatchEvent(new Event('input'));
-
- const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
- if (alpineData) {
- alpineData.search = customer.name;
- alpineData.selectedId = customer.id;
- alpineData.selectedName = customer.name;
- }
- }
- }
-
- document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
- document.getElementById('invoice-customer').value = data.invoice.customer_id;
- const dateOnly = data.invoice.invoice_date.split('T')[0];
- document.getElementById('invoice-date').value = dateOnly;
- document.getElementById('invoice-terms').value = data.invoice.terms;
- document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
- document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
- document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
- // Scheduled Send Date
- const sendDateEl = document.getElementById('invoice-send-date');
- if (sendDateEl) {
- sendDateEl.value = data.invoice.scheduled_send_date
- ? data.invoice.scheduled_send_date.split('T')[0]
- : '';
- }
-
- // Load items
- document.getElementById('invoice-items').innerHTML = '';
- itemCounter = 0;
- data.items.forEach(item => {
- addInvoiceItem(item);
- });
-
- updateInvoiceTotals();
- } else {
- title.textContent = 'New Invoice';
- document.getElementById('invoice-form').reset();
- document.getElementById('invoice-items').innerHTML = '';
- document.getElementById('invoice-terms').value = 'Net 30';
- document.getElementById('invoice-number').value = ''; // Leer lassen!
- document.getElementById('invoice-send-date').value = '';
- itemCounter = 0;
- setDefaultDate();
-
- // KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO
-
- // Add one default item
- addInvoiceItem();
- }
-
- modal.classList.add('active');
-}
-
-function closeInvoiceModal() {
- document.getElementById('invoice-modal').classList.remove('active');
- currentInvoiceId = null;
-}
-
-function addInvoiceItem(item = null) {
- const itemId = itemCounter++;
- const itemsDiv = document.getElementById('invoice-items');
-
- const itemDiv = document.createElement('div');
- 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'} }`);
-
- // 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, 50) + (temp.textContent.length > 50 ? '...' : '');
- }
- // Preview Type Logic
- const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
-
- itemDiv.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
Qty: ${previewQty}
-
${typeLabel}
-
-
${previewDesc}
-
${previewAmount}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- itemsDiv.appendChild(itemDiv);
-
- // 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(); });
- editorDiv.quillInstance = quill;
-
- // 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"]');
-
- const calculateAmount = () => {
- if (qtyInput.value && rateInput.value) {
- const qty = parseFloat(qtyInput.value) || 0;
- const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
- amountInput.value = (qty * rateValue).toFixed(2);
- }
- updateInvoiceItemPreview(itemDiv, itemId);
- updateInvoiceTotals();
- };
-
- qtyInput.addEventListener('input', calculateAmount);
- rateInput.addEventListener('input', calculateAmount);
- amountInput.addEventListener('input', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
-
- updateInvoiceItemPreview(itemDiv, itemId);
- updateInvoiceTotals();
-}
-
-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, 50) + (plainText.length > 50 ? '...' : '');
- descPreview.textContent = preview || 'New item';
- }
-}
-function handleTypeChange(selectEl, itemId) {
- const itemDiv = selectEl.closest(`[id^=invoice-item-]`);
-
- // Wenn Labor gewählt und Rate leer → Labor Rate eintragen
- if (selectEl.value === '5' && qboLaborRate) {
- const rateInput = itemDiv.querySelector('[data-field="rate"]');
- if (rateInput && (!rateInput.value || rateInput.value === '0')) {
- rateInput.value = qboLaborRate;
- // Amount neu berechnen
- const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
- const amountInput = itemDiv.querySelector('[data-field="amount"]');
- if (qtyInput.value) {
- const qty = parseFloat(qtyInput.value) || 0;
- amountInput.value = (qty * qboLaborRate).toFixed(2);
- }
- }
- }
-
- updateInvoiceItemPreview(itemDiv, itemId);
- updateInvoiceTotals();
-}
-function removeInvoiceItem(itemId) {
- document.getElementById(`invoice-item-${itemId}`).remove();
- updateInvoiceTotals();
-}
-
-function moveInvoiceItemUp(itemId) {
- const item = document.getElementById(`invoice-item-${itemId}`);
- const prevItem = item.previousElementSibling;
-
- if (prevItem) {
- item.parentNode.insertBefore(item, prevItem);
- updateInvoiceTotals();
- }
-}
-
-function moveInvoiceItemDown(itemId) {
- const item = document.getElementById(`invoice-item-${itemId}`);
- const nextItem = item.nextElementSibling;
-
- if (nextItem) {
- item.parentNode.insertBefore(nextItem, item);
- updateInvoiceTotals();
- }
-}
-
-function updateInvoiceTotals() {
- const items = getInvoiceItems();
- const taxExempt = document.getElementById('invoice-tax-exempt').checked;
-
- let subtotal = 0;
-
- items.forEach(item => {
- const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
- subtotal += amount;
- });
-
- const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
- const total = subtotal + taxAmount;
-
- document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
- document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
- document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
-
- document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
-}
-
-function getInvoiceItems() {
- const items = [];
- const itemDivs = document.querySelectorAll('#invoice-items > div');
-
- itemDivs.forEach(div => {
- const descEditor = div.querySelector('.invoice-item-description-editor');
- 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;
-}
-
-async function handleInvoiceSubmit(e) {
- e.preventDefault();
-
- const data = {
- invoice_number: document.getElementById('invoice-number').value || null,
- customer_id: document.getElementById('invoice-customer').value,
- invoice_date: document.getElementById('invoice-date').value,
- terms: document.getElementById('invoice-terms').value,
- auth_code: document.getElementById('invoice-authorization').value,
- tax_exempt: document.getElementById('invoice-tax-exempt').checked,
- scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
- bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
- items: getInvoiceItems() // Deine bestehende Funktion
- };
-
- if (!data.customer_id) {
- alert('Please select a customer.');
- return;
- }
- if (!data.items || data.items.length === 0) {
- alert('Please add at least one item.');
- return;
- }
-
- const invoiceId = currentInvoiceId;
- const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
- const method = invoiceId ? 'PUT' : 'POST';
-
- // Spinner anzeigen
- showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
-
- try {
- const response = await fetch(url, {
- method,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- });
-
- const result = await response.json();
-
- if (response.ok) {
- closeInvoiceModal();
-
- // Info über QBO Status
- if (result.qbo_doc_number) {
- console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
- } else if (result.qbo_synced) {
- console.log('✅ Invoice saved & synced to QBO');
- } else {
- console.log('✅ Invoice saved locally (QBO sync pending)');
- }
-
- // Invoices neu laden
- if (window.invoiceView) window.invoiceView.loadInvoices();
- } else {
- alert(`Error: ${result.error}`);
- }
- } catch (error) {
- console.error('Error:', error);
- alert('Error saving invoice');
- } finally {
- hideSpinner();
- }
-}
-
-async function editInvoice(id) {
- await openInvoiceModal(id);
-}
-
-async function deleteInvoice(id) {
- if (!confirm('Are you sure you want to delete this invoice?')) return;
-
- try {
- const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
- if (response.ok) {
- loadInvoices();
- } else {
- alert('Error deleting invoice');
- }
- } catch (error) {
- console.error('Error:', error);
- alert('Error deleting invoice');
- }
-}
-
-function viewInvoicePDF(id) {
- if (window.invoiceView) {
- window.invoiceView.viewPDF(id);
- } else {
- window.open(`/api/invoices/${id}/pdf`, '_blank');
- }
-}
-
-async function checkQboOverdue() {
- const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
- const resultDiv = document.getElementById('qbo-result');
- const tbody = document.getElementById('qbo-result-list');
-
- // UI Loading State
- const originalText = btn.innerHTML;
- btn.innerHTML = '⏳ Connecting to QBO...';
- btn.disabled = true;
- resultDiv.classList.add('hidden');
- tbody.innerHTML = '';
-
- try {
- const response = await fetch('/api/qbo/overdue');
- const invoices = await response.json();
-
- if (response.ok) {
- resultDiv.classList.remove('hidden');
-
- if (invoices.length === 0) {
- tbody.innerHTML = '| ✅ Good news! No overdue invoices found older than 30 days. |
';
- } else {
- tbody.innerHTML = invoices.map(inv => `
-
- | ${inv.DocNumber || '(No Num)'} |
- ${inv.CustomerRef?.name || 'Unknown'} |
- ${inv.DueDate} |
- $${inv.Balance} |
-
- `).join('');
- }
- alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
- } else {
- throw new Error(invoices.error || 'Unknown error');
- }
- } catch (error) {
- console.error('QBO Test Error:', error);
- alert('❌ Connection Test Failed: ' + error.message);
- tbody.innerHTML = `| Error: ${error.message} |
`;
- resultDiv.classList.remove('hidden');
- } finally {
- btn.innerHTML = originalText;
- btn.disabled = false;
- }
-}
-
-async function importFromQBO() {
- if (!confirm(
- 'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
- '• Bereits importierte werden übersprungen\n' +
- '• Nur Kunden die lokal verknüpft sind\n\n' +
- 'Fortfahren?'
- )) return;
-
- const btn = document.querySelector('button[onclick="importFromQBO()"]');
- const resultDiv = document.getElementById('qbo-import-result');
-
- const originalText = btn.innerHTML;
- btn.innerHTML = '⏳ Importiere aus QBO...';
- btn.disabled = true;
- resultDiv.classList.add('hidden');
-
- try {
- const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
- const result = await response.json();
-
- resultDiv.classList.remove('hidden');
-
- if (response.ok) {
- let html = ``;
- html += `
Import abgeschlossen
`;
- html += `
`;
- html += `- ✅ ${result.imported} Rechnungen importiert
`;
-
- if (result.skipped > 0) {
- html += `- ⏭️ ${result.skipped} bereits vorhanden (übersprungen)
`;
- }
- if (result.skippedNoCustomer > 0) {
- html += `- ⚠️ ${result.skippedNoCustomer} übersprungen — Kunde nicht verknüpft:
`;
- html += `- ${result.skippedCustomerNames.join(', ')}
`;
- }
-
- html += `
`;
- resultDiv.innerHTML = html;
-
- // Invoice-Liste aktualisieren
- if (result.imported > 0) {
- loadInvoices();
- }
- } else {
- resultDiv.innerHTML = `
-
Import fehlgeschlagen
-
${result.error}
-
`;
- }
- } catch (error) {
- console.error('Import Error:', error);
- resultDiv.classList.remove('hidden');
- resultDiv.innerHTML = `
-
Netzwerkfehler beim Import.
-
`;
- } finally {
- btn.innerHTML = originalText;
- btn.disabled = false;
- }
-}
-// =====================================================
-// 3. Labor Rate laden und in addInvoiceItem verwenden
-// NEUE globale Variable + Lade-Funktion
-// =====================================================
-
-let qboLaborRate = null; // Wird beim Start geladen
-
-async function loadLaborRate() {
- try {
- const response = await fetch('/api/qbo/labor-rate');
- const data = await response.json();
- if (data.rate) {
- qboLaborRate = data.rate;
- console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
- }
- } catch (e) {
- console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.');
- }
-}
-// =====================================================
-// 5. Spinner Funktionen — NEUE Funktionen hinzufügen
-// Wird bei QBO-Operationen angezeigt
-// =====================================================
-
-function showSpinner(message = 'Bitte warten...') {
- let overlay = document.getElementById('qbo-spinner');
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.id = 'qbo-spinner';
- overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
- document.body.appendChild(overlay);
- }
- overlay.innerHTML = `
- `;
- overlay.style.display = 'flex';
-}
-
-function hideSpinner() {
- const overlay = document.getElementById('qbo-spinner');
- if (overlay) overlay.style.display = 'none';
-}
-
-
-
-window.openInvoiceModal = openInvoiceModal;
\ No newline at end of file
diff --git a/public/css/styles.css b/public/css/styles.css
new file mode 100644
index 0000000..f751a40
--- /dev/null
+++ b/public/css/styles.css
@@ -0,0 +1,54 @@
+/* styles.css — Application styles extracted from index.html */
+
+.modal {
+ display: none;
+}
+.modal.active {
+ display: flex;
+}
+
+/* Invoice/Quote Modal — visible field borders */
+#invoice-modal input,
+#invoice-modal select,
+#invoice-modal textarea,
+#quote-modal input,
+#quote-modal select,
+#quote-modal textarea {
+ border: 1.5px solid #9ca3af !important;
+}
+
+#invoice-modal input:focus,
+#invoice-modal select:focus,
+#invoice-modal textarea:focus,
+#quote-modal input:focus,
+#quote-modal select:focus,
+#quote-modal textarea:focus {
+ border-color: #3b82f6 !important;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
+}
+
+/* Rich Text Editor borders */
+#invoice-modal .ql-container,
+#invoice-modal .ql-toolbar,
+#quote-modal .ql-container,
+#quote-modal .ql-toolbar {
+ border: 1.5px solid #9ca3af !important;
+}
+
+.item-row input,
+.item-row select,
+.invoice-item input,
+.invoice-item select,
+#invoice-items input,
+#invoice-items select,
+#quote-items input,
+#quote-items select {
+ border: 1.5px solid #9ca3af !important;
+}
+
+#invoice-items > div,
+#quote-items > div,
+#invoice-items .border,
+#quote-items .border {
+ border: 1.5px solid #9ca3af !important;
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 8d40f41..41eef6c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,61 +7,12 @@
+
-
+
@@ -111,7 +62,7 @@
-
+
Invoices
@@ -120,7 +71,6 @@
-
@@ -209,6 +159,9 @@
+
+
+
QuickBooks Online Authorization
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
@@ -224,7 +177,6 @@
+
+
QuickBooks Online Connection Test
Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.
@@ -260,10 +214,10 @@
-
+
-
+
@@ -361,13 +315,9 @@
+ class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel
+ class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote
@@ -386,7 +336,7 @@