/** * 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 = `
Qty: ${previewQty} ${typeLabel} ${previewDesc} ${previewAmount}
`; 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 };