Added transaction searching, optimized transaction fetching as well.
This commit is contained in:
parent
7150b0b259
commit
8f3a334ce4
|
|
@ -6,12 +6,15 @@ on:
|
|||
- '.gitea/workflows/api.yaml'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-ons: ubuntu-latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dlang-community/setup-dlang@v2
|
||||
with:
|
||||
compiler: ldc-latest
|
||||
compiler: ldc-latest\
|
||||
- name: Test
|
||||
run: dub test
|
||||
working-directory: ./finnow-api
|
||||
- name: Build
|
||||
run: dub build --build=release
|
||||
working-directory: ./finnow-api
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
meta {
|
||||
name: Search Test
|
||||
type: http
|
||||
seq: 6
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/transactions/search
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
Code,Symbol
|
||||
USD,$
|
||||
CAD,$
|
||||
AUD,$
|
||||
GBP,£
|
||||
EUR,€
|
||||
CHF,Fr
|
||||
ZAR,R
|
||||
JPY,¥
|
||||
INR,₹
|
||||
|
|
|
@ -83,7 +83,7 @@ version(unittest) {
|
|||
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
History getHistory(ulong id) {
|
||||
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
}
|
||||
|
|
@ -108,13 +108,13 @@ version(unittest) {
|
|||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
||||
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
class TestAccountValueRecordRepositoryStub : AccountValueRecordRepository {
|
||||
Optional!AccountValueRecord findById(ulong id) {
|
||||
Optional!AccountValueRecord findById(ulong accountId, ulong id) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AccountValueRecord insert(
|
||||
|
|
@ -135,5 +135,14 @@ version(unittest) {
|
|||
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
void deleteById(ulong accountId, ulong id) {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ unittest {
|
|||
this.journalEntries = journalEntries;
|
||||
}
|
||||
|
||||
override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
||||
override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
|
||||
auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl)
|
||||
.array;
|
||||
matches.sort!((a, b) => a.timestamp < b.timestamp);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory);
|
||||
// Transaction endpoints:
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search", &handleSearchTransactions);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleGetTransaction);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction);
|
||||
|
|
|
|||
|
|
@ -49,10 +49,14 @@ version(unittest) {
|
|||
class TestProfileDataSourceStub : ProfileDataSource {
|
||||
import account.data;
|
||||
import transaction.data;
|
||||
import attachment.data;
|
||||
|
||||
PropertiesRepository getPropertiesRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AttachmentRepository getAttachmentRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
AccountRepository getAccountRepository() {
|
||||
throw new Exception("Not implemented");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import util.data;
|
|||
|
||||
// Transactions API
|
||||
|
||||
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
||||
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]);
|
||||
|
||||
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
|
|
@ -28,6 +28,13 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
writeJsonBody(response, responsePage);
|
||||
}
|
||||
|
||||
void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||
auto page = ds.getTransactionRepository().search(pr, request);
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.data;
|
||||
|
||||
import handy_http_primitives : Optional;
|
||||
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
|
|
@ -36,7 +36,8 @@ interface TransactionTagRepository {
|
|||
}
|
||||
|
||||
interface TransactionRepository {
|
||||
Page!TransactionsListItem findAll(PageRequest pr);
|
||||
Page!TransactionsListItem findAll(in PageRequest pr);
|
||||
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request);
|
||||
Optional!TransactionDetail findById(ulong id);
|
||||
TransactionDetail insert(in AddTransactionPayload data);
|
||||
void linkAttachment(ulong transactionId, ulong attachmentId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module transaction.data_impl_sqlite;
|
||||
|
||||
import handy_http_primitives : Optional;
|
||||
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||
import std.datetime;
|
||||
import std.typecons;
|
||||
import d2sqlite3;
|
||||
|
|
@ -198,69 +198,60 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
this.db = db;
|
||||
}
|
||||
|
||||
Page!TransactionsListItem findAll(PageRequest pr) {
|
||||
const BASE_QUERY = import("sql/get_transactions.sql");
|
||||
// TODO: Implement filtering or something!
|
||||
import std.array;
|
||||
const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME;
|
||||
auto sqlBuilder = appender!string;
|
||||
sqlBuilder ~= BASE_QUERY;
|
||||
sqlBuilder ~= " ";
|
||||
sqlBuilder ~= pr.toSql();
|
||||
string query = sqlBuilder[];
|
||||
TransactionsListItem[] results = util.sqlite.findAll(db, query, (row) {
|
||||
TransactionsListItem item;
|
||||
item.id = row.peek!ulong(0);
|
||||
item.timestamp = row.peek!string(1);
|
||||
item.addedAt = row.peek!string(2);
|
||||
item.amount = row.peek!ulong(3);
|
||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||
item.description = row.peek!string(5);
|
||||
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
||||
if (!vendorId.isNull) {
|
||||
string vendorName = row.peek!string(7);
|
||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||
}
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
||||
if (!categoryId.isNull) {
|
||||
string categoryName = row.peek!string(9);
|
||||
string categoryColor = row.peek!string(10);
|
||||
item.category = Optional!(TransactionsListItem.Category).of(
|
||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||
}
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
||||
if (!creditedAccountId.isNull) {
|
||||
ulong id = creditedAccountId.get;
|
||||
string name = row.peek!string(12);
|
||||
string type = row.peek!string(13);
|
||||
string suffix = row.peek!string(14);
|
||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
||||
if (!debitedAccountId.isNull) {
|
||||
ulong id = debitedAccountId.get;
|
||||
string name = row.peek!string(16);
|
||||
string type = row.peek!string(17);
|
||||
string suffix = row.peek!string(18);
|
||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
string tagsStr = row.peek!string(19);
|
||||
if (tagsStr !is null && tagsStr.length > 0) {
|
||||
import std.string : split;
|
||||
item.tags = tagsStr.split(",");
|
||||
} else {
|
||||
item.tags = [];
|
||||
}
|
||||
return item;
|
||||
});
|
||||
ulong totalCount = util.sqlite.count(db, countQuery);
|
||||
Page!TransactionsListItem findAll(in PageRequest pr) {
|
||||
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
|
||||
QueryBuilder qb = getBuilderForTransactionsList();
|
||||
addSelectsForTransactionsList(qb);
|
||||
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
|
||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
|
||||
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
||||
}
|
||||
|
||||
Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) {
|
||||
import std.algorithm;
|
||||
import std.conv;
|
||||
import std.string : join;
|
||||
import transaction.search_filters;
|
||||
import std.stdio;
|
||||
QueryBuilder qb = getBuilderForTransactionsList();
|
||||
|
||||
// 1. Get the total count of transactions that match the search filters.
|
||||
qb.select("COUNT (DISTINCT txn.id)");
|
||||
applyFilters(qb, request);
|
||||
string countQuery = qb.build();
|
||||
// writeln(countQuery);
|
||||
Statement countStmt = db.prepare(countQuery);
|
||||
qb.applyArgBindings(countStmt);
|
||||
auto countResult = countStmt.execute();
|
||||
ulong count = countResult.empty ? 0 : countResult.front.peek!ulong(0);
|
||||
if (count == 0) return Page!TransactionsListItem.of([], pr, 0);
|
||||
|
||||
// 2. Select the ordered list of IDs of transactions to include in the page of results.
|
||||
// This is because one transaction may be split over multiple db rows.
|
||||
qb.selections = [];
|
||||
qb.select("DISTINCT txn.id");
|
||||
string idsQuery = qb.build() ~ "\n" ~ pr.toSql();
|
||||
Statement idsStmt = db.prepare(idsQuery);
|
||||
qb.applyArgBindings(idsStmt);
|
||||
string idsStr = idsStmt.execute()
|
||||
.map!(r => r.peek!ulong(0))
|
||||
.map!(id => id.to!string)
|
||||
.join(",");
|
||||
|
||||
// 3. Select the list of transactions for the results based on the IDs found in step 2.
|
||||
qb.selections = [];
|
||||
qb.conditions = [];
|
||||
qb.argBinders = [];
|
||||
addSelectsForTransactionsList(qb);
|
||||
qb.where("txn.id IN (" ~ idsStr ~ ")");
|
||||
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||
Statement stmt = db.prepare(query);
|
||||
auto results = parseListItems(stmt.execute());
|
||||
return Page!TransactionsListItem.of(results, pr, count);
|
||||
}
|
||||
|
||||
Optional!TransactionDetail findById(ulong id) {
|
||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||
db,
|
||||
|
|
@ -426,6 +417,144 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to parse a list of transaction list items as obtained from the
|
||||
* `get_transactions.sql` query. Because there are possibly multiple rows
|
||||
* per transaction, we have to deal with the result range as a whole.
|
||||
* Params:
|
||||
* r = The result range to read.
|
||||
* Returns: The list of transaction list items.
|
||||
*/
|
||||
private static TransactionsListItem[] parseListItems(ResultRange r) {
|
||||
import std.array : appender;
|
||||
auto app = appender!(TransactionsListItem[]);
|
||||
TransactionsListItem item;
|
||||
|
||||
/// Helper function that appends the current item to the list, and resets state.
|
||||
void appendItem() {
|
||||
import std.algorithm : sort;
|
||||
sort(item.tags);
|
||||
app ~= item;
|
||||
item.id = 0;
|
||||
item.tags = [];
|
||||
}
|
||||
|
||||
size_t rowCount = 0;
|
||||
foreach (Row row; r) {
|
||||
rowCount++;
|
||||
ulong txnId = row.peek!ulong(0);
|
||||
if (item.id != txnId) {
|
||||
// We're parsing a new item. First, append the current one if there is one.
|
||||
if (item.id != 0) {
|
||||
appendItem();
|
||||
}
|
||||
|
||||
item.id = txnId;
|
||||
item.timestamp = row.peek!string(1);
|
||||
item.addedAt = row.peek!string(2);
|
||||
item.amount = row.peek!ulong(3);
|
||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||
item.description = row.peek!string(5);
|
||||
// Read the nullable Vendor information.
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
||||
if (!vendorId.isNull) {
|
||||
string vendorName = row.peek!string(7);
|
||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||
}
|
||||
// Read the nullable Category information.
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
||||
if (!categoryId.isNull) {
|
||||
string categoryName = row.peek!string(9);
|
||||
string categoryColor = row.peek!string(10);
|
||||
item.category = Optional!(TransactionsListItem.Category).of(
|
||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||
}
|
||||
// Read the nullable creditedAccount.
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
||||
if (!creditedAccountId.isNull) {
|
||||
ulong id = creditedAccountId.get;
|
||||
string name = row.peek!string(12);
|
||||
string type = row.peek!string(13);
|
||||
string suffix = row.peek!string(14);
|
||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
// Read the nullable debitedAccount.
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
||||
if (!debitedAccountId.isNull) {
|
||||
ulong id = debitedAccountId.get;
|
||||
string name = row.peek!string(16);
|
||||
string type = row.peek!string(17);
|
||||
string suffix = row.peek!string(18);
|
||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
}
|
||||
|
||||
// Read multi-row properties, like tags, to the current item.
|
||||
string tag = row.peek!string(19);
|
||||
if (tag !is null) {
|
||||
item.tags ~= tag;
|
||||
}
|
||||
}
|
||||
// If there's one last cached item, append it to the list.
|
||||
if (item.id != 0) {
|
||||
appendItem();
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
static TransactionsListItem parseTransactionsListItem(Row row) {
|
||||
TransactionsListItem item;
|
||||
item.id = row.peek!ulong(0);
|
||||
item.timestamp = row.peek!string(1);
|
||||
item.addedAt = row.peek!string(2);
|
||||
item.amount = row.peek!ulong(3);
|
||||
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
|
||||
item.description = row.peek!string(5);
|
||||
|
||||
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
|
||||
if (!vendorId.isNull) {
|
||||
string vendorName = row.peek!string(7);
|
||||
item.vendor = Optional!(TransactionsListItem.Vendor).of(
|
||||
TransactionsListItem.Vendor(vendorId.get, vendorName));
|
||||
}
|
||||
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
|
||||
if (!categoryId.isNull) {
|
||||
string categoryName = row.peek!string(9);
|
||||
string categoryColor = row.peek!string(10);
|
||||
item.category = Optional!(TransactionsListItem.Category).of(
|
||||
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
|
||||
}
|
||||
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
|
||||
if (!creditedAccountId.isNull) {
|
||||
ulong id = creditedAccountId.get;
|
||||
string name = row.peek!string(12);
|
||||
string type = row.peek!string(13);
|
||||
string suffix = row.peek!string(14);
|
||||
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
|
||||
if (!debitedAccountId.isNull) {
|
||||
ulong id = debitedAccountId.get;
|
||||
string name = row.peek!string(16);
|
||||
string type = row.peek!string(17);
|
||||
string suffix = row.peek!string(18);
|
||||
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
|
||||
TransactionsListItem.Account(id, name, type, suffix));
|
||||
}
|
||||
string tagsStr = row.peek!string(19);
|
||||
if (tagsStr !is null && tagsStr.length > 0) {
|
||||
import std.string : split;
|
||||
item.tags = tagsStr.split(",");
|
||||
} else {
|
||||
item.tags = [];
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||
foreach (size_t idx, lineItem; data.lineItems) {
|
||||
util.sqlite.update(
|
||||
|
|
@ -440,4 +569,41 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
private QueryBuilder getBuilderForTransactionsList() {
|
||||
return QueryBuilder("\"transaction\" txn")
|
||||
.join("LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id")
|
||||
.join("LEFT JOIN transaction_category category ON category.id = txn.category_id")
|
||||
.join("LEFT JOIN account_journal_entry j_credit " ~
|
||||
"ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'")
|
||||
.join("LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id")
|
||||
.join("LEFT JOIN account_journal_entry j_debit " ~
|
||||
"ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'")
|
||||
.join("LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id")
|
||||
.join("LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id");
|
||||
}
|
||||
|
||||
private void addSelectsForTransactionsList(ref QueryBuilder qb) {
|
||||
qb
|
||||
.select("txn.id")
|
||||
.select("txn.timestamp")
|
||||
.select("txn.added_at")
|
||||
.select("txn.amount")
|
||||
.select("txn.currency")
|
||||
.select("txn.description")
|
||||
.select("txn.vendor_id")
|
||||
.select("vendor.name")
|
||||
.select("txn.category_id")
|
||||
.select("category.name")
|
||||
.select("category.color")
|
||||
.select("account_credit.id")
|
||||
.select("account_credit.name")
|
||||
.select("account_credit.type")
|
||||
.select("account_credit.number_suffix")
|
||||
.select("account_debit.id")
|
||||
.select("account_debit.name")
|
||||
.select("account_debit.type")
|
||||
.select("account_debit.number_suffix")
|
||||
.select("tags.tag");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* This module provides helper logic for applying filters when searching over
|
||||
* transactions.
|
||||
*/
|
||||
module transaction.search_filters;
|
||||
|
||||
import handy_http_primitives;
|
||||
import std.algorithm;
|
||||
import std.conv;
|
||||
import std.array;
|
||||
import std.range;
|
||||
import std.string;
|
||||
import std.uri;
|
||||
import std.uni;
|
||||
|
||||
import util.sqlite;
|
||||
|
||||
/**
|
||||
* Applies a set of filters to a query builder for searching over transactions.
|
||||
* Params:
|
||||
* qb = The query builder to add WHERE clauses and argument bindings to.
|
||||
* request = The request to get filter options from.
|
||||
*/
|
||||
void applyFilters(ref QueryBuilder qb, in ServerHttpRequest request) {
|
||||
applyPropertyInFilter!string(qb, request, "tags.tag", "tag");
|
||||
applyPropertyInFilter!ulong(qb, request, "vendor.id", "vendor");
|
||||
applyPropertyInFilter!ulong(qb, request, "category.id", "category");
|
||||
applyPropertyInFilter!string(qb, request, "txn.currency", "currency");
|
||||
applyPropertyInFilter!ulong(qb, request, "account_credit.id", "credited-account");
|
||||
applyPropertyInFilter!ulong(qb, request, "account_debit.id", "debited-account");
|
||||
|
||||
// Separate filter that combines both credit and debit accounts.
|
||||
if (request.hasParam("account")) {
|
||||
ulong[] accountIds = request.getParamValues!ulong("account");
|
||||
string inStr = "(" ~ "?".repeat(accountIds.length).join(",") ~ ")";
|
||||
qb.where("(account_credit.id IN " ~ inStr ~ " OR account_debit.id IN " ~ inStr ~ ")");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
foreach (value; accountIds) {
|
||||
stmt.bind(idx++, value);
|
||||
}
|
||||
// Again for the second IN clause.
|
||||
foreach (value; accountIds) {
|
||||
stmt.bind(idx++, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (request.hasParam("min-amount")) {
|
||||
ulong[] values = request.getParamValues!ulong("min-amount");
|
||||
if (values.length > 0) {
|
||||
ulong minAmount = values[0];
|
||||
qb.where("txn.amount >= ?");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
stmt.bind(idx++, minAmount);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (request.hasParam("max-amount")) {
|
||||
ulong[] values = request.getParamValues!ulong("max-amount");
|
||||
if (values.length > 0) {
|
||||
ulong minAmount = values[0];
|
||||
qb.where("txn.amount <= ?");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
stmt.bind(idx++, minAmount);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Textual search query:
|
||||
if (request.hasParam("q")) {
|
||||
string searchQuery = request.getParamValues!string("q")[0];
|
||||
string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%";
|
||||
const string[] conditions = [
|
||||
"UPPER(txn.description) LIKE ?",
|
||||
"UPPER(vendor.name) LIKE ?",
|
||||
"UPPER(category.name) LIKE ?",
|
||||
"UPPER(account_credit.name) LIKE ?",
|
||||
"UPPER(account_debit.name) LIKE ?",
|
||||
"UPPER(tags.tag) LIKE ?"
|
||||
];
|
||||
qb.where("(" ~ conditions.join(" OR ") ~ ")");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
for (int i = 0; i < conditions.length; i++) {
|
||||
stmt.bind(idx++, likeStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPropertyInFilter(T)(
|
||||
ref QueryBuilder qb,
|
||||
in ServerHttpRequest request,
|
||||
string property,
|
||||
string key
|
||||
) {
|
||||
if (request.hasParam(key)) {
|
||||
T[] values = request.getParamValues!T(key);
|
||||
qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")");
|
||||
qb.withArgBinding((ref stmt, ref idx) {
|
||||
foreach (value; values) {
|
||||
stmt.bind(idx++, value);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private bool hasParam(in ServerHttpRequest request, string key) {
|
||||
foreach (param; request.queryParams) {
|
||||
if (param.key == key && param.values.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private T[] getParamValues(T = string)(in ServerHttpRequest request, string key) {
|
||||
foreach (param; request.queryParams) {
|
||||
if (param.key == key) {
|
||||
return param.values.map!(s => s.to!T).array;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import std.traits : isSomeString, EnumMembers;
|
|||
* https://en.wikipedia.org/wiki/ISO_4217
|
||||
*/
|
||||
struct Currency {
|
||||
// The common name of the currency.
|
||||
string name;
|
||||
/// The common 3-character code for the currency, like "USD".
|
||||
char[3] code;
|
||||
/// The number of digits after the decimal place that the currency supports.
|
||||
|
|
@ -27,20 +29,85 @@ struct Currency {
|
|||
}
|
||||
}
|
||||
|
||||
/// An enumeration of all available currencies.
|
||||
enum Currencies : Currency {
|
||||
AUD = Currency("AUD", 2, 36, "$"),
|
||||
USD = Currency("USD", 2, 840, "$"),
|
||||
CAD = Currency("CAD", 2, 124, "$"),
|
||||
GBP = Currency("GBP", 2, 826, "£"),
|
||||
EUR = Currency("EUR", 2, 978, "€"),
|
||||
CHF = Currency("CHF", 2, 756, "Fr"),
|
||||
ZAR = Currency("ZAR", 2, 710, "R"),
|
||||
JPY = Currency("JPY", 0, 392, "¥"),
|
||||
INR = Currency("INR", 2, 356, "₹")
|
||||
/**
|
||||
* An enumeration defining all available currencies. This is generated at
|
||||
* compile time by reading currency data from CSV files and generating a list
|
||||
* of currency declarations.
|
||||
*/
|
||||
mixin("enum Currencies : Currency {\n" ~ getCurrenciesEnumMembers() ~ "\n}");
|
||||
|
||||
/**
|
||||
* A list of all currencies, as a convenience for getting all members of the
|
||||
* `Currencies` enum.
|
||||
*/
|
||||
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];
|
||||
|
||||
private Currency[] readCurrenciesFromFile() {
|
||||
import std.csv;
|
||||
import std.stdio;
|
||||
import std.algorithm;
|
||||
import std.typecons;
|
||||
import std.array;
|
||||
import std.string;
|
||||
import std.conv;
|
||||
// First read the list of known currency symbols and use it as a lookup table.
|
||||
string[string] knownCurrencySymbols;
|
||||
const string currencySymbolsFile = import("currency_symbols.csv");
|
||||
foreach (record; currencySymbolsFile.csvReader!(Tuple!(string, string))) {
|
||||
string code = record[0].strip();
|
||||
string symbol = record[1].strip();
|
||||
knownCurrencySymbols[code] = symbol;
|
||||
}
|
||||
// Then read the list of currencies.
|
||||
auto app = appender!(Currency[]);
|
||||
auto codes = appender!(string[]);
|
||||
const string currenciesFile = import("currency_codes_ISO4217.csv");
|
||||
foreach (record; currenciesFile.csvReader!(Tuple!(string, string, string, string, string, string))) {
|
||||
string currencyName = record[1].strip();
|
||||
string code = record[2].strip();
|
||||
string numericCode = record[3].strip();
|
||||
string minorUnit = record[4].strip();
|
||||
string withdrawalDate = record[5].strip();
|
||||
string symbol;
|
||||
if (code in knownCurrencySymbols) {
|
||||
symbol = knownCurrencySymbols[code];
|
||||
} else {
|
||||
symbol = "$";
|
||||
}
|
||||
if (
|
||||
withdrawalDate.length > 0 ||
|
||||
canFind(codes[], code) ||
|
||||
code.length != 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (minorUnit == "-") {
|
||||
minorUnit = "0";
|
||||
}
|
||||
app ~= Currency(currencyName, code[0..3], minorUnit.to!ubyte, numericCode.to!ushort, symbol);
|
||||
codes ~= code;
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];
|
||||
private string getCurrenciesEnumMembers() {
|
||||
import std.algorithm;
|
||||
import std.array;
|
||||
import std.format;
|
||||
import std.conv;
|
||||
auto currencies = readCurrenciesFromFile();
|
||||
return currencies
|
||||
.map!(c => format!"%s = Currency(\"%s\", \"%s\", %d, %d, \"%s\")"(
|
||||
c.code,
|
||||
c.name,
|
||||
c.code,
|
||||
c.fractionalDigits,
|
||||
c.numericCode,
|
||||
c.symbol
|
||||
))
|
||||
.joiner(",\n")
|
||||
.array.to!string;
|
||||
}
|
||||
|
||||
unittest {
|
||||
assert(Currency.ofCode("USD") == Currencies.USD);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,19 @@ struct PageRequest {
|
|||
string toSql() const {
|
||||
import std.array;
|
||||
auto app = appender!string;
|
||||
app ~= this.toOrderClause();
|
||||
if (!isUnpaged()) {
|
||||
app ~= "LIMIT ";
|
||||
app ~= size.to!string;
|
||||
app ~= " OFFSET ";
|
||||
app ~= ((page - 1) * size).to!string;
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
string toOrderClause() const {
|
||||
import std.array;
|
||||
auto app = appender!string;
|
||||
if (sorts.length > 0) {
|
||||
app ~= "ORDER BY ";
|
||||
for (size_t i = 0; i < sorts.length; i++) {
|
||||
|
|
@ -91,12 +103,6 @@ struct PageRequest {
|
|||
}
|
||||
app ~= " ";
|
||||
}
|
||||
if (!isUnpaged()) {
|
||||
app ~= "LIMIT ";
|
||||
app ~= size.to!string;
|
||||
app ~= " OFFSET ";
|
||||
app ~= ((page - 1) * size).to!string;
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,23 @@ T[] findAll(T, Args...)(Database db, string query, T function(Row) resultMapper,
|
|||
return stmt.execute().map!(r => resultMapper(r)).array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a list of records from a database, using a single function to parse
|
||||
* the entire result set at once, useful for cases where records may be spread
|
||||
* over multiple rows due to joined properties.
|
||||
* Params:
|
||||
* db = The database to use.
|
||||
* query = The query to execute.
|
||||
* resultMapper = A function to map the result range to the list of results.
|
||||
* args = Arguments for the query.
|
||||
* Returns: A list of results.
|
||||
*/
|
||||
T[] findAllDirect(T, Args...)(Database db, string query, T[] function(ResultRange) resultMapper, Args args) {
|
||||
Statement stmt = db.prepare(query);
|
||||
stmt.bindAll(args);
|
||||
return resultMapper(stmt.execute());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if at least one record exists.
|
||||
* Params:
|
||||
|
|
@ -174,3 +191,62 @@ SysTime parseISOTimestamp(Row row, size_t idx) {
|
|||
immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
||||
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
|
||||
}
|
||||
|
||||
struct QueryBuilder {
|
||||
string fromTable;
|
||||
string[] selections;
|
||||
string[] joins;
|
||||
string[] conditions;
|
||||
void delegate(ref Statement, ref int)[] argBinders;
|
||||
|
||||
this(string fromTable) {
|
||||
this.fromTable = fromTable;
|
||||
}
|
||||
|
||||
ref select(string expr) {
|
||||
selections ~= expr;
|
||||
return this;
|
||||
}
|
||||
|
||||
ref join(string expr) {
|
||||
joins ~= expr;
|
||||
return this;
|
||||
}
|
||||
|
||||
ref where(string expr) {
|
||||
conditions ~= expr;
|
||||
return this;
|
||||
}
|
||||
|
||||
ref withArgBinding(void delegate(ref Statement, ref int) dg) {
|
||||
argBinders ~= dg;
|
||||
return this;
|
||||
}
|
||||
|
||||
string build() const {
|
||||
import std.algorithm : map;
|
||||
import std.string : join;
|
||||
import std.array : appender;
|
||||
auto app = appender!string;
|
||||
app ~= "SELECT\n";
|
||||
if (selections.length > 0) {
|
||||
app ~= selections.map!(s => " " ~ s).join(",\n");
|
||||
} else {
|
||||
app ~= " *";
|
||||
}
|
||||
app ~= "\nFROM " ~ fromTable ~ "\n";
|
||||
app ~= joins.join("\n");
|
||||
if (conditions.length > 0) {
|
||||
app ~= "\nWHERE\n";
|
||||
app ~= conditions.map!(s => " " ~ s).join(" AND\n");
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
void applyArgBindings(ref Statement stmt) const {
|
||||
int idx = 1;
|
||||
foreach (binding; argBinders) {
|
||||
binding(stmt, idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
SELECT
|
||||
txn.id AS id,
|
||||
txn.timestamp AS timestamp,
|
||||
txn.added_at AS added_at,
|
||||
txn.amount AS amount,
|
||||
txn.currency AS currency,
|
||||
txn.description AS description,
|
||||
|
||||
txn.vendor_id AS vendor_id,
|
||||
vendor.name AS vendor_name,
|
||||
|
||||
txn.category_id AS category_id,
|
||||
category.name AS category_name,
|
||||
category.color AS category_color,
|
||||
|
||||
account_credit.id AS credited_account_id,
|
||||
account_credit.name AS credited_account_name,
|
||||
account_credit.type AS credited_account_type,
|
||||
account_credit.number_suffix AS credited_account_number_suffix,
|
||||
|
||||
account_debit.id AS debited_account_id,
|
||||
account_debit.name AS debited_account_name,
|
||||
account_debit.type AS debited_account_type,
|
||||
account_debit.number_suffix AS debited_account_number_suffix,
|
||||
|
||||
GROUP_CONCAT(tag) AS tags
|
||||
FROM
|
||||
"transaction" txn
|
||||
LEFT JOIN transaction_vendor vendor
|
||||
ON vendor.id = txn.vendor_id
|
||||
LEFT JOIN transaction_category category
|
||||
ON category.id = txn.category_id
|
||||
LEFT JOIN account_journal_entry j_credit
|
||||
ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'
|
||||
LEFT JOIN account account_credit
|
||||
ON account_credit.id = j_credit.account_id
|
||||
LEFT JOIN account_journal_entry j_debit
|
||||
ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'
|
||||
LEFT JOIN account account_debit
|
||||
ON account_debit.id = j_debit.account_id
|
||||
LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id
|
||||
GROUP BY txn.id
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-select-component": "^0.12.1"
|
||||
"vue3-select-component": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-select-component": "^0.12.1"
|
||||
"vue3-select-component": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
|
|
|
|||
|
|
@ -29,3 +29,14 @@ export interface Page<T> {
|
|||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
export function defaultPage<T>(): Page<T> {
|
||||
return {
|
||||
items: [],
|
||||
pageRequest: { page: 1, size: 5, sorts: [] },
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
isFirst: true,
|
||||
isLast: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface TransactionsListItem {
|
|||
category: TransactionsListItemCategory | null
|
||||
creditedAccount: TransactionsListItemAccount | null
|
||||
debitedAccount: TransactionsListItemAccount | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface TransactionsListItemVendor {
|
||||
|
|
@ -201,6 +202,20 @@ export class TransactionApiClient extends ApiClient {
|
|||
return super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||
}
|
||||
|
||||
searchTransactions(
|
||||
params: URLSearchParams,
|
||||
paginationOptions: PageRequest | undefined = undefined,
|
||||
): Promise<Page<TransactionsListItem>> {
|
||||
if (paginationOptions !== undefined) {
|
||||
params.append('page', paginationOptions.page + '')
|
||||
params.append('size', paginationOptions.size + '')
|
||||
for (const sort of paginationOptions.sorts) {
|
||||
params.append('sort', sort.attribute + ',' + sort.dir)
|
||||
}
|
||||
}
|
||||
return super.getJson(this.path + '/transactions/search?' + params.toString())
|
||||
}
|
||||
|
||||
getTransaction(id: number): Promise<TransactionDetail> {
|
||||
return super.getJson(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||
import CategoryLabel from './CategoryLabel.vue'
|
||||
import { computed, type Ref } from 'vue'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
import TagLabel from './TagLabel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -29,10 +30,7 @@ function goToTransaction() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="transaction-card"
|
||||
@click="goToTransaction()"
|
||||
>
|
||||
<div class="transaction-card" @click="goToTransaction()">
|
||||
<!-- Top row contains timestamp and amount. -->
|
||||
<div class="transaction-card-top-row">
|
||||
<div>
|
||||
|
|
@ -42,25 +40,16 @@ function goToTransaction() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-mono align-right font-size-small"
|
||||
:class="{
|
||||
'text-positive': moneyStyle === 'positive',
|
||||
'text-negative': moneyStyle === 'negative',
|
||||
}"
|
||||
>
|
||||
<div class="font-mono align-right font-size-small" :class="{
|
||||
'text-positive': moneyStyle === 'positive',
|
||||
'text-negative': moneyStyle === 'negative',
|
||||
}">
|
||||
{{ formatMoney(tx.amount, tx.currency) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="tx.creditedAccount !== null"
|
||||
class="font-size-small text-muted"
|
||||
>
|
||||
<div v-if="tx.creditedAccount !== null" class="font-size-small text-muted">
|
||||
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="tx.debitedAccount !== null"
|
||||
class="font-size-small text-muted"
|
||||
>
|
||||
<div v-if="tx.debitedAccount !== null" class="font-size-small text-muted">
|
||||
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -72,13 +61,14 @@ function goToTransaction() {
|
|||
</div>
|
||||
|
||||
<!-- Bottom row contains other links. -->
|
||||
<div>
|
||||
<CategoryLabel
|
||||
:category="tx.category"
|
||||
v-if="tx.category"
|
||||
style="margin-left: 0"
|
||||
/>
|
||||
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<div>
|
||||
<CategoryLabel :category="tx.category" v-if="tx.category" style="margin-left: 0" />
|
||||
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
|
||||
</div>
|
||||
<div>
|
||||
<TagLabel v-for="tag in tx.tags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -28,37 +28,23 @@ function incrementPage(step: number) {
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="page && page.totalElements > 0">
|
||||
<AppButton
|
||||
size="sm"
|
||||
:disabled="!page || page.isFirst"
|
||||
@click="updatePage(1)"
|
||||
>
|
||||
<AppButton size="sm" :disabled="!page || page.isFirst" @click="updatePage(1)">
|
||||
First Page
|
||||
</AppButton>
|
||||
|
||||
<AppButton
|
||||
size="sm"
|
||||
:disabled="!page || page.isFirst"
|
||||
@click="incrementPage(-1)"
|
||||
>
|
||||
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)">
|
||||
Previous Page
|
||||
</AppButton>
|
||||
|
||||
<span>Page {{ page?.pageRequest.page }} / {{ page?.totalPages }}</span>
|
||||
<span style="min-width: 100px; text-align: center; display: inline-block;" class="font-size-xsmall">
|
||||
Page <span class="font-bold">{{ page?.pageRequest.page }}</span> of {{ page?.totalPages }}
|
||||
</span>
|
||||
|
||||
<AppButton
|
||||
size="sm"
|
||||
:disabled="!page || page.isLast"
|
||||
@click="incrementPage(1)"
|
||||
>
|
||||
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)">
|
||||
Next Page
|
||||
</AppButton>
|
||||
|
||||
<AppButton
|
||||
size="sm"
|
||||
:disabled="!page || page.isLast"
|
||||
@click="updatePage(page?.totalPages ?? 0)"
|
||||
>
|
||||
<AppButton size="sm" :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)">
|
||||
Last Page
|
||||
</AppButton>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
<script setup lang="ts">
|
||||
import { type Account, AccountApiClient } from '@/api/account';
|
||||
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionCategory, type TransactionVendor, type TransactionsListItem } from '@/api/transaction';
|
||||
import AppBadge from '@/components/common/AppBadge.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import PaginationControls from '@/components/common/PaginationControls.vue';
|
||||
import TransactionCard from '@/components/TransactionCard.vue';
|
||||
import { computed, onMounted, ref, watch, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import VueSelect from 'vue3-select-component';
|
||||
|
||||
interface SortOption {
|
||||
label: string
|
||||
property: string
|
||||
}
|
||||
const SORT_PROPERTIES: SortOption[] = [
|
||||
{ label: "Timestamp", property: "txn.timestamp" },
|
||||
{ label: "Added at", property: "txn.added_at" },
|
||||
{ label: "Amount", property: "txn.amount" }
|
||||
]
|
||||
|
||||
const FETCH_DEBOUNCE_DELAY = 300
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const page: Ref<Page<TransactionsListItem>> = ref(defaultPage())
|
||||
const lastFetchTime = ref(0)
|
||||
const fetchTimeoutId = ref<number | undefined>(undefined)
|
||||
|
||||
// Sorting options:
|
||||
const selectedSort: Ref<string> = ref(SORT_PROPERTIES[0].property)
|
||||
const selectedSortDir: Ref<SortDir> = ref('DESC')
|
||||
|
||||
// Filtering options:
|
||||
const searchQuery = ref('')
|
||||
|
||||
const tagFilters: Ref<string[]> = ref([])
|
||||
const availableTags: Ref<string[]> = ref([])
|
||||
const tagOptions = computed(() => {
|
||||
return availableTags.value.map(tag => {
|
||||
return { label: tag, value: tag }
|
||||
})
|
||||
})
|
||||
|
||||
const vendorFilters = ref<number[]>([])
|
||||
const availableVendors = ref<TransactionVendor[]>([])
|
||||
const vendorOptions = computed(() => {
|
||||
return availableVendors.value.map(vendor => {
|
||||
return { label: vendor.name, value: vendor.id }
|
||||
})
|
||||
})
|
||||
|
||||
const categoryFilters = ref<number[]>([])
|
||||
const availableCategories = ref<TransactionCategory[]>([])
|
||||
const categoryOptions = computed(() => availableCategories.value.map(category => {
|
||||
return { label: category.name, value: category.id }
|
||||
}))
|
||||
|
||||
const accountFilters = ref<number[]>([])
|
||||
const availableAccounts = ref<Account[]>([])
|
||||
const accountOptions = computed(() => availableAccounts.value.map(acc => {
|
||||
return { label: `${acc.name} - #${acc.numberSuffix}`, value: acc.id }
|
||||
}))
|
||||
|
||||
const minAmountFilter = ref<number | undefined>(undefined)
|
||||
const maxAmountFilter = ref<number | undefined>(undefined)
|
||||
|
||||
onMounted(async () => {
|
||||
loadFiltersFromRoute()
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
api.getAllTags().then(tags => availableTags.value = tags)
|
||||
api.getVendors().then(vendors => availableVendors.value = vendors)
|
||||
api.getCategoriesFlattened().then(categories => availableCategories.value = categories)
|
||||
const accountApi = new AccountApiClient(route)
|
||||
accountApi.getAccounts().then(accounts => availableAccounts.value = accounts)
|
||||
await fetchPage(1, 10)
|
||||
|
||||
watch(
|
||||
[
|
||||
selectedSort,
|
||||
selectedSortDir,
|
||||
searchQuery,
|
||||
tagFilters,
|
||||
vendorFilters,
|
||||
categoryFilters,
|
||||
accountFilters,
|
||||
minAmountFilter,
|
||||
maxAmountFilter
|
||||
],
|
||||
() => {
|
||||
window.clearTimeout(fetchTimeoutId.value)
|
||||
fetchTimeoutId.value = window.setTimeout(() => {
|
||||
fetchPage(1, 10)
|
||||
}, FETCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetches a page of results based on the currently-configured filters, and
|
||||
* once a successful result is obtained, the current route is updated to
|
||||
* include the latest filters.
|
||||
* @param pg The page number to fetch.
|
||||
* @param size The number of items in the page.
|
||||
*/
|
||||
async function fetchPage(pg: number, size: number) {
|
||||
const pageRequest: PageRequest = {
|
||||
page: pg,
|
||||
size,
|
||||
sorts: [
|
||||
{
|
||||
attribute: selectedSort.value,
|
||||
dir: selectedSortDir.value
|
||||
}
|
||||
]
|
||||
}
|
||||
const params = buildFiltersQuery()
|
||||
const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString()
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
try {
|
||||
const startTime = performance.now()
|
||||
page.value = await api.searchTransactions(params, pageRequest)
|
||||
const endTime = performance.now()
|
||||
lastFetchTime.value = endTime - startTime
|
||||
await router.replace(urlWithParams)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
page.value = defaultPage()
|
||||
}
|
||||
}
|
||||
|
||||
function buildFiltersQuery(): URLSearchParams {
|
||||
const p = new URLSearchParams()
|
||||
if (searchQuery.value.trim().length > 0) {
|
||||
p.append("q", searchQuery.value.trim())
|
||||
}
|
||||
for (const tag of tagFilters.value) {
|
||||
p.append("tag", tag)
|
||||
}
|
||||
for (const vendorId of vendorFilters.value) {
|
||||
p.append("vendor", vendorId + "")
|
||||
}
|
||||
for (const categoryId of categoryFilters.value) {
|
||||
p.append("category", categoryId + "")
|
||||
}
|
||||
for (const accountId of accountFilters.value) {
|
||||
p.append("account", accountId + "")
|
||||
}
|
||||
if (minAmountFilter.value !== undefined && minAmountFilter.value > 0) {
|
||||
p.append("min-amount", minAmountFilter.value * 100 + "")
|
||||
}
|
||||
if (maxAmountFilter.value !== undefined && maxAmountFilter.value > 0) {
|
||||
p.append("max-amount", maxAmountFilter.value * 100 + "")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
tagFilters.value = []
|
||||
vendorFilters.value = []
|
||||
categoryFilters.value = []
|
||||
accountFilters.value = []
|
||||
minAmountFilter.value = undefined
|
||||
maxAmountFilter.value = undefined
|
||||
}
|
||||
|
||||
function goToHome() {
|
||||
router.push(`/profiles/${getSelectedProfile(route)}`)
|
||||
}
|
||||
|
||||
function loadFiltersFromRoute() {
|
||||
searchQuery.value = loadFirstParamValue("q") ?? ''
|
||||
tagFilters.value = loadAllParamValues("tag")
|
||||
vendorFilters.value = loadAllParamValues("vendor").map(s => parseInt(s))
|
||||
categoryFilters.value = loadAllParamValues("category").map(s => parseInt(s))
|
||||
accountFilters.value = loadAllParamValues("account").map(s => parseInt(s))
|
||||
const minAmount = loadFirstParamValue("min-amount")
|
||||
if (minAmount !== undefined) {
|
||||
minAmountFilter.value = Math.round(parseInt(minAmount) / 100)
|
||||
}
|
||||
const maxAmount = loadFirstParamValue("max-amount")
|
||||
if (maxAmount !== undefined) {
|
||||
maxAmountFilter.value = Math.round(parseInt(maxAmount) / 100)
|
||||
}
|
||||
}
|
||||
|
||||
function loadFirstParamValue(key: string): string | undefined {
|
||||
if (key in route.query) {
|
||||
if (Array.isArray(route.query[key]) && route.query[key][0] !== null) {
|
||||
return route.query[key][0]
|
||||
} else if (typeof route.query[key] === 'string') {
|
||||
return route.query[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadAllParamValues(key: string): string[] {
|
||||
if (key in route.query) {
|
||||
if (Array.isArray(route.query[key])) {
|
||||
return route.query[key].filter(s => s !== null)
|
||||
} else if (route.query[key] !== null) {
|
||||
return [route.query[key]]
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage title="Transactions">
|
||||
<AppForm>
|
||||
<FormGroup>
|
||||
<FormControl label="Search" hint="Free-form text search against description, tags, vendor, category, account.">
|
||||
<input v-model="searchQuery" type="text" placeholder="Search for transactions..." />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<div class="vueselect-control">
|
||||
<h5>Tag</h5>
|
||||
<VueSelect v-model="tagFilters" :options="tagOptions" placeholder="Select tags" is-multi />
|
||||
</div>
|
||||
<div class="vueselect-control">
|
||||
<h5>Vendor</h5>
|
||||
<VueSelect v-model="vendorFilters" :options="vendorOptions" placeholder="Select vendors" is-multi />
|
||||
</div>
|
||||
<div class="vueselect-control">
|
||||
<h5>Category</h5>
|
||||
<VueSelect v-model="categoryFilters" :options="categoryOptions" placeholder="Select categories" is-multi />
|
||||
</div>
|
||||
<div class="vueselect-control">
|
||||
<h5>Account</h5>
|
||||
<VueSelect v-model="accountFilters" :options="accountOptions" placeholder="Select accounts" is-multi />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormControl label="Max Amount">
|
||||
<input v-model="maxAmountFilter" type="number" min="0" step="1" />
|
||||
</FormControl>
|
||||
<FormControl label="Min Amount">
|
||||
<input v-model="minAmountFilter" type="number" min="0" step="1" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormControl label="Sort By">
|
||||
<select v-model="selectedSort">
|
||||
<option v-for="sortOpt in SORT_PROPERTIES" :key="sortOpt.property" :value="sortOpt.property">{{
|
||||
sortOpt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Sort Direction">
|
||||
<select v-model="selectedSortDir">
|
||||
<option value="DESC">Descending</option>
|
||||
<option value="ASC">Ascending</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<ButtonBar>
|
||||
<AppButton size="sm" icon="home" @click="goToHome()">Back to Homepage</AppButton>
|
||||
<AppButton size="sm" icon="trash" @click="clearFilters()">Clear Filters</AppButton>
|
||||
</ButtonBar>
|
||||
</AppForm>
|
||||
|
||||
<PaginationControls :page="page" @update="(pr) => fetchPage(pr.page, pr.size)" class="align-right" />
|
||||
<AppBadge size="sm">
|
||||
{{ page.totalElements }} search
|
||||
{{ page.totalElements == 1 ? 'result' : 'results' }}
|
||||
in {{ lastFetchTime }} milliseconds
|
||||
</AppBadge>
|
||||
<TransactionCard v-for="txn in page.items" :key="txn.id" :tx="txn" />
|
||||
</AppPage>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
.vueselect-control {
|
||||
flex-grow: 1;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.vueselect-control>h5 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -61,31 +61,22 @@ async function checkAuth() {
|
|||
<div>
|
||||
<header class="app-header-bar">
|
||||
<div>
|
||||
<h1
|
||||
class="app-header-text"
|
||||
@click="onHeaderClicked()"
|
||||
>
|
||||
<h1 class="app-header-text" @click="onHeaderClicked()">
|
||||
Finnow
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="app-user-widget"
|
||||
@click="router.push('/me')"
|
||||
>
|
||||
<span class="app-user-widget" @click="router.push('/me')">
|
||||
<font-awesome-icon icon="fa-user"></font-awesome-icon>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="app-logout-button"
|
||||
@click="authStore.onUserLoggedOut()"
|
||||
>
|
||||
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
|
||||
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView :key="$route.fullPath"></RouterView>
|
||||
<RouterView></RouterView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -32,29 +32,22 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
function goToSearch() {
|
||||
router.push(`/profiles/${getSelectedProfile(route)}/transactions/search`)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<HomeModule title="Transactions">
|
||||
<template v-slot:default>
|
||||
<TransactionCard
|
||||
v-for="tx in transactions.items"
|
||||
:key="tx.id"
|
||||
:tx="tx"
|
||||
/>
|
||||
|
||||
<PaginationControls
|
||||
:page="transactions"
|
||||
@update="(pr) => fetchPage(pr)"
|
||||
></PaginationControls>
|
||||
<PaginationControls :page="transactions" @update="(pr) => fetchPage(pr)" class="align-right" />
|
||||
<TransactionCard v-for="tx in transactions.items" :key="tx.id" :tx="tx" />
|
||||
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
|
||||
</template>
|
||||
<template v-slot:actions>
|
||||
<AppButton
|
||||
icon="plus"
|
||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
|
||||
>
|
||||
Add Transaction</AppButton
|
||||
>
|
||||
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">
|
||||
Add Transaction</AppButton>
|
||||
<AppButton @click="goToSearch()">Search</AppButton>
|
||||
</template>
|
||||
</HomeModule>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ const router = createRouter({
|
|||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
meta: { title: 'Edit Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'transactions/search',
|
||||
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||
meta: { title: 'Search Transactions' },
|
||||
},
|
||||
{
|
||||
path: 'add-transaction',
|
||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue