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 @@ + + + + + diff --git a/src/pages/GeneralLedgerView.vue b/src/pages/GeneralLedgerView.vue index ca72b82..352dc07 100644 --- a/src/pages/GeneralLedgerView.vue +++ b/src/pages/GeneralLedgerView.vue @@ -56,11 +56,14 @@ import dayjs from 'dayjs'; + import { PencilIcon } from '@heroicons/vue/24/outline'; + import { onUnmounted, ref, watch } from 'vue'; import { asCost } from '../amounts.ts'; import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { pp, ppWithCommodity } from '../display.ts'; + import { renderComponent } from '../webutil.ts'; const commodityDetail = ref(false); @@ -81,17 +84,23 @@ } 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 transaction of transactions.value) { + let editLink = ''; + if (transaction.id !== null) { + editLink = `${ PencilIconHTML }`; + } rows.push( ` ${ dayjs(transaction.dt).format('YYYY-MM-DD') } - ${ transaction.description } + ${ transaction.description } ${ editLink } ` ); + for (const posting of transaction.postings) { if (commodityDetail.value) { rows.push( diff --git a/src/pages/JournalView.vue b/src/pages/JournalView.vue index 4ef60fd..f8534d9 100644 --- a/src/pages/JournalView.vue +++ b/src/pages/JournalView.vue @@ -62,11 +62,14 @@ import dayjs from 'dayjs'; + import { PencilIcon } from '@heroicons/vue/24/outline'; + import { onUnmounted, ref, watch } from 'vue'; import { asCost } from '../amounts.ts'; import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { pp, ppWithCommodity } from '../display.ts'; + import { renderComponent } from '../webutil.ts'; const commodityDetail = ref(false); @@ -87,13 +90,17 @@ } 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 transaction of transactions.value) { rows.push( ` ${ dayjs(transaction.dt).format('YYYY-MM-DD') } - ${ transaction.description } + + ${ transaction.description } + ${ PencilIconHTML } + ` diff --git a/src/pages/TransactionsView.vue b/src/pages/TransactionsView.vue index 9873b91..74d5e05 100644 --- a/src/pages/TransactionsView.vue +++ b/src/pages/TransactionsView.vue @@ -40,7 +40,7 @@ import { ref } from 'vue'; 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 TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue'; @@ -52,6 +52,9 @@ async function 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( `SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance FROM transactions diff --git a/src/pages/TransactionsWithCommodityView.vue b/src/pages/TransactionsWithCommodityView.vue index 8914bb9..3533e37 100644 --- a/src/pages/TransactionsWithCommodityView.vue +++ b/src/pages/TransactionsWithCommodityView.vue @@ -43,12 +43,15 @@ import dayjs from 'dayjs'; + import { PencilIcon } from '@heroicons/vue/24/outline'; + import { onMounted, onUnmounted, watch } from 'vue'; import { useRoute } from 'vue-router'; import { Balance } from '../amounts.ts'; import { Transaction } from '../db.ts'; import { ppWithCommodity } from '../display.ts'; + import { renderComponent } from '../webutil.ts'; const route = useRoute(); const { transactions } = defineProps<{ transactions: Transaction[] }>(); @@ -75,16 +78,18 @@ } // Render table + const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon const rows = []; for (const transaction of transactions) { + let editLink = ''; + if (transaction.id !== null) { + editLink = `${ PencilIconHTML }`; + } rows.push( ` ${ dayjs(transaction.dt).format('YYYY-MM-DD') } - - ${ transaction.description } - - + ${ transaction.description } ${ editLink } diff --git a/src/pages/TransactionsWithoutCommodityView.vue b/src/pages/TransactionsWithoutCommodityView.vue index bd5bbe9..7f6e6dc 100644 --- a/src/pages/TransactionsWithoutCommodityView.vue +++ b/src/pages/TransactionsWithoutCommodityView.vue @@ -44,12 +44,15 @@ import dayjs from 'dayjs'; + import { PencilIcon } from '@heroicons/vue/24/outline'; + import { onMounted, onUnmounted, watch } from 'vue'; import { useRoute } from 'vue-router'; import { asCost } from '../amounts.ts'; import { Transaction } from '../db.ts'; import { pp } from '../display.ts'; + import { renderComponent } from '../webutil.ts'; const route = useRoute(); const { transactions } = defineProps<{ transactions: Transaction[] }>(); @@ -58,9 +61,15 @@ function renderTable() { // Render table + const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon const rows = []; for (const transaction of transactions) { + let editLink = ''; + if (transaction.id !== null) { + editLink = `${ PencilIconHTML }`; + } + if (transaction.postings.length == 2) { // Simple transaction let thisAccountPosting, otherAccountPosting; @@ -76,10 +85,7 @@ rows.push( ` ${ dayjs(transaction.dt).format('YYYY-MM-DD') } - - ${ transaction.description } - - + ${ transaction.description } ${ editLink } ${ otherAccountPosting!.account } ${ thisAccountPosting!.quantity >= 0 ? pp(asCost(thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' } ${ thisAccountPosting!.quantity < 0 ? pp(asCost(-thisAccountPosting!.quantity, thisAccountPosting!.commodity)) : '' } @@ -92,10 +98,7 @@ rows.push( ` ${ dayjs(transaction.dt).format('YYYY-MM-DD') } - - ${ transaction.description } - - + ${ transaction.description } ${ editLink } diff --git a/src/webutil.ts b/src/webutil.ts new file mode 100644 index 0000000..c9e588b --- /dev/null +++ b/src/webutil.ts @@ -0,0 +1,25 @@ +/* + 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 . +*/ + +import { createApp } from 'vue'; + +export function renderComponent(component: any, props={}): string { + const container = document.createElement('div'); + createApp(component, props).mount(container); + return container.innerHTML; +} diff --git a/tailwind.config.js b/tailwind.config.js index 1f5563d..4384169 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,7 @@ /** @type {import('tailwindcss').Config} */ + +import tailwindcssforms from '@tailwindcss/forms'; + export default { content: [ "./index.html", @@ -10,5 +13,7 @@ export default { "sans": ["Roboto Flex", "Helvetica", "Arial", "sans-serif"], } }, - plugins: [], + plugins: [ + tailwindcssforms, + ], }