Implement statement line view
This commit is contained in:
parent
86c007c28a
commit
a688ea7c22
@ -165,16 +165,45 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate transaction
|
||||||
if (!newTransaction.doesBalance()) {
|
if (!newTransaction.doesBalance()) {
|
||||||
error.value = 'Debits and credits do not balance.';
|
error.value = 'Debits and credits do not balance.';
|
||||||
return;
|
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
|
// Save changes to database
|
||||||
// FIXME: Use transactions
|
// FIXME: Use transactions
|
||||||
|
|
||||||
const session = await db.load();
|
|
||||||
|
|
||||||
if (newTransaction.id === null) {
|
if (newTransaction.id === null) {
|
||||||
// Insert new transaction
|
// Insert new transaction
|
||||||
const result = await session.execute(
|
const result = await session.execute(
|
||||||
@ -212,11 +241,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert new posting
|
// Insert new posting
|
||||||
await session.execute(
|
const result = await session.execute(
|
||||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
||||||
VALUES ($1, $2, $3, $4, $5, NULL)`,
|
VALUES ($1, $2, $3, $4, $5, NULL)`,
|
||||||
[newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity]
|
[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 {
|
} else {
|
||||||
// Update existing posting
|
// Update existing posting
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
@ -34,6 +34,7 @@ async function initApp() {
|
|||||||
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
|
{ 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/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: '/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: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.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>
|
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
||||||
<ul class="list-disc ml-6">
|
<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><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">Balance assertions</a></li>-->
|
||||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
||||||
<!-- TODO: Plugin reports -->
|
<!-- 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