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'
|
- '.gitea/workflows/api.yaml'
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-ons: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dlang-community/setup-dlang@v2
|
- uses: dlang-community/setup-dlang@v2
|
||||||
with:
|
with:
|
||||||
compiler: ldc-latest
|
compiler: ldc-latest\
|
||||||
|
- name: Test
|
||||||
|
run: dub test
|
||||||
|
working-directory: ./finnow-api
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dub build --build=release
|
run: dub build --build=release
|
||||||
working-directory: ./finnow-api
|
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) {
|
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
History getHistory(ulong id) {
|
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,13 +108,13 @@ version(unittest) {
|
||||||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestAccountValueRecordRepositoryStub : AccountValueRecordRepository {
|
class TestAccountValueRecordRepositoryStub : AccountValueRecordRepository {
|
||||||
Optional!AccountValueRecord findById(ulong id) {
|
Optional!AccountValueRecord findById(ulong accountId, ulong id) {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
AccountValueRecord insert(
|
AccountValueRecord insert(
|
||||||
|
|
@ -135,5 +135,14 @@ version(unittest) {
|
||||||
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
||||||
throw new Exception("Not implemented");
|
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;
|
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)
|
auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl)
|
||||||
.array;
|
.array;
|
||||||
matches.sort!((a, b) => a.timestamp < b.timestamp);
|
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);
|
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory);
|
||||||
// Transaction endpoints:
|
// Transaction endpoints:
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions);
|
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.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleGetTransaction);
|
||||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction);
|
a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction);
|
||||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction);
|
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction);
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,14 @@ version(unittest) {
|
||||||
class TestProfileDataSourceStub : ProfileDataSource {
|
class TestProfileDataSourceStub : ProfileDataSource {
|
||||||
import account.data;
|
import account.data;
|
||||||
import transaction.data;
|
import transaction.data;
|
||||||
|
import attachment.data;
|
||||||
|
|
||||||
PropertiesRepository getPropertiesRepository() {
|
PropertiesRepository getPropertiesRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
AttachmentRepository getAttachmentRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
AccountRepository getAccountRepository() {
|
AccountRepository getAccountRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import util.data;
|
||||||
|
|
||||||
// Transactions API
|
// 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) {
|
void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
|
@ -28,6 +28,13 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
writeJsonBody(response, responsePage);
|
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) {
|
void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
|
TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
module transaction.data;
|
module transaction.data;
|
||||||
|
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
|
||||||
import transaction.model;
|
import transaction.model;
|
||||||
|
|
@ -36,7 +36,8 @@ interface TransactionTagRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TransactionRepository {
|
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);
|
Optional!TransactionDetail findById(ulong id);
|
||||||
TransactionDetail insert(in AddTransactionPayload data);
|
TransactionDetail insert(in AddTransactionPayload data);
|
||||||
void linkAttachment(ulong transactionId, ulong attachmentId);
|
void linkAttachment(ulong transactionId, ulong attachmentId);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
module transaction.data_impl_sqlite;
|
module transaction.data_impl_sqlite;
|
||||||
|
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional, ServerHttpRequest;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
import d2sqlite3;
|
import d2sqlite3;
|
||||||
|
|
@ -198,69 +198,60 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
Page!TransactionsListItem findAll(PageRequest pr) {
|
Page!TransactionsListItem findAll(in PageRequest pr) {
|
||||||
const BASE_QUERY = import("sql/get_transactions.sql");
|
const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql();
|
||||||
// TODO: Implement filtering or something!
|
QueryBuilder qb = getBuilderForTransactionsList();
|
||||||
import std.array;
|
addSelectsForTransactionsList(qb);
|
||||||
const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME;
|
qb.where("txn.id IN (" ~ pageIdsQuery ~ ")");
|
||||||
auto sqlBuilder = appender!string;
|
string query = qb.build() ~ "\n" ~ pr.toOrderClause();
|
||||||
sqlBuilder ~= BASE_QUERY;
|
TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems);
|
||||||
sqlBuilder ~= " ";
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\"");
|
||||||
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);
|
|
||||||
return Page!(TransactionsListItem).of(results, pr, totalCount);
|
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 findById(ulong id) {
|
||||||
Optional!TransactionDetail item = util.sqlite.findOne(
|
Optional!TransactionDetail item = util.sqlite.findOne(
|
||||||
db,
|
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) {
|
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||||
foreach (size_t idx, lineItem; data.lineItems) {
|
foreach (size_t idx, lineItem; data.lineItems) {
|
||||||
util.sqlite.update(
|
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
|
* https://en.wikipedia.org/wiki/ISO_4217
|
||||||
*/
|
*/
|
||||||
struct Currency {
|
struct Currency {
|
||||||
|
// The common name of the currency.
|
||||||
|
string name;
|
||||||
/// The common 3-character code for the currency, like "USD".
|
/// The common 3-character code for the currency, like "USD".
|
||||||
char[3] code;
|
char[3] code;
|
||||||
/// The number of digits after the decimal place that the currency supports.
|
/// 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 {
|
* An enumeration defining all available currencies. This is generated at
|
||||||
AUD = Currency("AUD", 2, 36, "$"),
|
* compile time by reading currency data from CSV files and generating a list
|
||||||
USD = Currency("USD", 2, 840, "$"),
|
* of currency declarations.
|
||||||
CAD = Currency("CAD", 2, 124, "$"),
|
*/
|
||||||
GBP = Currency("GBP", 2, 826, "£"),
|
mixin("enum Currencies : Currency {\n" ~ getCurrenciesEnumMembers() ~ "\n}");
|
||||||
EUR = Currency("EUR", 2, 978, "€"),
|
|
||||||
CHF = Currency("CHF", 2, 756, "Fr"),
|
/**
|
||||||
ZAR = Currency("ZAR", 2, 710, "R"),
|
* A list of all currencies, as a convenience for getting all members of the
|
||||||
JPY = Currency("JPY", 0, 392, "¥"),
|
* `Currencies` enum.
|
||||||
INR = Currency("INR", 2, 356, "₹")
|
*/
|
||||||
|
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 {
|
unittest {
|
||||||
assert(Currency.ofCode("USD") == Currencies.USD);
|
assert(Currency.ofCode("USD") == Currencies.USD);
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,19 @@ struct PageRequest {
|
||||||
string toSql() const {
|
string toSql() const {
|
||||||
import std.array;
|
import std.array;
|
||||||
auto app = appender!string;
|
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) {
|
if (sorts.length > 0) {
|
||||||
app ~= "ORDER BY ";
|
app ~= "ORDER BY ";
|
||||||
for (size_t i = 0; i < sorts.length; i++) {
|
for (size_t i = 0; i < sorts.length; i++) {
|
||||||
|
|
@ -91,12 +103,6 @@ struct PageRequest {
|
||||||
}
|
}
|
||||||
app ~= " ";
|
app ~= " ";
|
||||||
}
|
}
|
||||||
if (!isUnpaged()) {
|
|
||||||
app ~= "LIMIT ";
|
|
||||||
app ~= size.to!string;
|
|
||||||
app ~= " OFFSET ";
|
|
||||||
app ~= ((page - 1) * size).to!string;
|
|
||||||
}
|
|
||||||
return app[];
|
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;
|
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.
|
* Determines if at least one record exists.
|
||||||
* Params:
|
* Params:
|
||||||
|
|
@ -174,3 +191,62 @@ SysTime parseISOTimestamp(Row row, size_t idx) {
|
||||||
immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
immutable(ubyte[]) parseBlob(Row row, size_t idx) {
|
||||||
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
|
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",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue3-select-component": "^0.12.1"
|
"vue3-select-component": "^0.12.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue3-select-component": "^0.12.1"
|
"vue3-select-component": "^0.12.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,14 @@ export interface Page<T> {
|
||||||
isFirst: boolean
|
isFirst: boolean
|
||||||
isLast: 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
|
category: TransactionsListItemCategory | null
|
||||||
creditedAccount: TransactionsListItemAccount | null
|
creditedAccount: TransactionsListItemAccount | null
|
||||||
debitedAccount: TransactionsListItemAccount | null
|
debitedAccount: TransactionsListItemAccount | null
|
||||||
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionsListItemVendor {
|
export interface TransactionsListItemVendor {
|
||||||
|
|
@ -201,6 +202,20 @@ export class TransactionApiClient extends ApiClient {
|
||||||
return super.getJsonPage(this.path + '/transactions', paginationOptions)
|
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> {
|
getTransaction(id: number): Promise<TransactionDetail> {
|
||||||
return super.getJson(this.path + '/transactions/' + id)
|
return super.getJson(this.path + '/transactions/' + id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||||
import CategoryLabel from './CategoryLabel.vue'
|
import CategoryLabel from './CategoryLabel.vue'
|
||||||
import { computed, type Ref } from 'vue'
|
import { computed, type Ref } from 'vue'
|
||||||
import AppBadge from './common/AppBadge.vue'
|
import AppBadge from './common/AppBadge.vue'
|
||||||
|
import TagLabel from './TagLabel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -29,10 +30,7 @@ function goToTransaction() {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="transaction-card" @click="goToTransaction()">
|
||||||
class="transaction-card"
|
|
||||||
@click="goToTransaction()"
|
|
||||||
>
|
|
||||||
<!-- Top row contains timestamp and amount. -->
|
<!-- Top row contains timestamp and amount. -->
|
||||||
<div class="transaction-card-top-row">
|
<div class="transaction-card-top-row">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -42,25 +40,16 @@ function goToTransaction() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div class="font-mono align-right font-size-small" :class="{
|
||||||
class="font-mono align-right font-size-small"
|
|
||||||
:class="{
|
|
||||||
'text-positive': moneyStyle === 'positive',
|
'text-positive': moneyStyle === 'positive',
|
||||||
'text-negative': moneyStyle === 'negative',
|
'text-negative': moneyStyle === 'negative',
|
||||||
}"
|
}">
|
||||||
>
|
|
||||||
{{ formatMoney(tx.amount, tx.currency) }}
|
{{ formatMoney(tx.amount, tx.currency) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="tx.creditedAccount !== null" class="font-size-small text-muted">
|
||||||
v-if="tx.creditedAccount !== null"
|
|
||||||
class="font-size-small text-muted"
|
|
||||||
>
|
|
||||||
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
|
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="tx.debitedAccount !== null" class="font-size-small text-muted">
|
||||||
v-if="tx.debitedAccount !== null"
|
|
||||||
class="font-size-small text-muted"
|
|
||||||
>
|
|
||||||
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
|
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,14 +61,15 @@ function goToTransaction() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row contains other links. -->
|
<!-- Bottom row contains other links. -->
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<CategoryLabel
|
<CategoryLabel :category="tx.category" v-if="tx.category" style="margin-left: 0" />
|
||||||
:category="tx.category"
|
|
||||||
v-if="tx.category"
|
|
||||||
style="margin-left: 0"
|
|
||||||
/>
|
|
||||||
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
|
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<TagLabel v-for="tag in tx.tags" :key="tag" :tag="tag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|
|
||||||
|
|
@ -28,37 +28,23 @@ function incrementPage(step: number) {
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="page && page.totalElements > 0">
|
<div v-if="page && page.totalElements > 0">
|
||||||
<AppButton
|
<AppButton size="sm" :disabled="!page || page.isFirst" @click="updatePage(1)">
|
||||||
size="sm"
|
|
||||||
:disabled="!page || page.isFirst"
|
|
||||||
@click="updatePage(1)"
|
|
||||||
>
|
|
||||||
First Page
|
First Page
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|
||||||
<AppButton
|
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)">
|
||||||
size="sm"
|
|
||||||
:disabled="!page || page.isFirst"
|
|
||||||
@click="incrementPage(-1)"
|
|
||||||
>
|
|
||||||
Previous Page
|
Previous Page
|
||||||
</AppButton>
|
</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
|
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)">
|
||||||
size="sm"
|
|
||||||
:disabled="!page || page.isLast"
|
|
||||||
@click="incrementPage(1)"
|
|
||||||
>
|
|
||||||
Next Page
|
Next Page
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|
||||||
<AppButton
|
<AppButton size="sm" :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)">
|
||||||
size="sm"
|
|
||||||
:disabled="!page || page.isLast"
|
|
||||||
@click="updatePage(page?.totalPages ?? 0)"
|
|
||||||
>
|
|
||||||
Last Page
|
Last Page
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<header class="app-header-bar">
|
<header class="app-header-bar">
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1 class="app-header-text" @click="onHeaderClicked()">
|
||||||
class="app-header-text"
|
|
||||||
@click="onHeaderClicked()"
|
|
||||||
>
|
|
||||||
Finnow
|
Finnow
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span class="app-user-widget" @click="router.push('/me')">
|
||||||
class="app-user-widget"
|
|
||||||
@click="router.push('/me')"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="fa-user"></font-awesome-icon>
|
<font-awesome-icon icon="fa-user"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
|
||||||
class="app-logout-button"
|
|
||||||
@click="authStore.onUserLoggedOut()"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<RouterView :key="$route.fullPath"></RouterView>
|
<RouterView></RouterView>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,22 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToSearch() {
|
||||||
|
router.push(`/profiles/${getSelectedProfile(route)}/transactions/search`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<HomeModule title="Transactions">
|
<HomeModule title="Transactions">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<TransactionCard
|
<PaginationControls :page="transactions" @update="(pr) => fetchPage(pr)" class="align-right" />
|
||||||
v-for="tx in transactions.items"
|
<TransactionCard v-for="tx in transactions.items" :key="tx.id" :tx="tx" />
|
||||||
:key="tx.id"
|
|
||||||
:tx="tx"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PaginationControls
|
|
||||||
:page="transactions"
|
|
||||||
@update="(pr) => fetchPage(pr)"
|
|
||||||
></PaginationControls>
|
|
||||||
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
|
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:actions>
|
<template v-slot:actions>
|
||||||
<AppButton
|
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">
|
||||||
icon="plus"
|
Add Transaction</AppButton>
|
||||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
|
<AppButton @click="goToSearch()">Search</AppButton>
|
||||||
>
|
|
||||||
Add Transaction</AppButton
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
</HomeModule>
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
meta: { title: 'Edit Transaction' },
|
meta: { title: 'Edit Transaction' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'transactions/search',
|
||||||
|
component: () => import('@/pages/TransactionSearchPage.vue'),
|
||||||
|
meta: { title: 'Search Transactions' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'add-transaction',
|
path: 'add-transaction',
|
||||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue