refactoring
This commit is contained in:
parent
27ecafea5f
commit
e333628f1c
|
|
@ -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
|
||||
|
|
|
|||
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>
|
||||
<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="js/components/customer-search.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/png" sizes="32x32" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||
<style>
|
||||
.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>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="min-h-screen">
|
||||
|
|
@ -120,7 +71,6 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
||||
<div id="invoice-toolbar"></div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
|
|
@ -209,6 +159,9 @@
|
|||
</button>
|
||||
|
||||
<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>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||
|
|
@ -224,7 +177,6 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// QBO Status beim Laden prüfen
|
||||
fetch('/api/qbo/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
|
@ -240,6 +192,8 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<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>
|
||||
|
||||
|
|
@ -361,13 +315,9 @@
|
|||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeQuoteModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Quote
|
||||
</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -495,35 +445,15 @@
|
|||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeInvoiceModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Invoice
|
||||
</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script type="module">
|
||||
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './customer-view.js';
|
||||
|
||||
// Override showTab to inject customer toolbar
|
||||
const originalShowTab = window.showTab;
|
||||
window.showTab = function(tab) {
|
||||
originalShowTab(tab);
|
||||
if (tab === 'customers') {
|
||||
injectCustomerToolbar();
|
||||
renderCustomerView();
|
||||
}
|
||||
};
|
||||
|
||||
// Load customers on init (needed for quote/invoice dropdowns)
|
||||
loadCustomers();
|
||||
</script>
|
||||
<script type="module" src="invoice-view-init.js"></script>
|
||||
<!-- Single module entry point — all JS loaded from here -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</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