Compare commits
No commits in common. "30cb94274b957b337ef969c4158e516cb9d0964d" and "7616d1256d237719e6fb195b4a3ce443752c3263" have entirely different histories.
30cb94274b
...
7616d1256d
@ -10,19 +10,16 @@
|
|||||||
"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",
|
||||||
"@tauri-apps/plugin-sql": "~2",
|
"@tauri-apps/plugin-sql": "~2",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
|
||||||
"clusterize.js": "^1.0.0",
|
"clusterize.js": "^1.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"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,9 +8,6 @@ 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
|
||||||
@ -23,9 +20,6 @@ importers:
|
|||||||
'@tauri-apps/plugin-sql':
|
'@tauri-apps/plugin-sql':
|
||||||
specifier: ~2
|
specifier: ~2
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
'@tauri-apps/plugin-store':
|
|
||||||
specifier: ~2
|
|
||||||
version: 2.1.0
|
|
||||||
clusterize.js:
|
clusterize.js:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@ -39,9 +33,6 @@ 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
|
||||||
@ -231,11 +222,6 @@ 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'}
|
||||||
@ -364,11 +350,6 @@ 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==}
|
||||||
|
|
||||||
@ -446,9 +427,6 @@ packages:
|
|||||||
'@tauri-apps/plugin-sql@2.0.1':
|
'@tauri-apps/plugin-sql@2.0.1':
|
||||||
resolution: {integrity: sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==}
|
resolution: {integrity: sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-store@2.1.0':
|
|
||||||
resolution: {integrity: sha512-GADqrc17opUKYIAKnGHIUgEeTZ2wJGu1ZITKQ1WMuOFdv8fvXRFBAqsqPjE3opgWohbczX6e1NpwmZK1AnuWVw==}
|
|
||||||
|
|
||||||
'@types/clusterize.js@0.18.3':
|
'@types/clusterize.js@0.18.3':
|
||||||
resolution: {integrity: sha512-udptC3aq8hfaXgmt9lC73OuE4RJYt26D2XIj+fTNDs0wuzAgQ6cyDpQOSkWhU65NroISAWhZ3/aovvV88IX7Gw==}
|
resolution: {integrity: sha512-udptC3aq8hfaXgmt9lC73OuE4RJYt26D2XIj+fTNDs0wuzAgQ6cyDpQOSkWhU65NroISAWhZ3/aovvV88IX7Gw==}
|
||||||
|
|
||||||
@ -751,10 +729,6 @@ 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'}
|
||||||
@ -1128,10 +1102,6 @@ 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
|
||||||
@ -1227,11 +1197,6 @@ 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':
|
||||||
@ -1289,10 +1254,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.1.1
|
'@tauri-apps/api': 2.1.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-store@2.1.0':
|
|
||||||
dependencies:
|
|
||||||
'@tauri-apps/api': 2.1.1
|
|
||||||
|
|
||||||
'@types/clusterize.js@0.18.3': {}
|
'@types/clusterize.js@0.18.3': {}
|
||||||
|
|
||||||
'@types/estree@1.0.6': {}
|
'@types/estree@1.0.6': {}
|
||||||
@ -1620,8 +1581,6 @@ 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
|
||||||
|
32
src-tauri/Cargo.lock
generated
32
src-tauri/Cargo.lock
generated
@ -927,17 +927,13 @@ dependencies = [
|
|||||||
name = "drcr"
|
name = "drcr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.6.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-store",
|
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -4402,22 +4398,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-plugin-store"
|
|
||||||
version = "2.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e9a580be53f04bb62422d239aa798e88522877f58a0d4a0e745f030055a51bb4"
|
|
||||||
dependencies = [
|
|
||||||
"dunce",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-plugin",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@ -4649,22 +4629,10 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-macros"
|
|
||||||
version = "2.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.87",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
|
@ -18,13 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", features = ["json", "time"] }
|
|
||||||
tauri = { version = "2", features = [] }
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2"
|
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tokio = { version = "1", features = ["sync"] }
|
|
||||||
|
@ -1,20 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for all windows",
|
"description": "Capability for the main window",
|
||||||
"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",
|
||||||
"sql:allow-execute",
|
"sql:allow-execute"
|
||||||
"store:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -16,74 +16,39 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
mod sql;
|
use tauri::{Builder, Manager, State};
|
||||||
|
|
||||||
use tauri::{AppHandle, Builder, Manager, State};
|
use std::sync::Mutex;
|
||||||
use tauri_plugin_store::StoreExt;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
db_filename: Option<String>,
|
db_filename: Option<String>,
|
||||||
sql_transactions: Vec<Option<crate::sql::SqliteTransaction>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filename state
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> {
|
|
||||||
let state = state.lock().await;
|
|
||||||
Ok(state.db_filename.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> {
|
fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Option<String> {
|
||||||
let mut state = state.lock().await;
|
let state = state.lock().unwrap();
|
||||||
state.db_filename = filename.clone();
|
state.db_filename.clone()
|
||||||
|
|
||||||
// Persist in store
|
|
||||||
let store = app.store("store.json").expect("Error opening store");
|
|
||||||
store.set("db_filename", filename);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main method
|
#[tauri::command]
|
||||||
|
fn set_open_filename(state: State<'_, Mutex<AppState>>, filename: Option<String>) {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
state.db_filename = filename;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
Builder::default()
|
Builder::default()
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Get open filename
|
|
||||||
let store = app.store("store.json")?;
|
|
||||||
let db_filename = match store.get("db_filename") {
|
|
||||||
None => None,
|
|
||||||
Some(serde_json::Value::String(s)) => {
|
|
||||||
if fs::exists(&s)? {
|
|
||||||
Some(s)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => panic!("Unexpected db_filename in store")
|
|
||||||
};
|
|
||||||
|
|
||||||
app.manage(Mutex::new(AppState {
|
app.manage(Mutex::new(AppState {
|
||||||
db_filename: db_filename,
|
db_filename: None
|
||||||
sql_transactions: Vec::new(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_sql::Builder::new().build())
|
.plugin(tauri_plugin_sql::Builder::new().build())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.invoke_handler(tauri::generate_handler![get_open_filename, set_open_filename])
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
get_open_filename, set_open_filename,
|
|
||||||
sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
@ -1,233 +0,0 @@
|
|||||||
/*
|
|
||||||
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/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use serde_json::Value as JsonValue;
|
|
||||||
use sqlx::{Column, Executor, Row, Sqlite, Transaction, TypeInfo, Value, ValueRef};
|
|
||||||
use sqlx::query::Query;
|
|
||||||
use sqlx::sqlite::{SqliteArguments, SqliteRow, SqliteValueRef};
|
|
||||||
use sqlx::types::time::{Date, PrimitiveDateTime, Time};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use tauri::State;
|
|
||||||
use tauri_plugin_sql::{DbInstances, DbPool, Error};
|
|
||||||
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
pub type SqliteTransaction = Transaction<'static, Sqlite>;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sql_transaction_begin(state: State<'_, Mutex<AppState>>, db_instances: State<'_, DbInstances>, db: String) -> Result<usize, Error> {
|
|
||||||
let instances = db_instances.0.read().await;
|
|
||||||
let db = instances.get(&db).ok_or(Error::DatabaseNotLoaded(db))?;
|
|
||||||
|
|
||||||
let pool = match db {
|
|
||||||
DbPool::Sqlite(pool) => pool,
|
|
||||||
//_ => panic!("Unexpected non-SQLite backend"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Open transaction
|
|
||||||
let transaction = pool.begin().await?;
|
|
||||||
|
|
||||||
// Store transaction in state
|
|
||||||
let mut state = state.lock().await;
|
|
||||||
let available_index = state.sql_transactions.iter().position(|t| t.is_none());
|
|
||||||
match available_index {
|
|
||||||
Some(i) => {
|
|
||||||
state.sql_transactions[i] = Some(transaction);
|
|
||||||
Ok(i)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
state.sql_transactions.push(Some(transaction));
|
|
||||||
Ok(state.sql_transactions.len() - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sql_transaction_execute(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize, query: String, values: Vec<JsonValue>) -> Result<(u64, i64), Error> {
|
|
||||||
let mut state = state.lock().await;
|
|
||||||
let transaction =
|
|
||||||
state.sql_transactions.get_mut(transaction_instance_id)
|
|
||||||
.expect("Invalid database transaction ID")
|
|
||||||
.as_mut() // Take reference to transaction rather than moving out of the Vec
|
|
||||||
.expect("Database transaction ID used after closed");
|
|
||||||
|
|
||||||
let query = prepare_query(&query, values);
|
|
||||||
let result = transaction.execute(query).await?;
|
|
||||||
Ok((
|
|
||||||
result.rows_affected(),
|
|
||||||
result.last_insert_rowid(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sql_transaction_select(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize, query: String, values: Vec<JsonValue>) -> Result<Vec<IndexMap<String, JsonValue>>, Error> {
|
|
||||||
let mut state = state.lock().await;
|
|
||||||
let transaction =
|
|
||||||
state.sql_transactions.get_mut(transaction_instance_id)
|
|
||||||
.expect("Invalid database transaction ID")
|
|
||||||
.as_mut() // Take reference to transaction rather than moving out of the Vec
|
|
||||||
.expect("Database transaction ID used after closed");
|
|
||||||
|
|
||||||
let query = prepare_query(&query, values);
|
|
||||||
let rows = transaction.fetch_all(query).await?;
|
|
||||||
rows_to_vec(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sql_transaction_rollback(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize) -> Result<(), Error> {
|
|
||||||
let mut state = state.lock().await;
|
|
||||||
|
|
||||||
let transaction = state.sql_transactions.get_mut(transaction_instance_id)
|
|
||||||
.expect("Invalid database transaction ID")
|
|
||||||
.take() // Remove from Vec
|
|
||||||
.expect("Database transaction ID used after closed");
|
|
||||||
|
|
||||||
transaction.rollback().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn sql_transaction_commit(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize) -> Result<(), Error> {
|
|
||||||
let mut state = state.lock().await;
|
|
||||||
|
|
||||||
let transaction = state.sql_transactions.get_mut(transaction_instance_id)
|
|
||||||
.expect("Invalid database transaction ID")
|
|
||||||
.take() // Remove from Vec
|
|
||||||
.expect("Database transaction ID used after closed");
|
|
||||||
|
|
||||||
transaction.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_query<'a, 'b: 'a>(_query: &'b str, _values: Vec<JsonValue>) -> Query<'b, Sqlite, SqliteArguments<'a>> {
|
|
||||||
// Copied from tauri_plugin_sql/src/commands.rs
|
|
||||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
||||||
// Licensed under MIT/Apache 2.0
|
|
||||||
|
|
||||||
let mut query = sqlx::query(_query);
|
|
||||||
for value in _values {
|
|
||||||
if value.is_null() {
|
|
||||||
query = query.bind(None::<JsonValue>);
|
|
||||||
} else if value.is_string() {
|
|
||||||
query = query.bind(value.as_str().unwrap().to_owned())
|
|
||||||
} else if let Some(number) = value.as_number() {
|
|
||||||
query = query.bind(number.as_f64().unwrap_or_default())
|
|
||||||
} else {
|
|
||||||
query = query.bind(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
query
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rows_to_vec(rows: Vec<SqliteRow>) -> Result<Vec<IndexMap<String, JsonValue>>, Error> {
|
|
||||||
// Copied from tauri_plugin_sql/src/commands.rs
|
|
||||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
||||||
// Licensed under MIT/Apache 2.0
|
|
||||||
|
|
||||||
let mut values = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
let mut value = IndexMap::default();
|
|
||||||
for (i, column) in row.columns().iter().enumerate() {
|
|
||||||
let v = row.try_get_raw(i)?;
|
|
||||||
|
|
||||||
let v = decode_sqlite_to_json(v)?;
|
|
||||||
|
|
||||||
value.insert(column.name().to_string(), v);
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(value);
|
|
||||||
}
|
|
||||||
Ok(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_sqlite_to_json(v: SqliteValueRef) -> Result<JsonValue, Error> {
|
|
||||||
// Copied from tauri_plugin_sql/src/decode/sqlite.rs
|
|
||||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
||||||
// Licensed under MIT/Apache 2.0
|
|
||||||
|
|
||||||
// Same as tauri_plugin_sql::decode::sqlite::to_json but that function is not exposed
|
|
||||||
|
|
||||||
if v.is_null() {
|
|
||||||
return Ok(JsonValue::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = match v.type_info().name() {
|
|
||||||
"TEXT" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode() {
|
|
||||||
JsonValue::String(v)
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"REAL" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<f64>() {
|
|
||||||
JsonValue::from(v)
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"INTEGER" | "NUMERIC" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<i64>() {
|
|
||||||
JsonValue::Number(v.into())
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"BOOLEAN" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode() {
|
|
||||||
JsonValue::Bool(v)
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"DATE" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<Date>() {
|
|
||||||
JsonValue::String(v.to_string())
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"TIME" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<Time>() {
|
|
||||||
JsonValue::String(v.to_string())
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"DATETIME" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<PrimitiveDateTime>() {
|
|
||||||
JsonValue::String(v.to_string())
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"BLOB" => {
|
|
||||||
if let Ok(v) = v.to_owned().try_decode::<Vec<u8>>() {
|
|
||||||
JsonValue::Array(v.into_iter().map(|n| JsonValue::Number(n.into())).collect())
|
|
||||||
} else {
|
|
||||||
JsonValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"NULL" => JsonValue::Null,
|
|
||||||
_ => return Err(Error::UnsupportedDatatype(v.type_info().name().to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
@ -17,7 +17,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="border-b border-gray-200 bg-white print:hidden" v-if="isMainWindow">
|
<nav class="border-b border-gray-200 bg-white print:hidden">
|
||||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
<div class="flex h-12 justify-between ml-[-0.25rem]"><!-- Adjust margin by -0.25rem to align navbar text with body text -->
|
<div class="flex h-12 justify-between ml-[-0.25rem]"><!-- Adjust margin by -0.25rem to align navbar text with body text -->
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@ -46,10 +46,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
||||||
|
|
||||||
import { db } from '../db.js';
|
import { db } from '../db.js';
|
||||||
|
|
||||||
// Only display header bar in main window
|
|
||||||
const isMainWindow = getCurrentWindow().label === 'main';
|
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,326 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
|
||||||
<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 class="btn-secondary text-red-600 ring-red-500" @click="deleteTransaction" v-if="transaction.id !== null">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 { Transaction, db, deserialiseAmount } 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,
|
|
||||||
}
|
|
||||||
export interface EditingTransaction {
|
|
||||||
id: number | null,
|
|
||||||
dt: string,
|
|
||||||
description: string,
|
|
||||||
postings: EditingPosting[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { transaction } = defineProps<{ transaction: EditingTransaction }>();
|
|
||||||
|
|
||||||
const error = ref(null as string | null);
|
|
||||||
|
|
||||||
function addPosting(posting: EditingPosting) {
|
|
||||||
const index = transaction.postings.indexOf(posting);
|
|
||||||
transaction.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;
|
|
||||||
|
|
||||||
// Prepare transaction for save
|
|
||||||
const newTransaction = new Transaction(
|
|
||||||
transaction.id,
|
|
||||||
dayjs(transaction.dt).format('YYYY-MM-DD HH:mm:ss.SSS000'),
|
|
||||||
transaction.description,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const posting of transaction.postings) {
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate transaction
|
|
||||||
if (!newTransaction.doesBalance()) {
|
|
||||||
error.value = 'Debits and credits do not balance.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await db.load();
|
|
||||||
|
|
||||||
// Validate statement line reconciliations
|
|
||||||
// Keep track of mapping, so we can fix up the reconciliation posting_id if renumbering occurs
|
|
||||||
const postingsToReconciliations = new Map();
|
|
||||||
|
|
||||||
if (newTransaction.id !== null) {
|
|
||||||
// Get statement line reconciliations affected by this transaction
|
|
||||||
const joinedReconciliations: any[] = await session.select(
|
|
||||||
`SELECT statement_line_reconciliations.id, postings.id AS posting_id, source_account, statement_lines.quantity, statement_lines.commodity
|
|
||||||
FROM statement_line_reconciliations
|
|
||||||
JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
|
||||||
JOIN statement_lines ON statement_line_reconciliations.statement_line_id = statement_lines.id
|
|
||||||
WHERE postings.transaction_id = $1`,
|
|
||||||
[newTransaction.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const joinedReconciliation of joinedReconciliations) {
|
|
||||||
for (const posting of newTransaction.postings) {
|
|
||||||
if (posting.id === joinedReconciliation.posting_id) {
|
|
||||||
if (posting.account !== joinedReconciliation.source_account || posting.quantity !== joinedReconciliation.quantity || posting.commodity !== joinedReconciliation.commodity) {
|
|
||||||
error.value = 'Edit would break reconciled statement line.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
postingsToReconciliations.set(posting, joinedReconciliation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save changes to database atomically
|
|
||||||
const dbTransaction = await session.begin();
|
|
||||||
|
|
||||||
if (newTransaction.id === null) {
|
|
||||||
// Insert new transaction
|
|
||||||
const result = await dbTransaction.execute(
|
|
||||||
`INSERT INTO transactions (dt, description)
|
|
||||||
VALUES ($1, $2)`,
|
|
||||||
[newTransaction.dt, newTransaction.description]
|
|
||||||
);
|
|
||||||
newTransaction.id = result.lastInsertId;
|
|
||||||
} else {
|
|
||||||
// Update existing transaction
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`UPDATE transactions
|
|
||||||
SET dt = $1, description = $2
|
|
||||||
WHERE id = $3`,
|
|
||||||
[newTransaction.dt, newTransaction.description, newTransaction.id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let insertPostings = false;
|
|
||||||
|
|
||||||
for (const posting of newTransaction.postings) {
|
|
||||||
if (posting.id === null) {
|
|
||||||
// When we encounter a new posting, delete and re-insert all subsequent postings to preserve the order
|
|
||||||
insertPostings = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (insertPostings) {
|
|
||||||
// Delete existing posting if required
|
|
||||||
if (posting.id !== null) {
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`DELETE FROM postings
|
|
||||||
WHERE id = $1`,
|
|
||||||
[posting.id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new posting
|
|
||||||
const result = await dbTransaction.execute(
|
|
||||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, NULL)`,
|
|
||||||
[newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fixup reconciliation if required
|
|
||||||
const joinedReconciliation = postingsToReconciliations.get(posting);
|
|
||||||
if (joinedReconciliation) {
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`UPDATE statement_line_reconciliations
|
|
||||||
SET posting_id = $1
|
|
||||||
WHERE id = $2`,
|
|
||||||
[result.lastInsertId, joinedReconciliation.id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Update existing posting
|
|
||||||
await dbTransaction.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 dbTransaction.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 dbTransaction.commit();
|
|
||||||
|
|
||||||
await getCurrentWindow().close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTransaction() {
|
|
||||||
if (!confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete atomically
|
|
||||||
const session = await db.load();
|
|
||||||
const dbTransaction = await session.begin();
|
|
||||||
|
|
||||||
// Cascade delete statement line reconciliations
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`DELETE FROM statement_line_reconciliations
|
|
||||||
WHERE posting_id IN (
|
|
||||||
SELECT postings.id FROM postings WHERE transaction_id = $1
|
|
||||||
)`,
|
|
||||||
[transaction.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete postings
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`DELETE FROM postings
|
|
||||||
WHERE transaction_id = $1`,
|
|
||||||
[transaction.id]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete transaction
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`DELETE FROM transactions
|
|
||||||
WHERE id = $1`,
|
|
||||||
[transaction.id]
|
|
||||||
)
|
|
||||||
|
|
||||||
await dbTransaction.commit();
|
|
||||||
|
|
||||||
await getCurrentWindow().close();
|
|
||||||
}
|
|
||||||
</script>
|
|
194
src/db.ts
194
src/db.ts
@ -22,9 +22,6 @@ import Database from '@tauri-apps/plugin-sql';
|
|||||||
|
|
||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
import { asCost, Balance } from './amounts.ts';
|
|
||||||
import { ExtendedDatabase } from './dbutil.ts';
|
|
||||||
|
|
||||||
export const db = reactive({
|
export const db = reactive({
|
||||||
filename: null as (string | null),
|
filename: null as (string | null),
|
||||||
|
|
||||||
@ -36,7 +33,7 @@ export const db = reactive({
|
|||||||
dps: null! as number,
|
dps: null! as number,
|
||||||
},
|
},
|
||||||
|
|
||||||
init: async function(filename: string): Promise<void> {
|
init: async function(filename: string) {
|
||||||
// Set the DB filename and initialise cached data
|
// Set the DB filename and initialise cached data
|
||||||
this.filename = filename;
|
this.filename = filename;
|
||||||
|
|
||||||
@ -53,14 +50,12 @@ export const db = reactive({
|
|||||||
this.metadata.dps = parseInt(metadataObject.amount_dps);
|
this.metadata.dps = parseInt(metadataObject.amount_dps);
|
||||||
},
|
},
|
||||||
|
|
||||||
load: async function(): Promise<ExtendedDatabase> {
|
load: async function() {
|
||||||
return new ExtendedDatabase(await Database.load('sqlite:' + this.filename));
|
return await Database.load('sqlite:' + this.filename);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function totalBalances(session: ExtendedDatabase): Promise<{account: string, quantity: number}[]> {
|
export async function totalBalances(session: Database): Promise<{account: string, quantity: number}[]> {
|
||||||
await updateRunningBalances(session);
|
|
||||||
|
|
||||||
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
|
||||||
(
|
(
|
||||||
@ -74,158 +69,18 @@ export async function totalBalances(session: ExtendedDatabase): Promise<{account
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRunningBalances(session: ExtendedDatabase) {
|
|
||||||
// TODO: This is very slow - it would be faster to do this in Rust
|
|
||||||
|
|
||||||
// Recompute any required running balances
|
|
||||||
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, running_balance
|
|
||||||
FROM transactions
|
|
||||||
JOIN postings ON transactions.id = postings.transaction_id
|
|
||||||
WHERE postings.account IN ${arraySQL}
|
|
||||||
ORDER BY dt, transaction_id, postings.id`,
|
|
||||||
staleAccounts
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update running balances atomically
|
|
||||||
const dbTransaction = await session.begin();
|
|
||||||
|
|
||||||
const runningBalances = new Map();
|
|
||||||
for (const posting of joinedTransactionPostings) {
|
|
||||||
const openingBalance = runningBalances.get(posting.account) ?? 0;
|
|
||||||
const quantityCost = asCost(posting.quantity, posting.commodity);
|
|
||||||
const runningBalance = openingBalance + quantityCost;
|
|
||||||
|
|
||||||
runningBalances.set(posting.account, runningBalance);
|
|
||||||
|
|
||||||
// Update running balance of posting
|
|
||||||
// Only perform this update if required, to avoid expensive call to DB
|
|
||||||
if (posting.running_balance !== runningBalance) {
|
|
||||||
await dbTransaction.execute(
|
|
||||||
`UPDATE postings
|
|
||||||
SET running_balance = $1
|
|
||||||
WHERE id = $2`,
|
|
||||||
[runningBalance, posting.id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbTransaction.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 class Transaction {
|
export interface Transaction {
|
||||||
constructor(
|
id: number,
|
||||||
public id: number | null = null,
|
dt: string,
|
||||||
public dt: string = '',
|
description: string,
|
||||||
public description: string = '',
|
postings: Posting[]
|
||||||
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 | null,
|
id: number,
|
||||||
description: string | null,
|
description: string,
|
||||||
account: string,
|
account: string,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
commodity: string,
|
commodity: string,
|
||||||
@ -243,3 +98,30 @@ 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;
|
||||||
|
}
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
/*
|
|
||||||
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 { invoke } from '@tauri-apps/api/core';
|
|
||||||
import Database, { QueryResult } from '@tauri-apps/plugin-sql';
|
|
||||||
|
|
||||||
export class ExtendedDatabase {
|
|
||||||
db: Database;
|
|
||||||
|
|
||||||
constructor(db: Database) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(query: string, bindValues?: unknown[]): Promise<QueryResult> {
|
|
||||||
return await this.db.execute(query, bindValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
async select<T>(query: string, bindValues?: unknown[]): Promise<T> {
|
|
||||||
return await this.db.select(query, bindValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
async begin(): Promise<DatabaseTransaction> {
|
|
||||||
const transactionInstanceId: number = await invoke('sql_transaction_begin', {
|
|
||||||
db: this.db.path
|
|
||||||
});
|
|
||||||
const db_transaction = new DatabaseTransaction(this, transactionInstanceId);
|
|
||||||
registry.register(db_transaction, transactionInstanceId, db_transaction); // Remember to rollback and close connection on finalization
|
|
||||||
return db_transaction;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DatabaseTransaction {
|
|
||||||
db: ExtendedDatabase;
|
|
||||||
transactionInstanceId: number;
|
|
||||||
|
|
||||||
constructor(db: ExtendedDatabase, transactionInstanceId: number) {
|
|
||||||
this.db = db;
|
|
||||||
this.transactionInstanceId = transactionInstanceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(query: string, bindValues?: unknown[]): Promise<QueryResult> {
|
|
||||||
const [rowsAffected, lastInsertId] = await invoke('sql_transaction_execute', {
|
|
||||||
transactionInstanceId: this.transactionInstanceId,
|
|
||||||
query,
|
|
||||||
values: bindValues ?? []
|
|
||||||
}) as [number, number];
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastInsertId: lastInsertId,
|
|
||||||
rowsAffected: rowsAffected
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async select<T>(query: string, bindValues?: unknown[]): Promise<T> {
|
|
||||||
const result: T = await invoke('sql_transaction_select', {
|
|
||||||
transactionInstanceId: this.transactionInstanceId,
|
|
||||||
query,
|
|
||||||
values: bindValues ?? []
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async rollback(): Promise<void> {
|
|
||||||
registry.unregister(this);
|
|
||||||
await invoke('sql_transaction_rollback', {
|
|
||||||
transactionInstanceId: this.transactionInstanceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async commit(): Promise<void> {
|
|
||||||
registry.unregister(this);
|
|
||||||
await invoke('sql_transaction_commit', {
|
|
||||||
transactionInstanceId: this.transactionInstanceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = new FinalizationRegistry(async (transactionInstanceId) => {
|
|
||||||
// Remember to rollback and close connection on finalization
|
|
||||||
await invoke('sql_transaction_rollback', {
|
|
||||||
transactionInstanceId: transactionInstanceId
|
|
||||||
});
|
|
||||||
});
|
|
14
src/main.ts
14
src/main.ts
@ -17,7 +17,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@ -32,9 +31,6 @@ 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: '/journal/new-transaction', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') },
|
|
||||||
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
|
|
||||||
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
||||||
];
|
];
|
||||||
@ -53,14 +49,4 @@ 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();
|
||||||
|
@ -1,71 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
|
||||||
|
|
||||||
<TransactionEditor :transaction="transaction" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
|
|
||||||
import { JoinedTransactionPosting, Posting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
|
|
||||||
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const transaction = ref({
|
|
||||||
id: null,
|
|
||||||
dt: null!,
|
|
||||||
description: null!,
|
|
||||||
postings: []
|
|
||||||
} as EditingTransaction);
|
|
||||||
|
|
||||||
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();
|
|
||||||
</script>
|
|
@ -21,12 +21,7 @@
|
|||||||
General ledger
|
General ledger
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="my-4 flex gap-x-2">
|
<div class="my-4 flex">
|
||||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
|
||||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
|
||||||
<PlusIcon class="w-4 h-4" />
|
|
||||||
New transaction
|
|
||||||
</a>
|
|
||||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||||
</div>
|
</div>
|
||||||
@ -61,14 +56,11 @@
|
|||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { PencilIcon, PlusIcon } 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);
|
||||||
|
|
||||||
@ -89,23 +81,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) {
|
||||||
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 } ${ editLink }</td>
|
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description }</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(
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
||||||
<ul class="list-disc ml-6">
|
<ul class="list-disc ml-6">
|
||||||
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
|
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
|
||||||
<li><RouterLink :to="{ name: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
|
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</a></li>-->
|
||||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>-->
|
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>-->
|
||||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
||||||
<!-- TODO: Plugin reports -->
|
<!-- TODO: Plugin reports -->
|
||||||
|
@ -22,11 +22,12 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="my-4 flex gap-x-2">
|
<div class="my-4 flex gap-x-2">
|
||||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
<!--<a href="{{ url_for('journal_new_transaction') }}" class="btn-primary pl-2">
|
||||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
<PlusIcon class="w-4 h-4" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
New transaction
|
New transaction
|
||||||
</a>
|
</a>-->
|
||||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||||
</div>
|
</div>
|
||||||
@ -61,14 +62,11 @@
|
|||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { PencilIcon, PlusIcon } 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);
|
||||||
|
|
||||||
@ -89,17 +87,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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">
|
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description }</td>
|
||||||
${ 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>`
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
<!--
|
|
||||||
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">
|
|
||||||
New transaction
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<TransactionEditor :transaction="transaction" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
|
|
||||||
|
|
||||||
// Initialise blank transaction
|
|
||||||
const transaction = ref({
|
|
||||||
id: null,
|
|
||||||
dt: dayjs().format('YYYY-MM-DD'),
|
|
||||||
description: '',
|
|
||||||
postings: [
|
|
||||||
// One blank Dr and one blank Cr posting
|
|
||||||
{
|
|
||||||
id: null,
|
|
||||||
description: null,
|
|
||||||
account: '',
|
|
||||||
sign: 'dr',
|
|
||||||
amount_abs: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: null,
|
|
||||||
description: null,
|
|
||||||
account: '',
|
|
||||||
sign: 'cr',
|
|
||||||
amount_abs: '',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
} as EditingTransaction);
|
|
||||||
</script>
|
|
@ -1,186 +0,0 @@
|
|||||||
<!--
|
|
||||||
DrCr: Web-based double-entry bookkeeping framework
|
|
||||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1 class="page-heading">
|
|
||||||
Statement lines
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="my-2 py-2 flex bg-white sticky top-0">
|
|
||||||
<div class="grow flex gap-x-2 items-baseline">
|
|
||||||
<!--<button class="btn-secondary text-emerald-700 ring-emerald-600">
|
|
||||||
Reconcile selected as transfer
|
|
||||||
</button>
|
|
||||||
<a href="#" class="btn-secondary">
|
|
||||||
Import statement
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
|
||||||
Show only unclassified lines
|
|
||||||
</a>-->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="statement-line-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
|
||||||
<table class="min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-300">
|
|
||||||
<th></th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Source account</th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Description</th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Charged to</th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
|
|
||||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
|
|
||||||
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td class="py-0.5 px-1" colspan="7">Loading data…</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Clusterize from 'clusterize.js';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
import { PencilIcon } from '@heroicons/vue/24/outline';
|
|
||||||
|
|
||||||
import { onUnmounted, ref, watch } from 'vue';
|
|
||||||
|
|
||||||
import { db } from '../db.ts';
|
|
||||||
import { renderComponent } from '../webutil.ts';
|
|
||||||
import { ppWithCommodity } from '../display.ts';
|
|
||||||
|
|
||||||
interface StatementLine {
|
|
||||||
id: number,
|
|
||||||
source_account: string,
|
|
||||||
dt: string,
|
|
||||||
description: string,
|
|
||||||
quantity: number,
|
|
||||||
balance: number | null,
|
|
||||||
commodity: string,
|
|
||||||
transaction_id: number,
|
|
||||||
posting_accounts: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const statementLines = ref([] as StatementLine[]);
|
|
||||||
let clusterize: Clusterize | null = null;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const session = await db.load();
|
|
||||||
|
|
||||||
const joinedStatementLines: any[] = await session.select(
|
|
||||||
`SELECT statement_lines.id, source_account, statement_lines.dt, statement_lines.description, statement_lines.quantity, statement_lines.balance, statement_lines.commodity, p2.transaction_id, p2.account AS posting_account
|
|
||||||
FROM statement_lines
|
|
||||||
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
|
||||||
LEFT JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
|
||||||
LEFT JOIN transactions ON postings.transaction_id = transactions.id
|
|
||||||
LEFT JOIN postings p2 ON transactions.id = p2.transaction_id
|
|
||||||
ORDER BY statement_lines.dt DESC, statement_lines.id DESC, p2.id`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unflatten statement lines
|
|
||||||
const newStatementLines: StatementLine[] = [];
|
|
||||||
|
|
||||||
for (const joinedStatementLine of joinedStatementLines) {
|
|
||||||
if (newStatementLines.length === 0 || newStatementLines.at(-1)!.id !== joinedStatementLine.id) {
|
|
||||||
newStatementLines.push({
|
|
||||||
id: joinedStatementLine.id,
|
|
||||||
source_account: joinedStatementLine.source_account,
|
|
||||||
dt: joinedStatementLine.dt,
|
|
||||||
description: joinedStatementLine.description,
|
|
||||||
quantity: joinedStatementLine.quantity,
|
|
||||||
balance: joinedStatementLine.balance,
|
|
||||||
commodity: joinedStatementLine.commodity,
|
|
||||||
transaction_id: joinedStatementLine.transaction_id,
|
|
||||||
posting_accounts: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (joinedStatementLine.posting_account !== null) {
|
|
||||||
newStatementLines.at(-1)!.posting_accounts.push(joinedStatementLine.posting_account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statementLines.value = newStatementLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable() {
|
|
||||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
for (const line of statementLines.value) {
|
|
||||||
let reconciliationCell;
|
|
||||||
if (line.posting_accounts.length === 0) {
|
|
||||||
// Unreconciled
|
|
||||||
reconciliationCell =
|
|
||||||
`<a href="#" class="text-red-500 hover:text-red-600 hover:underline" onclick="return classifyLine(this);">Unclassified</a>
|
|
||||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
|
||||||
} else if (line.posting_accounts.length === 2) {
|
|
||||||
// Simple reconciliation
|
|
||||||
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
|
|
||||||
reconciliationCell =
|
|
||||||
`<a href="#" class="hover:text-blue-700 hover:underline" onclick="return classifyLine(this);">${ otherAccount }</a>
|
|
||||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
|
||||||
} else {
|
|
||||||
// Complex reconciliation
|
|
||||||
reconciliationCell =
|
|
||||||
`<i>(Complex)</i>
|
|
||||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
`<tr data-line-id="{{ line.id }}">
|
|
||||||
<td class="py-0.5 pr-1 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-line-id" value="${ line.id }"></td>
|
|
||||||
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="#" class="hover:text-blue-700 hover:underline">${ line.source_account }</a></td>
|
|
||||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex]">${ dayjs(line.dt).format('YYYY-MM-DD') }</td>
|
|
||||||
<td class="py-0.5 px-1 align-baseline text-gray-900">${ line.description }</td>
|
|
||||||
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900">${ reconciliationCell }</td>
|
|
||||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }</td>
|
|
||||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }</td>
|
|
||||||
<td class="py-0.5 pl-1 align-baseline text-gray-900 text-end">${ line.balance ?? '' }</td>
|
|
||||||
</tr>`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clusterize === null) {
|
|
||||||
clusterize = new Clusterize({
|
|
||||||
'rows': rows,
|
|
||||||
scrollElem: document.getElementById('statement-line-list')!,
|
|
||||||
contentElem: document.querySelector('#statement-line-list tbody')!
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
clusterize.update(rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(statementLines, renderTable);
|
|
||||||
|
|
||||||
load();
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (clusterize !== null) {
|
|
||||||
clusterize.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
@ -22,11 +22,10 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="my-4 flex gap-x-2">
|
<div class="my-4 flex gap-x-2">
|
||||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
<!--<a href="{{ url_for('journal_new_transaction') }}" class="btn-primary pl-2">
|
||||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
<PlusIcon />
|
||||||
<PlusIcon class="w-4 h-4" />
|
|
||||||
New transaction
|
New transaction
|
||||||
</a>
|
</a>-->
|
||||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||||
</div>
|
</div>
|
||||||
@ -36,12 +35,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusIcon } from '@heroicons/vue/24/outline';
|
//import { PlusIcon } from '@heroicons/vue/24/solid';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions, updateRunningBalances } from '../db.ts';
|
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts';
|
||||||
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
|
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
|
||||||
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
|
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
|
||||||
|
|
||||||
@ -53,9 +52,6 @@
|
|||||||
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(session);
|
|
||||||
|
|
||||||
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,15 +43,12 @@
|
|||||||
|
|
||||||
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[] }>();
|
||||||
@ -78,18 +75,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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">${ transaction.description } ${ editLink }</td>
|
<td class="py-0.5 px-1 text-gray-900">
|
||||||
|
${ transaction.description }
|
||||||
|
<!-- TODO: Edit button -->
|
||||||
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
@ -44,15 +44,12 @@
|
|||||||
|
|
||||||
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[] }>();
|
||||||
@ -61,15 +58,9 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -85,7 +76,10 @@
|
|||||||
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">${ transaction.description } ${ editLink }</td>
|
<td class="py-0.5 px-1 text-gray-900">
|
||||||
|
${ 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>
|
||||||
@ -98,7 +92,10 @@
|
|||||||
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">${ transaction.description } ${ editLink }</td>
|
<td colspan="2" class="py-0.5 px-1 text-gray-900">
|
||||||
|
${ transaction.description }
|
||||||
|
<!-- TODO: Edit button -->
|
||||||
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
/*
|
|
||||||
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,7 +1,4 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
import tailwindcssforms from '@tailwindcss/forms';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
@ -13,7 +10,5 @@ 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