Files
itflow/js/expense_import.js
johnnyq 53d0f8a841 Expenses stage 4: getting expenses in - Import Statement from the expenses menu brings a card or bank CSV in as expenses, reading lines before the header, commas, semicolons or tabs, Windows text and amounts like (12.00), 12.00 CR or 1.234,56, guessing the date and its format, description, memo, amount or money out and in columns, sign and reference and remembering them per account so the next statement goes straight to the review, matching each line to a vendor from what was picked for that merchant last time or a vendor name in the text (shortened names too) with its last category, suggesting a new vendor named from the line when nothing matches, locking lines imported before so a statement never comes in twice, leaving out lines that look entered by hand within 4 days and linking them, leaving out money in unless ticked, filling in the same merchant's other lines, bulk category and client and billing on, making new vendors once and opening the account's expenses for those dates, with remembered merchants to see or forget; paid personally expenses with who paid them that count as spent on their date but move no balance until paid back, a To reimburse pill with what each agent is owed, Reimburse paying back the ticked expenses from one account on one date as a Reimbursement in Transactions with undo for full financial access, quick look, list, vendor page, bulk, export and dashboard showing who paid and whether they were paid back, database 3.0.2 with expense_import_key, expense_paid_by, expense_reimbursement_id, expense_import_rules, reimbursements and account_import_map, CHANGELOG updated
2026-10-16 19:12:03 -04:00

219 lines
8.9 KiB
JavaScript

/*
* Import Statement's review (agent/expense_import.php?step=review).
*
* Each line's vendor, category and client start as they were matched. A
* vendor picked for a line fills in its category (the vendor's last one)
* and the same vendor on the other lines from that merchant that nothing
* matched yet; + New vendor shows a name to make it with. Bill on shows
* once there is a client. Lines not new (money in, lines already in) are
* hidden until asked for. The ticked lines need a vendor and category;
* the summary and button follow the ticks. On Import every line's choices
* go as one JSON field, so a long statement stays under max_input_vars.
*
* Needs js/billing_items.js for itflowBillingMoney when it is loaded, and
* falls back to Intl otherwise.
*
* itflowExpenseImport({ currency: 'USD', locale: 'en-US', defaults: { vendor_id: category_id } });
*/
function itflowExpenseImport(options) {
var opts = options || {};
var form = document.getElementById('expenseImportReview');
if (!form || form.dataset.wired) {
return;
}
form.dataset.wired = '1';
var rows = Array.prototype.slice.call(form.querySelectorAll('[data-import-row]'));
var all = form.querySelector('[data-import-all]');
var showAll = form.querySelector('[data-import-show-all]');
var summary = form.querySelector('[data-import-summary]');
var submit = form.querySelector('[data-import-submit]');
var submitLabel = form.querySelector('[data-import-submit-label]');
var picks = form.querySelector('[data-import-picks]');
var bulkCategory = form.querySelector('[data-import-bulk-category]');
var bulkClient = form.querySelector('[data-import-bulk-client]');
var defaults = opts.defaults || {};
function money(amount) {
try {
return new Intl.NumberFormat(opts.locale || undefined, { style: 'currency', currency: opts.currency || 'USD' }).format(amount);
} catch (e) {
return amount.toFixed(2);
}
}
function part(row, name) {
return row.querySelector('[data-import-' + name + ']');
}
// One line: the new vendor's name only for + New vendor, Bill on only with a client
function tidy(row) {
var vendor = part(row, 'vendor');
if (!vendor) {
return;
}
var take = part(row, 'take');
part(row, 'new-vendor').hidden = vendor.value !== 'new';
part(row, 'billable-box').hidden = part(row, 'client').value === '0' || Number(row.getAttribute('data-amount')) <= 0;
var on = take.checked;
vendor.required = on;
part(row, 'category').required = on;
part(row, 'new-vendor').required = on && vendor.value === 'new';
row.classList.toggle('itflow-unbilled-off', !on);
}
function update() {
var count = 0;
var total = 0;
rows.forEach(function (row) {
var take = part(row, 'take');
tidy(row);
if (take.checked && !take.disabled) {
count++;
total += Number(row.getAttribute('data-amount'));
}
});
summary.textContent = count ? count + ' line' + (count === 1 ? '' : 's') + ' · ' + money(Math.round(total * 100) / 100) : 'Tick the lines to import';
submitLabel.textContent = count ? 'Import ' + count : 'Import';
submit.disabled = count === 0;
if (all) {
var shown = rows.filter(function (row) { return !row.hidden && !part(row, 'take').disabled; });
var on = shown.filter(function (row) { return part(row, 'take').checked; }).length;
all.checked = on > 0 && on === shown.length;
all.indeterminate = on > 0 && on < shown.length;
}
}
// Start each pick where it was matched
rows.forEach(function (row) {
['vendor', 'category', 'client'].forEach(function (name) {
var select = part(row, name);
if (select && select.getAttribute('data-value') !== null) {
var value = select.getAttribute('data-value');
if (select.querySelector('option[value="' + CSS.escape(value) + '"]')) {
select.value = value;
}
}
});
if (row.hasAttribute('data-import-extra')) {
row.hidden = true;
}
});
// A vendor picked fills in its category, and the same merchant's other unmatched lines
form.addEventListener('change', function (e) {
var row = e.target.closest('[data-import-row]');
if (row && e.target.matches('[data-import-vendor]')) {
row.dataset.touched = '1';
var vendor = e.target.value;
var category = part(row, 'category');
if (vendor !== '' && vendor !== 'new' && defaults[vendor] && !category.dataset.touched) {
category.value = String(defaults[vendor]);
}
var key = row.getAttribute('data-key');
if (key && vendor !== '') {
rows.forEach(function (other) {
var otherVendor = part(other, 'vendor');
if (other === row || other.getAttribute('data-key') !== key || other.dataset.touched || !otherVendor || (otherVendor.value !== '' && otherVendor.value !== 'new')) {
return;
}
otherVendor.value = vendor;
part(other, 'new-vendor').value = part(row, 'new-vendor').value;
if (!part(other, 'category').dataset.touched) {
part(other, 'category').value = category.value;
}
});
}
} else if (row && e.target.matches('[data-import-category], [data-import-client]')) {
e.target.dataset.touched = '1';
}
if (e.target === bulkCategory || e.target === bulkClient) {
var name = e.target === bulkCategory ? 'category' : 'client';
if (e.target.value !== '') {
rows.forEach(function (r) {
if (part(r, 'take').checked && part(r, name)) {
part(r, name).value = e.target.value;
part(r, name).dataset.touched = '1';
}
});
}
e.target.value = '';
}
update();
});
// A new vendor's name typed on one line follows to the same merchant's lines
form.addEventListener('input', function (e) {
if (!e.target.matches('[data-import-new-vendor]')) {
return;
}
var row = e.target.closest('[data-import-row]');
var key = row.getAttribute('data-key');
rows.forEach(function (other) {
if (other !== row && key && other.getAttribute('data-key') === key && part(other, 'vendor') && part(other, 'vendor').value === 'new' && !other.dataset.touched) {
part(other, 'new-vendor').value = e.target.value;
}
});
});
if (all) {
all.addEventListener('change', function () {
rows.forEach(function (row) {
var take = part(row, 'take');
if (!row.hidden && !take.disabled) {
take.checked = all.checked;
}
});
update();
});
}
if (showAll) {
showAll.addEventListener('change', function () {
rows.forEach(function (row) {
if (row.hasAttribute('data-import-extra')) {
row.hidden = !showAll.checked;
if (row.hidden) {
part(row, 'take').checked = false;
}
}
});
update();
});
}
// Clicking a line (not its picks) ticks it
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('a, input, button, select, label, textarea')) {
return;
}
var take = part(row, 'take');
if (!take.disabled) {
take.checked = !take.checked;
update();
}
});
});
form.addEventListener('submit', function (e) {
if (!form.reportValidity()) {
e.preventDefault();
return;
}
var list = rows.map(function (row) {
var vendor = part(row, 'vendor');
return {
n: Number(row.getAttribute('data-n')),
take: part(row, 'take').checked && !part(row, 'take').disabled,
vendor: vendor ? vendor.value : '',
new_vendor: vendor ? part(row, 'new-vendor').value : '',
category: part(row, 'category') ? part(row, 'category').value : '',
client: part(row, 'client') ? part(row, 'client').value : '0',
billable: part(row, 'billable') ? part(row, 'billable').checked : false
};
});
picks.value = JSON.stringify(list);
});
update();
}