293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
/**
|
||
* 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
|
||
}; |