Files
itflow/js/unbilled_time.js

273 lines
11 KiB
JavaScript

/*
* Unbilled Time (agent/unbilled_time.php).
*
* One client: tick the tickets to bill and change their hours - each row's
* amount, and the total at the rate and tax picked, follow as you type. With
* the rate left empty, each ticket is at its own (a contract's sets some). The
* same for its expenses and their prices, each with its margin on its cost,
* and its trips and their prices. Every client: tick the clients to bill -
* the total is what their tickets, expenses and trips come to now. Either way the button says what it will make,
* and the new invoice's date, category and scope only show when there is a
* new one.
*
* Needs js/billing_items.js (itflowBillingMoney, itflowBillingNumber).
*
* itflowUnbilledTime({ currency: 'USD', locale: 'en-US' });
*/
function itflowUnbilledTime(options) {
var opts = options || {};
var form = document.querySelector('form[data-unbilled]');
if (!form) {
return;
}
var mode = form.getAttribute('data-unbilled');
var rows = Array.prototype.slice.call(form.querySelectorAll('[data-unbilled-row]'));
var expenseRows = Array.prototype.slice.call(form.querySelectorAll('[data-unbilled-expense-row]'));
var tripRows = Array.prototype.slice.call(form.querySelectorAll('[data-unbilled-trip-row]'));
var all = form.querySelector('[data-unbilled-all]');
var expenseAll = form.querySelector('[data-unbilled-expense-all]');
var tripAll = form.querySelector('[data-unbilled-trip-all]');
var rate = form.querySelector('[data-unbilled-rate]');
var tax = form.querySelector('[data-unbilled-tax]');
var target = form.querySelector('[data-unbilled-target]');
var summary = form.querySelector('[data-unbilled-summary]');
var total = form.querySelector('[data-unbilled-total]');
var submit = form.querySelector('[data-unbilled-submit]');
var submitLabel = form.querySelector('[data-unbilled-submit-label]');
function money(amount) {
return itflowBillingMoney(amount, opts.currency, opts.locale);
}
function number(value) {
var n = itflowBillingNumber(value);
return isNaN(n) ? 0 : n;
}
function plural(n, word) {
return n + ' ' + word + (n === 1 ? '' : 's');
}
function update() {
var picked = 0;
var tickets = 0;
var expenses = 0;
var trips = 0;
var hours = 0;
var amount = 0;
var perHour = rate ? number(rate.value) : 0;
var option = tax && tax.options[tax.selectedIndex];
var percent = option ? Number(option.getAttribute('data-percent') || 0) : 0;
rows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-pick]');
var on = !pick || pick.checked;
row.classList.toggle('itflow-unbilled-off', !on);
if (mode === 'client') {
var input = row.querySelector('[data-unbilled-hours]');
var h = input ? number(input.value) : 0;
// The rate typed for them all, or - left empty - the ticket's own
var rowRate = rate && rate.value.trim() === '' ? Number(row.getAttribute('data-rate') || 0) : perHour;
var line = Math.round(h * rowRate * 100) / 100;
var cell = row.querySelector('[data-unbilled-amount]');
if (cell && input) {
cell.textContent = money(line);
}
if (input) {
input.classList.toggle('is-invalid', input.value.trim() !== '' && (isNaN(itflowBillingNumber(input.value)) || h < 0));
}
if (on) {
picked++;
tickets++;
hours += h;
amount += line;
}
} else if (on) {
picked++;
tickets += Number(row.getAttribute('data-tickets') || 0);
expenses += Number(row.getAttribute('data-expenses') || 0);
trips += Number(row.getAttribute('data-trips') || 0);
hours += Number(row.getAttribute('data-hours') || 0);
amount += Number(row.getAttribute('data-value') || 0);
}
});
// A client's expenses - each at its price, with the margin on its cost
var expensePicked = 0;
expenseRows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-expense-pick]');
var on = !pick || pick.checked;
var input = row.querySelector('[data-unbilled-expense-price]');
var price = input ? number(input.value) : 0;
var cost = Number(row.getAttribute('data-cost') || 0);
var margin = row.querySelector('[data-unbilled-expense-margin]');
row.classList.toggle('itflow-unbilled-off', !on);
if (input) {
input.classList.toggle('is-invalid', input.value.trim() !== '' && (isNaN(itflowBillingNumber(input.value)) || price < 0));
if (margin) {
margin.textContent = price > 0 ? Math.round((price - cost) / price * 100) + '% margin' : 'No charge';
margin.classList.toggle('text-danger-emphasis', price < cost);
}
}
if (on && pick) {
expensePicked++;
expenses++;
amount += Math.round(price * 100) / 100;
}
});
if (expenseAll) {
expenseAll.checked = expensePicked > 0 && expensePicked === expenseRows.length;
expenseAll.indeterminate = expensePicked > 0 && expensePicked < expenseRows.length;
}
// A client's trips - each at its price
var tripPicked = 0;
tripRows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-trip-pick]');
var on = !pick || pick.checked;
var input = row.querySelector('[data-unbilled-trip-price]');
var price = input ? number(input.value) : 0;
row.classList.toggle('itflow-unbilled-off', !on);
if (input) {
input.classList.toggle('is-invalid', input.value.trim() !== '' && (isNaN(itflowBillingNumber(input.value)) || price < 0));
}
if (on && pick) {
tripPicked++;
trips++;
amount += Math.round(price * 100) / 100;
}
});
if (tripAll) {
tripAll.checked = tripPicked > 0 && tripPicked === tripRows.length;
tripAll.indeterminate = tripPicked > 0 && tripPicked < tripRows.length;
}
var taxed = amount + Math.round(amount * percent) / 100;
hours = Math.round(hours * 100) / 100;
if (summary) {
var parts = mode === 'client' ? [] : [plural(picked, 'client')];
if (tickets || (!expenses && !trips)) {
parts.push(plural(tickets, 'ticket'), hours + ' h');
}
if (expenses) {
parts.push(plural(expenses, 'expense'));
}
if (trips) {
parts.push(plural(trips, 'trip'));
}
summary.textContent = parts.join(' · ') + (percent ? ' · with tax' : '');
}
if (total) {
total.textContent = money(taxed);
}
if (mode === 'client') {
picked += expensePicked + tripPicked;
}
if (submit) {
submit.disabled = picked === 0;
}
if (submitLabel) {
if (mode === 'client') {
submitLabel.textContent = target && target.value !== '0' ? 'Add to Invoice' : 'Make Invoice';
} else {
submitLabel.textContent = picked === 1 ? 'Make 1 Invoice' : 'Make ' + picked + ' Invoices';
}
}
if (all) {
var rowsPicked = picked - (mode === 'client' ? expensePicked + tripPicked : 0);
all.checked = rowsPicked > 0 && rowsPicked === rows.length;
all.indeterminate = rowsPicked > 0 && rowsPicked < rows.length;
}
// Not billable needs something ticked too
form.querySelectorAll('button[name="unbilled_write_off"]').forEach(function (button) {
button.disabled = picked === 0;
});
}
// A new invoice's own fields - only when one is made
function targetChanged() {
var making = !target || mode !== 'client' || target.value === '0';
form.querySelectorAll('[data-unbilled-new]').forEach(function (section) {
section.hidden = !making;
section.querySelectorAll('input, select').forEach(function (input) {
input.disabled = !making;
});
});
update();
}
if (all) {
all.addEventListener('change', function () {
rows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-pick]');
if (pick) {
pick.checked = all.checked;
}
});
update();
});
}
if (expenseAll) {
expenseAll.addEventListener('change', function () {
expenseRows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-expense-pick]');
if (pick) {
pick.checked = expenseAll.checked;
}
});
update();
});
}
if (tripAll) {
tripAll.addEventListener('change', function () {
tripRows.forEach(function (row) {
var pick = row.querySelector('[data-unbilled-trip-pick]');
if (pick) {
pick.checked = tripAll.checked;
}
});
update();
});
}
form.addEventListener('input', function (e) {
if (e.target.matches('[data-unbilled-hours], [data-unbilled-rate], [data-unbilled-expense-price], [data-unbilled-trip-price]')) {
update();
}
});
form.addEventListener('change', function (e) {
if (e.target.matches('[data-unbilled-pick], [data-unbilled-expense-pick], [data-unbilled-trip-pick], [data-unbilled-tax]')) {
update();
} else if (e.target === target) {
targetChanged();
}
});
// Clicking a ticket's, expense's or trip's row (not its link or box) ticks it
rows.concat(expenseRows, tripRows).forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('a, input, button, select, label')) {
return;
}
var pick = row.querySelector('[data-unbilled-pick], [data-unbilled-expense-pick], [data-unbilled-trip-pick]');
if (pick) {
pick.checked = !pick.checked;
update();
}
});
});
// Enter in an hours or price box moves to the next one rather than billing
form.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' || !e.target.matches('[data-unbilled-hours], [data-unbilled-expense-price], [data-unbilled-trip-price]')) {
return;
}
e.preventDefault();
var inputs = Array.prototype.slice.call(form.querySelectorAll('[data-unbilled-hours], [data-unbilled-expense-price], [data-unbilled-trip-price]'));
var next = inputs[inputs.indexOf(e.target) + 1];
if (next) {
next.focus();
next.select();
}
});
targetChanged();
}