diff --git a/package.json b/package.json
index 9fb5473..11486a0 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"tauri": "tauri"
},
"dependencies": {
+ "@heroicons/vue": "^2.1.5",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-shell": "^2",
@@ -20,6 +21,7 @@
"vue-router": "4"
},
"devDependencies": {
+ "@tailwindcss/forms": "^0.5.9",
"@tauri-apps/cli": "^2",
"@types/clusterize.js": "^0.18.3",
"@vitejs/plugin-vue": "^5.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d99cd1..e54a849 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@heroicons/vue':
+ specifier: ^2.1.5
+ version: 2.1.5(vue@3.5.12(typescript@5.6.3))
'@tauri-apps/api':
specifier: ^2
version: 2.1.1
@@ -33,6 +36,9 @@ importers:
specifier: '4'
version: 4.4.5(vue@3.5.12(typescript@5.6.3))
devDependencies:
+ '@tailwindcss/forms':
+ specifier: ^0.5.9
+ version: 0.5.9(tailwindcss@3.4.15)
'@tauri-apps/cli':
specifier: ^2
version: 2.1.0
@@ -222,6 +228,11 @@ packages:
cpu: [x64]
os: [win32]
+ '@heroicons/vue@2.1.5':
+ resolution: {integrity: sha512-IpqR72sFqFs55kyKfFS7tN+Ww6odFNeH/7UxycIOrlVYfj4WUGAdzQtLBnJspucSeqWFQsKM0g0YrgU655BEfA==}
+ peerDependencies:
+ vue: '>= 3'
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -350,6 +361,11 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==}
@@ -729,6 +745,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mini-svg-data-uri@1.4.4:
+ resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
+ hasBin: true
+
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -1102,6 +1122,10 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
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':
dependencies:
string-width: 5.1.2
@@ -1197,6 +1221,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.26.0':
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/cli-darwin-arm64@2.1.0':
@@ -1581,6 +1610,8 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
+ mini-svg-data-uri@1.4.4: {}
+
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.1
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index a3672de..c2563c6 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -1,16 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
- "description": "Capability for the main window",
+ "description": "Capability for all windows",
"windows": [
- "main"
+ "*"
],
"permissions": [
"core:default",
+ "core:webview:allow-create-webview-window",
+ "core:window:allow-close",
"core:window:allow-set-title",
+ "core:window:allow-show",
"dialog:default",
"shell:allow-open",
"sql:default",
"sql:allow-execute"
]
-}
\ No newline at end of file
+}
diff --git a/src/db.ts b/src/db.ts
index 0c78ce4..50bc4a4 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -22,6 +22,8 @@ import Database from '@tauri-apps/plugin-sql';
import { reactive } from 'vue';
+import { asCost, Balance } from './amounts.ts';
+
export const db = reactive({
filename: null as (string | null),
@@ -56,6 +58,8 @@ export const db = reactive({
});
export async function totalBalances(session: Database): Promise<{account: string, quantity: number}[]> {
+ await updateRunningBalances();
+
return await session.select(`
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
-export interface Transaction {
- id: number,
- dt: string,
- description: string,
- postings: Posting[]
+export class Transaction {
+ constructor(
+ public id: number = null!,
+ public dt: string = null!,
+ 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 {
id: number,
- description: string,
+ description: string | null,
account: string,
quantity: number,
commodity: string,
@@ -98,30 +231,3 @@ export interface JoinedTransactionPosting {
commodity: string,
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;
-}
diff --git a/src/main.ts b/src/main.ts
index 01a598f..fb91b92 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -17,6 +17,7 @@
*/
import { invoke } from '@tauri-apps/api/core';
+import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
@@ -31,6 +32,7 @@ async function initApp() {
{ path: '/', name: 'index', component: () => import('./pages/HomeView.vue') },
{ path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.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: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
];
@@ -49,4 +51,14 @@ async function initApp() {
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();
diff --git a/src/pages/EditTransactionView.vue b/src/pages/EditTransactionView.vue
new file mode 100644
index 0000000..3269768
--- /dev/null
+++ b/src/pages/EditTransactionView.vue
@@ -0,0 +1,259 @@
+
+
+
+ {{ error }}
+ Edit transaction
+
+
+
+
+
+
+
+
+
+
+ Date
+ Description
+ Dr
+ Cr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+