mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
273 lines
11 KiB
JavaScript
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();
|
|
}
|