/**
* 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
};