Use combo box for classifying statement lines

This commit is contained in:
RunasSudo 2025-03-29 22:08:43 +11:00
parent 4a9a4078ad
commit 08029fa9a8
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A

View File

@ -36,7 +36,7 @@
</div>
</div>
<div id="statement-line-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
<div id="statement-line-list" class="max-h-[100vh] overflow-y-scroll wk-aa relative">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-300">
@ -50,13 +50,23 @@
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
</tr>
</thead>
<tbody>
<tbody @click="onClickTableElement">
<tr>
<td></td>
<td class="py-0.5 px-1" colspan="7">Loading data</td>
</tr>
</tbody>
</table>
<!-- Component for reconciling statement lines -->
<div id="statement-line-classifier" class="hidden absolute">
<div class="flex items-stretch">
<ComboBoxAccounts v-model="classificationAccount" class="statement-line-classifier-input" />
<button @click="onLineClassified" id="statement-line-classifier-button" type="button" class="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 bg-white hover:bg-gray-50">
<CheckIcon class="w-5 h-5" />
</button>
</div>
</div>
</div>
</template>
@ -69,6 +79,7 @@
import { onUnmounted, ref, watch } from 'vue';
import ComboBoxAccounts from '../components/ComboBoxAccounts.vue';
import { db } from '../db.ts';
import { renderComponent } from '../webutil.ts';
import { ppWithCommodity } from '../display.ts';
@ -87,6 +98,10 @@
const showOnlyUnclassified = ref(false);
const statementLines = ref([] as StatementLine[]);
const classificationLineId = ref(0);
const classificationAccount = ref('');
let clusterize: Clusterize | null = null;
async function load() {
@ -127,48 +142,59 @@
statementLines.value = newStatementLines;
}
// TODO: Could probably avoid polluting global scope by using clusterize clusterChanged callback
(window as any).showClassifyLinePanel = function(el: HTMLAnchorElement) {
const CheckIconHTML = renderComponent(CheckIcon, { 'class': 'w-5 h-5' });
const td = el.closest('td')!;
td.className = 'relative'; // CSS trickery so as to not expand the height of the tr
td.innerHTML =
`<div class="flex items-stretch absolute top-[-4px]">
<input type="text" class="bordered-field min-w-[8em]">
<button type="button" class="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 bg-white hover:bg-gray-50">${ CheckIconHTML }</button>
</div>`;
td.querySelector('input')!.addEventListener('keydown', async function(event: KeyboardEvent) {
if (event.key === 'Enter') {
await onLineClassified(event);
function onClickTableElement(event: MouseEvent) {
// Use event delegation to avoid polluting global scope with the event listener
if (event.target && (event.target as Element).classList.contains('classify-link')) {
// ------------------------
// Show classify line panel
// Prevent selecting a different line when already classifying one line
if ((document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled) {
return;
}
})
td.querySelector('button')!.addEventListener('click', onLineClassified);
td.querySelector('input')!.focus();
return false;
};
// Set global state
const td = (event.target as Element).closest('td')!; // Reconciliation cell
const tr = td.closest('tr')!;
classificationLineId.value = parseInt(tr.dataset.lineId!);
// Show all other reconciliation cells
for (const el of document.querySelectorAll('#statement-line-list .charge-account > span')) {
el.classList.remove('invisible');
}
// Hide contents of the cell
const span = td.querySelector('span')!; // Span wrapper for reconciliation cell content
span.classList.add('invisible');
// Position the classify line panel in place (relative to #statement-line-list)
const outerDiv = document.getElementById('statement-line-list')!;
const divReconciler = document.getElementById('statement-line-classifier')!;
divReconciler.classList.remove('hidden');
divReconciler.style.top = (td.getBoundingClientRect().y - outerDiv.getBoundingClientRect().y - 4) + 'px';
divReconciler.style.left = (td.getBoundingClientRect().x - outerDiv.getBoundingClientRect().x) + 'px';
// Focus classify line panel
divReconciler.querySelector('input')!.focus();
}
}
async function onLineClassified(event: Event) {
// Callback when clicking OK or pressing enter to classify a statement line
if ((event.target as HTMLInputElement).disabled) {
// Callback when clicking OK to classify a statement line
if ((event.target! as any).disabled) {
return;
}
const td = (event.target as Element).closest('td')!;
const tr = td.closest('tr')!;
const lineId = parseInt(tr.dataset.lineId!);
const chargeAccount = (td.querySelector('input')! as HTMLInputElement).value;
const lineId = classificationLineId.value;
const chargeAccount = classificationAccount.value;
if (!chargeAccount) {
return;
}
// Disable further submissions
td.querySelector('input')!.disabled = true;
td.querySelector('button')!.disabled = true;
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = true;
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = true;
const statementLine = statementLines.value.find((l) => l.id === lineId)!;
@ -184,8 +210,8 @@
if (!doesAccountExist) {
// Prompt for confirmation
if (!await confirm('Account "' + chargeAccount + '" does not exist. Continue to reconcile this transaction and create a new account?')) {
td.querySelector('input')!.disabled = false;
td.querySelector('button')!.disabled = false;
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = false;
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = false;
return;
}
}
@ -225,6 +251,14 @@
dbTransaction.commit();
// Reset statement line classifier state
classificationAccount.value = '';
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = false;
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = false;
// Hide the statement line classifier
document.getElementById('statement-line-classifier')!.classList.add('hidden');
// Reload transactions and re-render the table
await load();
}
@ -309,7 +343,7 @@
if (line.posting_accounts.length === 0) {
// Unreconciled
reconciliationCell =
`<a href="#" class="classify-link text-red-500 hover:text-red-600 hover:underline" onclick="return showClassifyLinePanel(this);">Unclassified</a>`;
`<a href="#" class="classify-link text-red-500 hover:text-red-600 hover:underline" onclick="return false;">Unclassified</a>`;
checkboxCell = `<input class="checkbox-primary statement-line-checkbox" type="checkbox">`; // Only show checkbox for unreconciled lines
} else if (line.posting_accounts.length === 2) {
// Simple reconciliation
@ -336,7 +370,7 @@
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="/transactions/${ encodeURIComponent(line.source_account) }" class="hover:text-blue-700 hover:underline">${ line.source_account }</a></td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex]">${ dayjs(line.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900">${ line.description }</td>
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900">${ reconciliationCell }</td>
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900"><span>${ reconciliationCell }</span></td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }</td>
<td class="py-0.5 pl-1 align-baseline text-gray-900 text-end">${ line.balance ?? '' }</td>
@ -349,11 +383,14 @@
'rows': rows,
scrollElem: document.getElementById('statement-line-list')!,
contentElem: document.querySelector('#statement-line-list tbody')!,
show_no_data_row: false,
show_no_data_row: false
});
} else {
clusterize.update(rows);
}
// Hide the statement line classifier
document.getElementById('statement-line-classifier')!.classList.add('hidden');
}
watch(showOnlyUnclassified, renderTable);