refactoring
This commit is contained in:
parent
27ecafea5f
commit
e333628f1c
|
|
@ -23,7 +23,6 @@ COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY server.js ./
|
|
||||||
COPY qbo_helper.js ./
|
COPY qbo_helper.js ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
|
||||||
1192
public/app.js
1192
public/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -7,61 +7,12 @@
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||||
|
<script src="js/components/customer-search.js"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||||
<style>
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
.modal {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.modal.active {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
/* Invoice/Quote Modal - deutlichere Feldumrandungen */
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
/* Line Item Felder + Rich Text Editor */
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
|
@ -120,7 +71,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
|
||||||
<div id="invoice-toolbar"></div>
|
<div id="invoice-toolbar"></div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
|
@ -209,6 +159,9 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||||
<p class="text-gray-600 mb-4">
|
<p class="text-gray-600 mb-4">
|
||||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||||
|
|
@ -224,7 +177,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// QBO Status beim Laden prüfen
|
|
||||||
fetch('/api/qbo/status')
|
fetch('/api/qbo/status')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|
@ -240,6 +192,8 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
||||||
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
||||||
|
|
||||||
|
|
@ -361,13 +315,9 @@
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeQuoteModal()"
|
<button type="button" onclick="closeQuoteModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||||
Save Quote
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -495,35 +445,15 @@
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeInvoiceModal()"
|
<button type="button" onclick="closeInvoiceModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||||
Save Invoice
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<!-- Single module entry point — all JS loaded from here -->
|
||||||
<script type="module">
|
<script type="module" src="js/app.js"></script>
|
||||||
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// invoice-view-init.js — Bootstrap-Script (type="module")
|
|
||||||
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
|
||||||
import './payment-modal.js';
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
injectToolbar();
|
|
||||||
window.loadInvoices = loadInvoices;
|
|
||||||
window.renderInvoices = renderInvoiceView;
|
|
||||||
loadInvoices();
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* app.js — Application Bootstrap
|
||||||
|
*
|
||||||
|
* This is the main entry point. All business logic has been moved to modules:
|
||||||
|
* - js/views/quote-view.js → Quote list
|
||||||
|
* - js/views/invoice-view.js → Invoice list (existing)
|
||||||
|
* - js/views/settings-view.js → Logo, QBO import/test
|
||||||
|
* - js/modals/quote-modal.js → Quote create/edit
|
||||||
|
* - js/modals/invoice-modal.js → Invoice create/edit
|
||||||
|
* - js/modals/payment-modal.js → Payment recording (existing)
|
||||||
|
* - js/components/customer-search.js → Alpine dropdown
|
||||||
|
* - js/utils/item-editor.js → Shared accordion item editor
|
||||||
|
* - js/utils/helpers.js → formatDate, spinner
|
||||||
|
* - js/utils/api.js → API wrapper (existing)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Imports ---
|
||||||
|
import { loadQuotes } from './views/quote-view.js';
|
||||||
|
import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js';
|
||||||
|
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js';
|
||||||
|
import { checkCurrentLogo, initSettingsView } from './views/settings-view.js';
|
||||||
|
import { initQuoteModal } from './modals/quote-modal.js';
|
||||||
|
import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
|
||||||
|
import './modals/payment-modal.js';
|
||||||
|
import { setDefaultDate } from './utils/helpers.js';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
localStorage.setItem('activeTab', tabName);
|
||||||
|
|
||||||
|
if (tabName === 'quotes') {
|
||||||
|
loadQuotes();
|
||||||
|
} else if (tabName === 'invoices') {
|
||||||
|
injectInvoiceToolbar();
|
||||||
|
loadInvoices();
|
||||||
|
} else if (tabName === 'customers') {
|
||||||
|
injectCustomerToolbar();
|
||||||
|
renderCustomerView();
|
||||||
|
} else if (tabName === 'settings') {
|
||||||
|
checkCurrentLogo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Init
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Load shared data
|
||||||
|
loadCustomers();
|
||||||
|
loadLaborRate();
|
||||||
|
setDefaultDate();
|
||||||
|
|
||||||
|
// Init modals (wire up form handlers)
|
||||||
|
initQuoteModal();
|
||||||
|
initInvoiceModal();
|
||||||
|
initSettingsView();
|
||||||
|
|
||||||
|
// Restore saved tab (or default to quotes)
|
||||||
|
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||||
|
showTab(savedTab);
|
||||||
|
|
||||||
|
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||||
|
if (window.location.hash) {
|
||||||
|
const hashTab = window.location.hash.replace('#', '');
|
||||||
|
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||||
|
showTab(hashTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to HTML onclick handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.showTab = showTab;
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* customer-search.js — Alpine.js Customer Search Component
|
||||||
|
* Used in Quote and Invoice modals for customer dropdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
function customerSearch(type) {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
selectedId: '',
|
||||||
|
selectedName: '',
|
||||||
|
open: false,
|
||||||
|
highlighted: 0,
|
||||||
|
|
||||||
|
get filteredCustomers() {
|
||||||
|
const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||||
|
|
||||||
|
if (!this.search) {
|
||||||
|
return allCustomers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchLower = this.search.toLowerCase();
|
||||||
|
return allCustomers.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 globally available for Alpine x-data
|
||||||
|
window.customerSearch = customerSearch;
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* invoice-modal.js — Invoice create/edit modal
|
||||||
|
* Uses shared item-editor for accordion items
|
||||||
|
*/
|
||||||
|
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||||
|
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let currentInvoiceId = null;
|
||||||
|
let qboLaborRate = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load labor rate from QBO (called once at startup)
|
||||||
|
*/
|
||||||
|
export 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLaborRate() {
|
||||||
|
return qboLaborRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openInvoiceModal(invoiceId = null) {
|
||||||
|
currentInvoiceId = invoiceId;
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
await loadInvoiceForEdit(invoiceId);
|
||||||
|
} else {
|
||||||
|
prepareNewInvoice();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('invoice-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeInvoiceModal() {
|
||||||
|
document.getElementById('invoice-modal').classList.remove('active');
|
||||||
|
currentInvoiceId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvoiceForEdit(invoiceId) {
|
||||||
|
document.getElementById('invoice-modal-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() : (window.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;
|
||||||
|
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||||
|
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 || '';
|
||||||
|
|
||||||
|
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 using shared editor
|
||||||
|
document.getElementById('invoice-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
data.items.forEach(item => {
|
||||||
|
addItem('invoice-items', {
|
||||||
|
item,
|
||||||
|
type: 'invoice',
|
||||||
|
laborRate: qboLaborRate,
|
||||||
|
onUpdate: updateInvoiceTotals
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateInvoiceTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareNewInvoice() {
|
||||||
|
document.getElementById('invoice-modal-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 = '';
|
||||||
|
document.getElementById('invoice-send-date').value = '';
|
||||||
|
resetItemCounter();
|
||||||
|
setDefaultDate();
|
||||||
|
|
||||||
|
// Add one default item
|
||||||
|
addItem('invoice-items', {
|
||||||
|
type: 'invoice',
|
||||||
|
laborRate: qboLaborRate,
|
||||||
|
onUpdate: updateInvoiceTotals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInvoiceItem(item = null) {
|
||||||
|
addItem('invoice-items', {
|
||||||
|
item,
|
||||||
|
type: 'invoice',
|
||||||
|
laborRate: qboLaborRate,
|
||||||
|
onUpdate: updateInvoiceTotals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInvoiceTotals() {
|
||||||
|
const items = getItems('invoice-items');
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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: getItems('invoice-items')
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error saving invoice');
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up form submit and tax-exempt checkbox
|
||||||
|
export function initInvoiceModal() {
|
||||||
|
const form = document.getElementById('invoice-form');
|
||||||
|
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||||
|
|
||||||
|
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||||
|
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.openInvoiceModal = openInvoiceModal;
|
||||||
|
window.closeInvoiceModal = closeInvoiceModal;
|
||||||
|
window.addInvoiceItem = addInvoiceItem;
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* quote-modal.js — Quote create/edit modal
|
||||||
|
* Uses shared item-editor for accordion items
|
||||||
|
*/
|
||||||
|
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||||
|
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let currentQuoteId = null;
|
||||||
|
|
||||||
|
export function openQuoteModal(quoteId = null) {
|
||||||
|
currentQuoteId = quoteId;
|
||||||
|
|
||||||
|
if (quoteId) {
|
||||||
|
loadQuoteForEdit(quoteId);
|
||||||
|
} else {
|
||||||
|
prepareNewQuote();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('quote-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeQuoteModal() {
|
||||||
|
document.getElementById('quote-modal').classList.remove('active');
|
||||||
|
currentQuoteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuoteForEdit(quoteId) {
|
||||||
|
document.getElementById('quote-modal-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() : (window.customers || []);
|
||||||
|
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||||
|
if (customer) {
|
||||||
|
const customerInput = document.querySelector('#quote-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('quote-customer').value = data.quote.customer_id;
|
||||||
|
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||||
|
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||||
|
|
||||||
|
// Load items using shared editor
|
||||||
|
document.getElementById('quote-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
data.items.forEach(item => {
|
||||||
|
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateQuoteTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareNewQuote() {
|
||||||
|
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||||
|
document.getElementById('quote-form').reset();
|
||||||
|
document.getElementById('quote-items').innerHTML = '';
|
||||||
|
resetItemCounter();
|
||||||
|
setDefaultDate();
|
||||||
|
|
||||||
|
// Add one default item
|
||||||
|
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addQuoteItem(item = null) {
|
||||||
|
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateQuoteTotals() {
|
||||||
|
const items = getItems('quote-items');
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleQuoteSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const items = getItems('quote-items');
|
||||||
|
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();
|
||||||
|
if (window.quoteView) window.quoteView.loadQuotes();
|
||||||
|
} else {
|
||||||
|
alert('Error saving quote');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error saving quote');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up form submit and tax-exempt checkbox
|
||||||
|
export function initQuoteModal() {
|
||||||
|
const form = document.getElementById('quote-form');
|
||||||
|
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||||
|
|
||||||
|
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||||
|
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.openQuoteModal = openQuoteModal;
|
||||||
|
window.closeQuoteModal = closeQuoteModal;
|
||||||
|
window.addQuoteItem = addQuoteItem;
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* helpers.js — Shared UI utility functions
|
||||||
|
* Extracted from app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
export 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 = `
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideSpinner() {
|
||||||
|
const overlay = document.getElementById('qbo-spinner');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backward compat for onclick handlers and modules using typeof check
|
||||||
|
window.showSpinner = showSpinner;
|
||||||
|
window.hideSpinner = hideSpinner;
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
/**
|
||||||
|
* item-editor.js — Shared accordion item editor for Quotes and Invoices
|
||||||
|
*
|
||||||
|
* Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js';
|
||||||
|
* addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 });
|
||||||
|
*/
|
||||||
|
|
||||||
|
let itemCounter = 0;
|
||||||
|
|
||||||
|
export function resetItemCounter() {
|
||||||
|
itemCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemCounter() {
|
||||||
|
return itemCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item row to the specified container.
|
||||||
|
*
|
||||||
|
* @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items')
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object|null} options.item - Existing item data (null for new empty item)
|
||||||
|
* @param {string} options.type - 'quote' or 'invoice'
|
||||||
|
* @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only)
|
||||||
|
* @param {function} options.onUpdate - Callback after any change (for recalculating totals)
|
||||||
|
*/
|
||||||
|
export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) {
|
||||||
|
const itemId = itemCounter++;
|
||||||
|
const itemsDiv = document.getElementById(containerId);
|
||||||
|
if (!itemsDiv) return;
|
||||||
|
|
||||||
|
const prefix = type; // 'quote' or 'invoice'
|
||||||
|
const cssClass = `${prefix}-item-input`;
|
||||||
|
const editorClass = `${prefix}-item-description-editor`;
|
||||||
|
const amountClass = `${prefix}-item-amount`;
|
||||||
|
|
||||||
|
// Preview defaults
|
||||||
|
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 ? '...' : '');
|
||||||
|
}
|
||||||
|
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||||
|
|
||||||
|
const itemDiv = document.createElement('div');
|
||||||
|
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||||
|
itemDiv.id = `${prefix}-item-${itemId}`;
|
||||||
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
|
itemDiv.innerHTML = `
|
||||||
|
<div class="flex items-center p-4">
|
||||||
|
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" onclick="window.itemEditor.moveUp('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||||
|
<button type="button" onclick="window.itemEditor.moveDown('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<button type="button" onclick="window.itemEditor.remove('${prefix}', ${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>
|
||||||
|
|
||||||
|
<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="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="window.itemEditor.handleTypeChange(this, '${prefix}', ${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="${editorClass} 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="${cssClass} 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="${amountClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
itemsDiv.appendChild(itemDiv);
|
||||||
|
|
||||||
|
// --- Quill Rich Text Editor ---
|
||||||
|
const editorDiv = itemDiv.querySelector(`.${editorClass}`);
|
||||||
|
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);
|
||||||
|
onUpdate();
|
||||||
|
});
|
||||||
|
editorDiv.quillInstance = quill;
|
||||||
|
|
||||||
|
// --- Auto-calculate Amount ---
|
||||||
|
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) {
|
||||||
|
// Quote supports TBD
|
||||||
|
if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') {
|
||||||
|
// Don't auto-calculate for TBD
|
||||||
|
} else {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
amountInput.value = (qty * rateValue).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
|
amountInput.addEventListener('input', () => {
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store metadata on the div for later retrieval
|
||||||
|
itemDiv._itemEditor = { type, laborRate, onUpdate };
|
||||||
|
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the collapsed preview bar of an item
|
||||||
|
*/
|
||||||
|
function updateItemPreview(itemDiv) {
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]');
|
||||||
|
const editorDivs = itemDiv.querySelectorAll('[data-field="description"]');
|
||||||
|
const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
|
if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
if (typePreview && typeInput) {
|
||||||
|
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descPreview && editorDiv && editorDiv.quillInstance) {
|
||||||
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
|
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||||
|
descPreview.textContent = preview || 'New item';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle type change (Labor/Parts).
|
||||||
|
* When Labor is selected and rate is empty, auto-fill with labor rate.
|
||||||
|
*/
|
||||||
|
export function handleTypeChange(selectEl, prefix, itemId) {
|
||||||
|
const itemDiv = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!itemDiv) return;
|
||||||
|
|
||||||
|
const meta = itemDiv._itemEditor || {};
|
||||||
|
const laborRate = meta.laborRate;
|
||||||
|
const onUpdate = meta.onUpdate || (() => {});
|
||||||
|
|
||||||
|
// Auto-fill labor rate when switching to Labor and rate is empty
|
||||||
|
if (selectEl.value === '5' && laborRate) {
|
||||||
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
|
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||||
|
rateInput.value = laborRate;
|
||||||
|
// Recalculate amount
|
||||||
|
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 * laborRate).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemPreview(itemDiv);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all items from a container as an array of objects.
|
||||||
|
*/
|
||||||
|
export function getItems(containerId) {
|
||||||
|
const items = [];
|
||||||
|
const itemDivs = document.querySelectorAll(`#${containerId} > div`);
|
||||||
|
|
||||||
|
itemDivs.forEach(div => {
|
||||||
|
const descEditor = div.querySelector('[data-field="description"]');
|
||||||
|
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||||
|
? descEditor.quillInstance.root.innerHTML
|
||||||
|
: '';
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item by prefix and itemId
|
||||||
|
*/
|
||||||
|
export function removeItem(prefix, itemId) {
|
||||||
|
const el = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!el) return;
|
||||||
|
const meta = el._itemEditor || {};
|
||||||
|
el.remove();
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item up
|
||||||
|
*/
|
||||||
|
export function moveItemUp(prefix, itemId) {
|
||||||
|
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!item) return;
|
||||||
|
const prevItem = item.previousElementSibling;
|
||||||
|
if (prevItem) {
|
||||||
|
item.parentNode.insertBefore(item, prevItem);
|
||||||
|
const meta = item._itemEditor || {};
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item down
|
||||||
|
*/
|
||||||
|
export function moveItemDown(prefix, itemId) {
|
||||||
|
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||||
|
if (!item) return;
|
||||||
|
const nextItem = item.nextElementSibling;
|
||||||
|
if (nextItem) {
|
||||||
|
item.parentNode.insertBefore(nextItem, item);
|
||||||
|
const meta = item._itemEditor || {};
|
||||||
|
if (meta.onUpdate) meta.onUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to window for onclick handlers in HTML
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.itemEditor = {
|
||||||
|
moveUp: moveItemUp,
|
||||||
|
moveDown: moveItemDown,
|
||||||
|
remove: removeItem,
|
||||||
|
handleTypeChange: handleTypeChange
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* quote-view.js — Quote list rendering and actions
|
||||||
|
* Analog to invoice-view.js
|
||||||
|
*/
|
||||||
|
import { formatDate } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
let quotes = [];
|
||||||
|
|
||||||
|
export async function loadQuotes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/quotes');
|
||||||
|
quotes = await response.json();
|
||||||
|
renderQuotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading quotes:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotesData() {
|
||||||
|
return quotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQuotes() {
|
||||||
|
const tbody = document.getElementById('quotes-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
tbody.innerHTML = quotes.map(quote => {
|
||||||
|
const total = quote.has_tbd
|
||||||
|
? `$${parseFloat(quote.total).toFixed(2)}*`
|
||||||
|
: `$${parseFloat(quote.total).toFixed(2)}`;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
|
<button onclick="window.quoteView.viewPDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||||
|
<button onclick="window.quoteView.convertToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
|
||||||
|
<button onclick="window.quoteView.edit(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
|
<button onclick="window.quoteView.remove(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (quotes.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No quotes found.</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewPDF(id) {
|
||||||
|
window.open(`/api/quotes/${id}/pdf`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function edit(id) {
|
||||||
|
if (typeof window.openQuoteModal === 'function') {
|
||||||
|
await window.openQuoteModal(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertToInvoice(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 || '(Draft)'} created successfully!`);
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
|
if (typeof window.showTab === 'function') window.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.quoteView = {
|
||||||
|
loadQuotes,
|
||||||
|
renderQuotes,
|
||||||
|
viewPDF,
|
||||||
|
edit,
|
||||||
|
remove,
|
||||||
|
convertToInvoice
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* settings-view.js — Logo upload, QBO import, QBO connection test
|
||||||
|
* Extracted from app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentLogoFile = null;
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 = '<p class="text-blue-600">Uploading...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/upload-logo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
|
||||||
|
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 = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSettingsView() {
|
||||||
|
const logoUpload = document.getElementById('logo-upload');
|
||||||
|
if (logoUpload) {
|
||||||
|
logoUpload.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkQboOverdue() {
|
||||||
|
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||||
|
const resultDiv = document.getElementById('qbo-result');
|
||||||
|
const tbody = document.getElementById('qbo-result-list');
|
||||||
|
|
||||||
|
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 = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = invoices.map(inv => `
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
|
||||||
|
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
|
||||||
|
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
|
||||||
|
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
|
||||||
|
</tr>
|
||||||
|
`).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 = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
|
||||||
|
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
|
||||||
|
html += `<ul class="text-sm text-gray-700 space-y-1">`;
|
||||||
|
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
|
||||||
|
}
|
||||||
|
if (result.skippedNoCustomer > 0) {
|
||||||
|
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
|
||||||
|
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</ul></div>`;
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
|
||||||
|
if (result.imported > 0 && window.invoiceView) {
|
||||||
|
window.invoiceView.loadInvoices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
|
||||||
|
<p class="text-sm text-red-600 mt-1">${result.error}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import Error:', error);
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="text-red-600">Netzwerkfehler beim Import.</p>
|
||||||
|
</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for onclick handlers
|
||||||
|
window.uploadLogo = uploadLogo;
|
||||||
|
window.checkQboOverdue = checkQboOverdue;
|
||||||
|
window.importFromQBO = importFromQBO;
|
||||||
Loading…
Reference in New Issue