Implement statement line view
This commit is contained in:
parent
86c007c28a
commit
a688ea7c22
@ -165,16 +165,45 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Validate transaction
|
||||
if (!newTransaction.doesBalance()) {
|
||||
error.value = 'Debits and credits do not balance.';
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
// Validate statement line reconciliations
|
||||
// Keep track of mapping, so we can fix up the reconciliation posting_id if renumbering occurs
|
||||
const postingsToReconciliations = new Map();
|
||||
|
||||
if (newTransaction.id !== null) {
|
||||
// Get statement line reconciliations affected by this transaction
|
||||
const joinedReconciliations: any[] = await session.select(
|
||||
`SELECT statement_line_reconciliations.id, postings.id AS posting_id, source_account, statement_lines.quantity, statement_lines.commodity
|
||||
FROM statement_line_reconciliations
|
||||
JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
||||
JOIN statement_lines ON statement_line_reconciliations.statement_line_id = statement_lines.id
|
||||
WHERE postings.transaction_id = $1`,
|
||||
[newTransaction.id]
|
||||
);
|
||||
|
||||
for (const joinedReconciliation of joinedReconciliations) {
|
||||
for (const posting of newTransaction.postings) {
|
||||
if (posting.id === joinedReconciliation.posting_id) {
|
||||
if (posting.account !== joinedReconciliation.source_account || posting.quantity !== joinedReconciliation.quantity || posting.commodity !== joinedReconciliation.commodity) {
|
||||
error.value = 'Edit would break reconciled statement line.';
|
||||
return;
|
||||
}
|
||||
postingsToReconciliations.set(posting, joinedReconciliation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes to database
|
||||
// FIXME: Use transactions
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
if (newTransaction.id === null) {
|
||||
// Insert new transaction
|
||||
const result = await session.execute(
|
||||
@ -212,11 +241,22 @@
|
||||
}
|
||||
|
||||
// Insert new posting
|
||||
await session.execute(
|
||||
const result = await session.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL)`,
|
||||
[newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity]
|
||||
);
|
||||
|
||||
// Fixup reconciliation if required
|
||||
const joinedReconciliation = postingsToReconciliations.get(posting);
|
||||
if (joinedReconciliation) {
|
||||
await session.execute(
|
||||
`UPDATE statement_line_reconciliations
|
||||
SET posting_id = $1
|
||||
WHERE id = $2`,
|
||||
[result.lastInsertId, joinedReconciliation.id]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Update existing posting
|
||||
await session.execute(
|
||||
|
@ -34,6 +34,7 @@ async function initApp() {
|
||||
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
|
||||
{ path: '/journal/edit-transaction/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
|
||||
{ path: '/journal/new-transaction', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') },
|
||||
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
|
||||
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
||||
];
|
||||
|
@ -22,7 +22,7 @@
|
||||
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</a></li>-->
|
||||
<li><RouterLink :to="{ name: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>-->
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
||||
<!-- TODO: Plugin reports -->
|
||||
|
186
src/pages/StatementLinesView.vue
Normal file
186
src/pages/StatementLinesView.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Statement lines
|
||||
</h1>
|
||||
|
||||
<div class="my-2 py-2 flex bg-white sticky top-0">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<!--<button class="btn-secondary text-emerald-700 ring-emerald-600">
|
||||
Reconcile selected as transfer
|
||||
</button>
|
||||
<a href="#" class="btn-secondary">
|
||||
Import statement
|
||||
</a>
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
||||
Show only unclassified lines
|
||||
</a>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statement-line-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Source account</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Charged to</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
|
||||
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1" colspan="7">Loading data…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Clusterize from 'clusterize.js';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PencilIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
import { ppWithCommodity } from '../display.ts';
|
||||
|
||||
interface StatementLine {
|
||||
id: number,
|
||||
source_account: string,
|
||||
dt: string,
|
||||
description: string,
|
||||
quantity: number,
|
||||
balance: number | null,
|
||||
commodity: string,
|
||||
transaction_id: number,
|
||||
posting_accounts: string[]
|
||||
}
|
||||
|
||||
const statementLines = ref([] as StatementLine[]);
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const joinedStatementLines: any[] = await session.select(
|
||||
`SELECT statement_lines.id, source_account, statement_lines.dt, statement_lines.description, statement_lines.quantity, statement_lines.balance, statement_lines.commodity, p2.transaction_id, p2.account AS posting_account
|
||||
FROM statement_lines
|
||||
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
||||
LEFT JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
||||
LEFT JOIN transactions ON postings.transaction_id = transactions.id
|
||||
LEFT JOIN postings p2 ON transactions.id = p2.transaction_id
|
||||
ORDER BY statement_lines.dt DESC, statement_lines.id DESC, p2.id`
|
||||
);
|
||||
|
||||
// Unflatten statement lines
|
||||
const newStatementLines: StatementLine[] = [];
|
||||
|
||||
for (const joinedStatementLine of joinedStatementLines) {
|
||||
if (newStatementLines.length === 0 || newStatementLines.at(-1)!.id !== joinedStatementLine.id) {
|
||||
newStatementLines.push({
|
||||
id: joinedStatementLine.id,
|
||||
source_account: joinedStatementLine.source_account,
|
||||
dt: joinedStatementLine.dt,
|
||||
description: joinedStatementLine.description,
|
||||
quantity: joinedStatementLine.quantity,
|
||||
balance: joinedStatementLine.balance,
|
||||
commodity: joinedStatementLine.commodity,
|
||||
transaction_id: joinedStatementLine.transaction_id,
|
||||
posting_accounts: []
|
||||
});
|
||||
}
|
||||
if (joinedStatementLine.posting_account !== null) {
|
||||
newStatementLines.at(-1)!.posting_accounts.push(joinedStatementLine.posting_account);
|
||||
}
|
||||
}
|
||||
|
||||
statementLines.value = newStatementLines;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
||||
const rows = [];
|
||||
|
||||
for (const line of statementLines.value) {
|
||||
let reconciliationCell;
|
||||
if (line.posting_accounts.length === 0) {
|
||||
// Unreconciled
|
||||
reconciliationCell =
|
||||
`<a href="#" class="text-red-500 hover:text-red-600 hover:underline" onclick="return classifyLine(this);">Unclassified</a>
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
} else if (line.posting_accounts.length === 2) {
|
||||
// Simple reconciliation
|
||||
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
|
||||
reconciliationCell =
|
||||
`<a href="#" class="hover:text-blue-700 hover:underline" onclick="return classifyLine(this);">${ otherAccount }</a>
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
} else {
|
||||
// Complex reconciliation
|
||||
reconciliationCell =
|
||||
`<i>(Complex)</i>
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
|
||||
rows.push(
|
||||
`<tr data-line-id="{{ line.id }}">
|
||||
<td class="py-0.5 pr-1 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-line-id" value="${ line.id }"></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="#" 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="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>
|
||||
</tr>`
|
||||
)
|
||||
}
|
||||
|
||||
if (clusterize === null) {
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('statement-line-list')!,
|
||||
contentElem: document.querySelector('#statement-line-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
}
|
||||
}
|
||||
|
||||
watch(statementLines, renderTable);
|
||||
|
||||
load();
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clusterize !== null) {
|
||||
clusterize.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
Loading…
Reference in New Issue
Block a user