Implement basic transaction editing
This commit is contained in:
parent
7616d1256d
commit
4bbfad335e
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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
172
src/db.ts
@ -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;
|
|
||||||
}
|
|
||||||
|
12
src/main.ts
12
src/main.ts
@ -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();
|
||||||
|
259
src/pages/EditTransactionView.vue
Normal file
259
src/pages/EditTransactionView.vue
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<!--
|
||||||
|
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 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>
|
@ -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(
|
||||||
|
@ -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>`
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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
25
src/webutil.ts
Normal file
@ -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 <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;
|
||||||
|
}
|
@ -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,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user