Implement basic transaction editing

This commit is contained in:
RunasSudo 2024-11-17 00:35:28 +11:00
parent 7616d1256d
commit 4bbfad335e
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
13 changed files with 522 additions and 52 deletions

View File

@ -10,6 +10,7 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.1.5",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-shell": "^2",
@ -20,6 +21,7 @@
"vue-router": "4" "vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/clusterize.js": "^0.18.3", "@types/clusterize.js": "^0.18.3",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",

View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@heroicons/vue':
specifier: ^2.1.5
version: 2.1.5(vue@3.5.12(typescript@5.6.3))
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2 specifier: ^2
version: 2.1.1 version: 2.1.1
@ -33,6 +36,9 @@ importers:
specifier: '4' specifier: '4'
version: 4.4.5(vue@3.5.12(typescript@5.6.3)) version: 4.4.5(vue@3.5.12(typescript@5.6.3))
devDependencies: devDependencies:
'@tailwindcss/forms':
specifier: ^0.5.9
version: 0.5.9(tailwindcss@3.4.15)
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.1.0 version: 2.1.0
@ -222,6 +228,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@heroicons/vue@2.1.5':
resolution: {integrity: sha512-IpqR72sFqFs55kyKfFS7tN+Ww6odFNeH/7UxycIOrlVYfj4WUGAdzQtLBnJspucSeqWFQsKM0g0YrgU655BEfA==}
peerDependencies:
vue: '>= 3'
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -350,6 +361,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tailwindcss/forms@0.5.9':
resolution: {integrity: sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==}
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20'
'@tauri-apps/api@2.1.1': '@tauri-apps/api@2.1.1':
resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==} resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==}
@ -729,6 +745,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mini-svg-data-uri@1.4.4:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true
minimatch@9.0.5: minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@ -1102,6 +1122,10 @@ snapshots:
'@esbuild/win32-x64@0.21.5': '@esbuild/win32-x64@0.21.5':
optional: true optional: true
'@heroicons/vue@2.1.5(vue@3.5.12(typescript@5.6.3))':
dependencies:
vue: 3.5.12(typescript@5.6.3)
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
dependencies: dependencies:
string-width: 5.1.2 string-width: 5.1.2
@ -1197,6 +1221,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.26.0': '@rollup/rollup-win32-x64-msvc@4.26.0':
optional: true optional: true
'@tailwindcss/forms@0.5.9(tailwindcss@3.4.15)':
dependencies:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.15
'@tauri-apps/api@2.1.1': {} '@tauri-apps/api@2.1.1': {}
'@tauri-apps/cli-darwin-arm64@2.1.0': '@tauri-apps/cli-darwin-arm64@2.1.0':
@ -1581,6 +1610,8 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mini-svg-data-uri@1.4.4: {}
minimatch@9.0.5: minimatch@9.0.5:
dependencies: dependencies:
brace-expansion: 2.0.1 brace-expansion: 2.0.1

View File

@ -1,13 +1,16 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for all windows",
"windows": [ "windows": [
"main" "*"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:webview:allow-create-webview-window",
"core:window:allow-close",
"core:window:allow-set-title", "core:window:allow-set-title",
"core:window:allow-show",
"dialog:default", "dialog:default",
"shell:allow-open", "shell:allow-open",
"sql:default", "sql:default",

172
src/db.ts
View File

@ -22,6 +22,8 @@ import Database from '@tauri-apps/plugin-sql';
import { reactive } from 'vue'; import { reactive } from 'vue';
import { asCost, Balance } from './amounts.ts';
export const db = reactive({ export const db = reactive({
filename: null as (string | null), filename: null as (string | null),
@ -56,6 +58,8 @@ export const db = reactive({
}); });
export async function totalBalances(session: Database): Promise<{account: string, quantity: number}[]> { export async function totalBalances(session: Database): Promise<{account: string, quantity: number}[]> {
await updateRunningBalances();
return await session.select(` return await session.select(`
SELECT p3.account AS account, running_balance AS quantity FROM SELECT p3.account AS account, running_balance AS quantity FROM
( (
@ -69,18 +73,147 @@ export async function totalBalances(session: Database): Promise<{account: string
`); `);
} }
export async function updateRunningBalances() {
// Recompute any required running balances
const session = await db.load();
const staleAccountsRaw: {account: string}[] = await session.select('SELECT DISTINCT account FROM postings WHERE running_balance IS NULL');
const staleAccounts: string[] = staleAccountsRaw.map((x) => x.account);
if (staleAccounts.length === 0) {
return;
}
// Get all relevant Postings in database in correct order
// FIXME: Recompute balances only from the last non-stale balance to be more efficient
const arraySQL = '(?' + ', ?'.repeat(staleAccounts.length - 1) + ')';
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
`SELECT postings.id, account, quantity, commodity
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
WHERE postings.account IN ${arraySQL}
ORDER BY dt, transaction_id, postings.id`,
staleAccounts
);
const runningBalances = new Map();
for (const posting of joinedTransactionPostings) {
const openingBalance = runningBalances.get(posting.account) ?? 0;
const quantityCost = asCost(posting.quantity, posting.commodity);
runningBalances.set(posting.account, openingBalance + quantityCost);
// Update running balance of posting
await session.execute(
`UPDATE postings
SET running_balance = $1
WHERE id = $2`,
[openingBalance + quantityCost, posting.id]
);
}
}
export function joinedToTransactions(joinedTransactionPostings: JoinedTransactionPosting[]): Transaction[] {
// Group postings into transactions
const transactions: Transaction[] = [];
for (const joinedTransactionPosting of joinedTransactionPostings) {
if (transactions.length === 0 || transactions.at(-1)!.id !== joinedTransactionPosting.transaction_id) {
transactions.push(new Transaction(
joinedTransactionPosting.transaction_id,
joinedTransactionPosting.dt,
joinedTransactionPosting.transaction_description,
[]
));
}
transactions.at(-1)!.postings.push({
id: joinedTransactionPosting.id,
description: joinedTransactionPosting.description,
account: joinedTransactionPosting.account,
quantity: joinedTransactionPosting.quantity,
commodity: joinedTransactionPosting.commodity,
running_balance: joinedTransactionPosting.running_balance
});
}
return transactions;
}
export function serialiseAmount(quantity: number, commodity: string): string {
// Pretty print the amount for an editable input
if (quantity < 0) {
return '-' + serialiseAmount(-quantity, commodity);
}
// Scale quantity by decimal places
const factor = Math.pow(10, db.metadata.dps);
const wholePart = Math.floor(quantity / factor);
const fracPart = quantity % factor;
const quantityString = wholePart.toString() + '.' + fracPart.toString().padStart(db.metadata.dps, '0');
if (commodity === db.metadata.reporting_commodity) {
return quantityString;
}
if (commodity.length === 1) {
return commodity + quantityString;
}
return quantityString + ' ' + commodity;
}
export function deserialiseAmount(amount: string): { quantity: number, commodity: string } {
const factor = Math.pow(10, db.metadata.dps);
if (amount.indexOf(' ') < 0) {
// Default commodity
const quantity = Math.round(parseFloat(amount) * factor)
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
return {
'quantity': quantity,
commodity: db.metadata.reporting_commodity
};
}
// FIXME: Parse single letter commodities
const quantityStr = amount.substring(0, amount.indexOf(' '));
const quantity = Math.round(parseFloat(quantityStr) * factor)
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
const commodity = amount.substring(amount.indexOf(' ') + 1);
return {
'quantity': quantity,
'commodity': commodity
};
}
// Type definitions // Type definitions
export interface Transaction { export class Transaction {
id: number, constructor(
dt: string, public id: number = null!,
description: string, public dt: string = null!,
postings: Posting[] public description: string = null!,
public postings: Posting[] = [],
) {}
doesBalance(): boolean {
const balance = new Balance();
for (const posting of this.postings) {
balance.add(posting.quantity, posting.commodity);
}
balance.clean();
return balance.amounts.length === 0;
}
} }
export interface Posting { export interface Posting {
id: number, id: number,
description: string, description: string | null,
account: string, account: string,
quantity: number, quantity: number,
commodity: string, commodity: string,
@ -98,30 +231,3 @@ export interface JoinedTransactionPosting {
commodity: string, commodity: string,
running_balance?: number running_balance?: number
} }
export function joinedToTransactions(joinedTransactionPostings: JoinedTransactionPosting[]): Transaction[] {
// Group postings into transactions
const transactions: Transaction[] = [];
for (const joinedTransactionPosting of joinedTransactionPostings) {
if (transactions.length === 0 || transactions.at(-1)!.id !== joinedTransactionPosting.transaction_id) {
transactions.push({
id: joinedTransactionPosting.transaction_id,
dt: joinedTransactionPosting.dt,
description: joinedTransactionPosting.transaction_description,
postings: []
});
}
transactions.at(-1)!.postings.push({
id: joinedTransactionPosting.id,
description: joinedTransactionPosting.description,
account: joinedTransactionPosting.account,
quantity: joinedTransactionPosting.quantity,
commodity: joinedTransactionPosting.commodity,
running_balance: joinedTransactionPosting.running_balance
});
}
return transactions;
}

View File

@ -17,6 +17,7 @@
*/ */
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
@ -31,6 +32,7 @@ async function initApp() {
{ path: '/', name: 'index', component: () => import('./pages/HomeView.vue') }, { path: '/', name: 'index', component: () => import('./pages/HomeView.vue') },
{ path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') }, { path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') },
{ 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: '/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') },
]; ];
@ -49,4 +51,14 @@ async function initApp() {
createApp(App).use(router).mount('#app'); createApp(App).use(router).mount('#app');
} }
(window as any).openLinkInNewWindow = function(link: HTMLAnchorElement) {
const webview = new WebviewWindow('dialog' + +new Date(), {
url: link.href,
});
webview.once('tauri://error', function(e) {
console.error(e);
});
return false;
}
initApp(); initApp();

View File

@ -0,0 +1,259 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 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 mb-4">
Edit transaction
</h1>
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-300">
<th class="pt-0.5 pb-1 pr-1 text-gray-900 font-semibold text-start">Date</th>
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start" colspan="2">Description</th>
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start">Dr</th>
<th class="pt-0.5 pb-1 pl-1 text-gray-900 font-semibold text-start">Cr</th>
</tr>
</thead>
<tbody>
<tr>
<td class="pt-2 pb-1 pr-1">
<input type="date" class="bordered-field" v-model="transaction.dt">
</td>
<td class="pt-2 pb-1 px-1" colspan="2">
<input type="text" class="bordered-field" v-model="transaction.description">
</td>
<td></td>
<td></td>
</tr>
<tr v-for="posting in transaction.postings">
<td></td>
<!-- TODO: Posting description -->
<td class="py-1 px-1" colspan="2">
<div class="relative flex">
<div class="relative flex flex-grow items-stretch shadow-sm">
<div class="absolute inset-y-0 left-0 flex items-center z-10">
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-emerald-600" v-model="posting.sign">
<option value="dr">Dr</option>
<option value="cr">Cr</option>
</select>
</div>
<div class="relative combobox w-full">
<input type="text" class="bordered-field pl-16 peer" v-model="posting.account">
<!-- TODO: Accounts combobox -->
</div>
</div>
<button class="relative -ml-px px-2 py-2 text-gray-500 hover:text-gray-700" @click="addPosting(posting)">
<PlusIcon class="w-4 h-4" />
</button>
</div>
</td>
<template v-if="posting.sign == 'dr'">
<td class="amount-dr has-amount py-1 px-1">
<div class="relative shadow-sm">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
</div>
<input type="text" class="bordered-field pl-7" v-model="posting.amount_abs">
</div>
</td>
<td class="amount-cr py-1 pl-1"></td>
</template>
<template v-if="posting.sign == 'cr'">
<td class="amount-dr py-1 px-1"></td>
<td class="amount-cr has-amount py-1 pl-1">
<div class="relative shadow-sm">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
</div>
<input type="text" class="bordered-field pl-7" v-model="posting.amount_abs">
</div>
</td>
</template>
</tr>
</tbody>
</table>
<div class="flex justify-end mt-4 space-x-2">
<!--<button type="submit" name="action" value="delete" class="btn-secondary text-red-600 ring-red-500" onclick="return confirm('Are you sure you want to delete this transaction? This operation is irreversible.');">Delete</button>-->
<button class="btn-primary" @click="saveTransaction">Save</button>
</div>
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="error !== null">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { PlusIcon, XCircleIcon } from '@heroicons/vue/24/solid';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { JoinedTransactionPosting, Posting, Transaction, db, deserialiseAmount, joinedToTransactions, serialiseAmount } from '../db.ts';
interface EditingPosting {
id: number | null,
description: string | null,
account: string,
sign: string, // Keep track of Dr/Cr status so this can be independently changed in the UI
amount_abs: string,
}
interface EditingTransaction {
id: number | null,
dt: string,
description: string,
postings: EditingPosting[]
}
const route = useRoute();
const transaction = ref({
id: null!,
dt: null!,
description: null!,
postings: []
} as EditingTransaction);
const error = ref(null as string | null);
async function load() {
const session = await db.load();
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
WHERE transactions.id = $1
ORDER BY postings.id`,
[route.params.id]
);
const transactions = joinedToTransactions(joinedTransactionPostings);
if (transactions.length !== 1) { throw new Error('Unexpected number of transactions returned from SQL'); }
transaction.value = transactions[0] as unknown as EditingTransaction;
// Format dt
transaction.value.dt = dayjs(transaction.value.dt).format('YYYY-MM-DD')
// Initialise sign and amount_abs
for (const posting of transaction.value.postings) {
posting.sign = (posting as unknown as Posting).quantity! >= 0 ? 'dr' : 'cr';
posting.amount_abs = serialiseAmount(Math.abs((posting as unknown as Posting).quantity), (posting as unknown as Posting).commodity);
}
}
load();
function addPosting(posting: EditingPosting) {
const index = transaction.value.postings.indexOf(posting);
transaction.value.postings.splice(index + 1, 0, {
id: null,
description: null,
account: '',
sign: posting.sign, // Create the new posting with the same sign as the entry clicked on
amount_abs: ''
});
}
async function saveTransaction() {
error.value = null;
if (transaction.value.id === null) {
error.value = 'Creating new transactions is not yet implemented.';
return;
}
// Prepare transaction for save
const newTransaction = new Transaction(
transaction.value.id,
dayjs(transaction.value.dt).format('YYYY-MM-DD HH:mm:ss.SSS000'),
transaction.value.description,
[]
);
for (const posting of transaction.value.postings) {
if (posting.id === null) {
error.value = 'Creating new postings is not yet implemented.';
return;
}
const amount_abs = deserialiseAmount(posting.amount_abs);
newTransaction.postings.push({
id: posting.id,
description: posting.description,
account: posting.account,
quantity: posting.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity,
commodity: amount_abs.commodity
});
}
if (!newTransaction.doesBalance()) {
error.value = 'Debits and credits do not balance.';
return;
}
// Save changes to database
// TODO: Use transactions
const session = await db.load();
await session.execute(
`UPDATE transactions
SET dt = $1, description = $2
WHERE id = $3`,
[newTransaction.dt, newTransaction.description, newTransaction.id]
);
for (const posting of newTransaction.postings) {
await session.execute(
`UPDATE postings
SET description = $1, account = $2, quantity = $3, commodity = $4
WHERE id = $5`,
[posting.description, posting.account, posting.quantity, posting.commodity, posting.id]
);
// Invalidate running balances
await session.execute(
`UPDATE postings
SET running_balance = NULL
FROM (
SELECT postings.id
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
WHERE DATE(dt) >= DATE($1) AND account = $2
) p
WHERE postings.id = p.id;`,
[newTransaction.dt, posting.account]
);
}
await getCurrentWindow().close();
}
</script>

View File

@ -56,11 +56,14 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from 'vue';
import { asCost } from '../amounts.ts'; import { asCost } from '../amounts.ts';
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts';
import { pp, ppWithCommodity } from '../display.ts'; import { pp, ppWithCommodity } from '../display.ts';
import { renderComponent } from '../webutil.ts';
const commodityDetail = ref(false); const commodityDetail = ref(false);
@ -81,17 +84,23 @@
} }
function renderTable() { function renderTable() {
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = []; const rows = [];
for (const transaction of transactions.value) { for (const transaction of transactions.value) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
rows.push( rows.push(
`<tr class="border-t border-gray-300"> `<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td> <td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description }</td> <td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description } ${ editLink }</td>
<td></td> <td></td>
<td></td> <td></td>
</tr>` </tr>`
); );
for (const posting of transaction.postings) { for (const posting of transaction.postings) {
if (commodityDetail.value) { if (commodityDetail.value) {
rows.push( rows.push(

View File

@ -62,11 +62,14 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from 'vue';
import { asCost } from '../amounts.ts'; import { asCost } from '../amounts.ts';
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts';
import { pp, ppWithCommodity } from '../display.ts'; import { pp, ppWithCommodity } from '../display.ts';
import { renderComponent } from '../webutil.ts';
const commodityDetail = ref(false); const commodityDetail = ref(false);
@ -87,13 +90,17 @@
} }
function renderTable() { function renderTable() {
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = []; const rows = [];
for (const transaction of transactions.value) { for (const transaction of transactions.value) {
rows.push( rows.push(
`<tr class="border-t border-gray-300"> `<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td> <td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description }</td> <td class="py-0.5 px-1 text-gray-900" colspan="3">
${ transaction.description }
<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
</td>
<td></td> <td></td>
<td></td> <td></td>
</tr>` </tr>`

View File

@ -40,7 +40,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { JoinedTransactionPosting, Transaction, db, joinedToTransactions, updateRunningBalances } from '../db.ts';
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue'; import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue'; import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
@ -52,6 +52,9 @@
async function load() { async function load() {
const session = await db.load(); const session = await db.load();
// Ensure running balances are up to date because we use these
await updateRunningBalances();
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance `SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance
FROM transactions FROM transactions

View File

@ -43,12 +43,15 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onMounted, onUnmounted, watch } from 'vue'; import { onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Balance } from '../amounts.ts'; import { Balance } from '../amounts.ts';
import { Transaction } from '../db.ts'; import { Transaction } from '../db.ts';
import { ppWithCommodity } from '../display.ts'; import { ppWithCommodity } from '../display.ts';
import { renderComponent } from '../webutil.ts';
const route = useRoute(); const route = useRoute();
const { transactions } = defineProps<{ transactions: Transaction[] }>(); const { transactions } = defineProps<{ transactions: Transaction[] }>();
@ -75,16 +78,18 @@
} }
// Render table // Render table
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = []; const rows = [];
for (const transaction of transactions) { for (const transaction of transactions) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
rows.push( rows.push(
`<tr class="border-t border-gray-300"> `<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td> <td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 text-gray-900"> <td class="py-0.5 px-1 text-gray-900">${ transaction.description } ${ editLink }</td>
${ transaction.description }
<!-- TODO: Edit button -->
</td>
<td></td> <td></td>
<td></td> <td></td>
<td></td> <td></td>

View File

@ -44,12 +44,15 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onMounted, onUnmounted, watch } from 'vue'; import { onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { asCost } from '../amounts.ts'; import { asCost } from '../amounts.ts';
import { Transaction } from '../db.ts'; import { Transaction } from '../db.ts';
import { pp } from '../display.ts'; import { pp } from '../display.ts';
import { renderComponent } from '../webutil.ts';
const route = useRoute(); const route = useRoute();
const { transactions } = defineProps<{ transactions: Transaction[] }>(); const { transactions } = defineProps<{ transactions: Transaction[] }>();
@ -58,9 +61,15 @@
function renderTable() { function renderTable() {
// Render table // Render table
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = []; const rows = [];
for (const transaction of transactions) { for (const transaction of transactions) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
if (transaction.postings.length == 2) { if (transaction.postings.length == 2) {
// Simple transaction // Simple transaction
let thisAccountPosting, otherAccountPosting; let thisAccountPosting, otherAccountPosting;
@ -76,10 +85,7 @@
rows.push( rows.push(
`<tr class="border-t border-gray-300"> `<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td> <td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 text-gray-900"> <td class="py-0.5 px-1 text-gray-900">${ transaction.description } ${ editLink }</td>
${ transaction.description }
<!-- TODO: Edit button -->
</td>
<td class="py-0.5 px-1 text-gray-900"><a href="/transactions/${ encodeURIComponent(otherAccountPosting!.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ otherAccountPosting!.account }</a></td> <td class="py-0.5 px-1 text-gray-900"><a href="/transactions/${ encodeURIComponent(otherAccountPosting!.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ otherAccountPosting!.account }</a></td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">${ thisAccountPosting!.quantity >= 0 ? pp(asCost(thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' }</td> <td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">${ thisAccountPosting!.quantity >= 0 ? pp(asCost(thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' }</td>
<td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">${ thisAccountPosting!.quantity < 0 ? pp(asCost(-thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' }</td> <td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">${ thisAccountPosting!.quantity < 0 ? pp(asCost(-thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' }</td>
@ -92,10 +98,7 @@
rows.push( rows.push(
`<tr class="border-t border-gray-300"> `<tr class="border-t border-gray-300">
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td> <td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td colspan="2" class="py-0.5 px-1 text-gray-900"> <td colspan="2" class="py-0.5 px-1 text-gray-900">${ transaction.description } ${ editLink }</td>
${ transaction.description }
<!-- TODO: Edit button -->
</td>
<td></td> <td></td>
<td></td> <td></td>
<td></td> <td></td>

25
src/webutil.ts Normal file
View File

@ -0,0 +1,25 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 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/>.
*/
import { createApp } from 'vue';
export function renderComponent(component: any, props={}): string {
const container = document.createElement('div');
createApp(component, props).mount(container);
return container.innerHTML;
}

View File

@ -1,4 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
import tailwindcssforms from '@tailwindcss/forms';
export default { export default {
content: [ content: [
"./index.html", "./index.html",
@ -10,5 +13,7 @@ export default {
"sans": ["Roboto Flex", "Helvetica", "Arial", "sans-serif"], "sans": ["Roboto Flex", "Helvetica", "Arial", "sans-serif"],
} }
}, },
plugins: [], plugins: [
tailwindcssforms,
],
} }