/*
* The lines on an invoice, quote or recurring invoice page
* (includes/billing_paper.php):
*
* - the add-a-line row: type a product's name or code and pick it to fill
* in its description, price and tax; the amount it comes to shows as you
* type; after adding, the page comes back here ready for the next
* - drag a line by its handle to move it - saved as it drops
* - the line just edited is picked out when the page comes back
*
* Call once the page has loaded:
*
* itflowBillingItems({ products: [...], csrf: '...', currency: 'USD', locale: 'en-US' });
*
* products is what getProductsForAutocomplete() gives.
*
* itflowBillingLineForm(form, options) does the product pick and the amount
* for any other line form - the Edit Line window uses it.
*/
function itflowBillingMoney(amount, currency, locale) {
try {
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: currency || 'USD' }).format(amount);
} catch (e) {
return (Math.round(amount * 100) / 100).toFixed(2);
}
}
/* A number from a form field - "1,250.50", "$99" - or NaN */
function itflowBillingNumber(value) {
var text = String(value == null ? '' : value).replace(/[\s,$€£]/g, '');
return text === '' ? NaN : Number(text);
}
/* A product's default billing in words - "Monthly, per user" - or '' for one time */
function itflowBillingBilledText(frequency, seat) {
var label = { month: 'Monthly', quarter: 'Quarterly', year: 'Yearly' }[frequency || ''];
if (!label) {
return '';
}
var per = { contacts: 'per user', assets: 'per device' }[seat || ''];
return label + (per ? ', ' + per : '');
}
/* Choose an option by value, when the select has it, and let the select's own handlers know */
function itflowBillingPick(select, value) {
var has = Array.prototype.some.call(select.options, function (option) {
return option.value === value && !option.disabled;
});
if (has && select.value !== value) {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function itflowBillingLineForm(root, options) {
if (!root) {
return;
}
var opts = options || {};
var name = root.querySelector('[data-line-name]');
var description = root.querySelector('[data-line-description]');
var qty = root.querySelector('[data-line-qty]');
var price = root.querySelector('[data-line-price]');
var tax = root.querySelector('[data-line-tax]');
var preview = root.querySelector('[data-line-preview]');
// The hidden product id can sit in a separate form (the add row's inputs use form="")
var product = root.querySelector('[data-line-product]') || (opts.productInput || null);
function update() {
if (!preview) {
return;
}
var q = itflowBillingNumber(qty && qty.value !== '' ? qty.value : '1');
var p = itflowBillingNumber(price ? price.value : '');
if (isNaN(q) || isNaN(p)) {
preview.textContent = price && price.value !== '' ? 'Not a number' : '';
return;
}
var option = tax && tax.options[tax.selectedIndex];
var percent = option ? Number(option.getAttribute('data-percent') || 0) : 0;
var subtotal = Math.round(q * p * 100) / 100;
var total = subtotal + Math.round(subtotal * percent) / 100;
preview.textContent = itflowBillingMoney(total, opts.currency, opts.locale);
}
[qty, price, tax].forEach(function (el) {
if (el) {
el.addEventListener('input', update);
el.addEventListener('change', update);
}
});
update();
// The description grows with what is typed into it
if (description && description.tagName === 'TEXTAREA') {
var grow = function () {
description.style.height = 'auto';
description.style.height = Math.min(description.scrollHeight + 2, 240) + 'px';
};
description.addEventListener('input', grow);
if (description.value) {
grow();
}
}
if (!name || !opts.products || !opts.products.length || typeof itflowAutocomplete !== 'function') {
return;
}
itflowAutocomplete(name, {
minLength: 1,
source: opts.products,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var isService = String(item.type || '').toLowerCase() === 'service';
var tracked = String(item.track_stock) === '1';
var stock = Number(item.available_stock || 0);
var billed = itflowBillingBilledText(item.frequency, item.seat_source);
return "
" +
"
" +
"
" + esc(item.label) + "
" +
(item.description ? "
" + esc(item.description) + "
" : "") +
"
" +
"" + (isService ? 'Service' : 'Product') + "" +
(item.tax_percent != null ? "Tax " + esc(parseFloat(item.tax_percent)) + "%" : "") +
(billed ? "" + esc(billed) + "" : "") +
(tracked ? "" + esc(stock) + " in stock" : "") +
"
" +
"
" +
"
" + esc(item.price != null && item.price !== '' ? itflowBillingMoney(Number(item.price), opts.currency, opts.locale) : '') + "
" +
"
";
},
onSelect: function (item) {
name.value = item.product_name;
if (description) {
description.value = item.description || '';
description.dispatchEvent(new Event('input', { bubbles: true }));
}
if (qty && (qty.value === '' || qty.value === '0')) {
qty.value = 1;
}
if (price) {
price.value = item.price;
}
if (tax) {
tax.value = item.tax || 0;
if (tax.selectedIndex < 0) {
tax.value = 0;
}
tax.dispatchEvent(new Event('change', { bubbles: true }));
}
if (product) {
product.value = item.prod_id;
}
// How it is billed by default - Billed on a quote, Counted on a recurring invoice
var billedSelect = root.querySelector('select[name="frequency"]');
if (billedSelect) {
itflowBillingPick(billedSelect, item.frequency || '');
}
var seatSelect = root.querySelector('select[name="seat_source"]');
if (seatSelect) {
itflowBillingPick(seatSelect, item.seat_source || '');
}
update();
if (price) {
price.focus();
price.select();
}
}
});
// Typing over the name by hand breaks the link to the product
if (product) {
name.addEventListener('input', function () {
product.value = 0;
});
}
}
function itflowBillingItems(options) {
var opts = options || {};
var paper = document.querySelector('.itflow-paper[data-billing-type]');
if (!paper) {
return;
}
var type = paper.getAttribute('data-billing-type');
var docId = paper.getAttribute('data-billing-id');
var addRow = paper.querySelector('[data-billing-add]');
var addForm = document.getElementById('billingAddLine');
if (addRow) {
itflowBillingLineForm(addRow, {
products: opts.products,
currency: opts.currency,
locale: opts.locale,
productInput: addForm ? addForm.querySelector('[data-line-product]') : null
});
// Per user / per device: the quantity is the count, not typed
var addSeat = addRow.querySelector('[data-add-seat]');
var addQty = addRow.querySelector('[data-line-qty]');
if (addSeat && addQty) {
addSeat.addEventListener('change', function () {
var option = addSeat.options[addSeat.selectedIndex];
var counted = addSeat.value !== '';
if (counted) {
addQty.value = option.getAttribute('data-count') || '0';
}
addQty.readOnly = counted;
addQty.classList.toggle('bg-body-tertiary', counted);
addQty.dispatchEvent(new Event('input', { bubbles: true }));
});
}
// Enter in the name, quantity or price adds the line
addRow.querySelectorAll('input').forEach(function (input) {
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.defaultPrevented && !document.querySelector('.itflow-ac-menu.show')) {
e.preventDefault();
if (addForm && addForm.reportValidity()) {
addForm.requestSubmit ? addForm.requestSubmit() : addForm.submit();
}
}
});
});
}
// Back from adding a line - ready for the next; back from editing one - show which
var hash = window.location.hash;
if (hash === '#add-line' && addRow) {
var nameInput = addRow.querySelector('[data-line-name]');
addRow.scrollIntoView({ block: 'center' });
if (nameInput) {
nameInput.focus();
}
} else if (/^#line-\d+$/.test(hash)) {
var line = document.querySelector(hash);
if (line) {
line.scrollIntoView({ block: 'center' });
line.classList.add('itflow-paper-flash');
}
}
// Drag to reorder
var body = paper.querySelector('[data-billing-items] tbody');
if (body && typeof Sortable !== 'undefined' && paper.querySelector('.drag-handle')) {
// A quote's lines sit under a heading for how often each is billed - a line moves within its own
var grouped = !!body.querySelector('tr.itflow-paper-group');
new Sortable(body, {
handle: '.drag-handle',
draggable: 'tr[data-item-id]',
animation: 150,
onMove: function (evt) {
if (!grouped) {
return true;
}
var before = evt.willInsertAfter ? evt.related : evt.related.previousElementSibling;
return !!before && before.getAttribute('data-group') === evt.dragged.getAttribute('data-group');
},
onEnd: function (evt) {
if (evt.oldIndex === evt.newIndex) {
return;
}
var ids = Array.prototype.map.call(body.querySelectorAll('tr[data-item-id]'), function (row) {
return row.getAttribute('data-item-id');
});
itflowPost('ajax.php', {
billing_items_order: 1,
type: type,
doc_id: docId,
items: ids.join(','),
csrf_token: opts.csrf
}, function () {
if (typeof itflowToast === 'function') {
itflowToast('Order saved', 'success');
}
}, function () {
if (typeof itflowToast === 'function') {
itflowToast("The new order didn't save - reloading", 'error');
}
setTimeout(function () { window.location.reload(); }, 1200);
});
}
});
}
}
/*
* The seat part of a recurring invoice's line form: pick where the quantity
* comes from - users, devices, a license's seats - and narrow it. The count
* comes from ajax.php (recurring_seat_count) as it changes, fills in the
* quantity, and the quantity can't be typed while it is counted.
*/
function itflowBillingSeatForm(form, recurringInvoiceId) {
var source = form.querySelector('[data-seat-source]');
var qty = form.querySelector('[data-line-qty]');
var preview = form.querySelector('[data-seat-preview]');
if (!source || !qty) {
return;
}
var timer = null;
function sections() {
form.querySelectorAll('[data-seat-for]').forEach(function (section) {
var on = section.getAttribute('data-seat-for') === source.value || (section.getAttribute('data-seat-for') === 'any' && source.value !== '');
section.hidden = !on;
// Hidden sections send nothing - the contacts' and devices' tag pickers share a name
section.querySelectorAll('input, select').forEach(function (input) {
input.disabled = !on;
});
});
qty.readOnly = source.value !== '';
qty.classList.toggle('bg-body-tertiary', source.value !== '');
}
function count() {
if (source.value === '') {
return;
}
var params = new URLSearchParams();
params.append('recurring_seat_count', '1');
params.append('recurring_invoice_id', recurringInvoiceId);
params.append('source', source.value);
form.querySelectorAll('[data-seat-for] input:not([disabled]), [data-seat-for] select:not([disabled])').forEach(function (input) {
if ((input.type === 'checkbox' && !input.checked) || !input.name) {
return;
}
params.append(input.name, input.value);
});
if (preview) {
preview.textContent = 'Counting...';
}
fetch('ajax.php?' + params.toString(), { credentials: 'same-origin' })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.error) {
preview.textContent = data.error;
return;
}
qty.value = data.quantity;
qty.dispatchEvent(new Event('input', { bubbles: true }));
if (preview) {
preview.innerHTML = '';
var strong = document.createElement('strong');
strong.textContent = data.count;
preview.append('Counts ', strong, ' now - ' + data.label);
if (Number(data.quantity) !== Number(data.count)) {
preview.append(' - billed at the minimum, ' + data.quantity);
}
}
})
.catch(function () {
if (preview) {
preview.textContent = "Couldn't count them just now - it is counted again when saved.";
}
});
}
function changed() {
sections();
clearTimeout(timer);
timer = setTimeout(count, 150);
}
source.addEventListener('change', changed);
form.querySelectorAll('[data-seat-input]').forEach(function (input) {
input.addEventListener(input.tagName === 'INPUT' && input.type === 'text' ? 'input' : 'change', changed);
});
sections();
count();
}