Added functioning account editor.
This commit is contained in:
parent
0b67bb605b
commit
e29d4e1c0f
|
@ -1,43 +1,43 @@
|
||||||
Amazon
|
Amazon,Online retailer for pretty much anything.
|
||||||
eBay
|
eBay,Website to buy and bid on stuff from people.
|
||||||
Walmart
|
Walmart,"Big, ugly general store."
|
||||||
Target
|
Target,Slightly less ugly general store.
|
||||||
Best Buy
|
Best Buy,Large tech-focused store.
|
||||||
Costco
|
Costco,Wholesale club grocery store.
|
||||||
Home Depot
|
Home Depot,Hardware store that’s painted orange.
|
||||||
Lowe's
|
Lowe's,Hardware store that’s painted blue.
|
||||||
Kroger
|
Kroger,A giant grocery conglomerate.
|
||||||
CVS
|
CVS,A chain of corner-store pharmacies.
|
||||||
Walgreens
|
Walgreens,Another chain of corner-store pharmacies.
|
||||||
Starbucks
|
Starbucks,Coffee distributor.
|
||||||
McDonald's
|
McDonald's,The most famous fast-food.
|
||||||
Burger King
|
Burger King,Second to McDonald’s for burger fast-food.
|
||||||
Subway
|
Subway,An outdated sandwich shop.
|
||||||
Pizza Hut
|
Pizza Hut,Pizza chain restaurant.
|
||||||
Domino's
|
Domino's,Another pizza chain restaurant.
|
||||||
Chipotle
|
Chipotle,“Mexican” fast food place.
|
||||||
Taco Bell
|
Taco Bell,Another “Mexican” fast food place.
|
||||||
Panera Bread
|
Panera Bread,Some random breakfast restaurant.
|
||||||
Dunkin'
|
Dunkin',Coffee and donuts shop.
|
||||||
Chick-fil-A
|
Chick-fil-A,Chicken shop that contributes to anti-gay politics.
|
||||||
Advance Auto Parts
|
Advance Auto Parts,Car parts store.
|
||||||
AutoZone
|
AutoZone,Another car parts store.
|
||||||
Delta Air Lines
|
Delta Air Lines,The most popular american air carrier.
|
||||||
American Airlines
|
American Airlines,"Another american air carrier, based in Charlotte, North Carolina."
|
||||||
United Airlines
|
United Airlines,"An american air carrier based in Chicago, Illinois."
|
||||||
Squarespace
|
Squarespace,Online website and domain name seller.
|
||||||
DigitalOcean
|
DigitalOcean,Cloud hosting provider.
|
||||||
GitHub
|
GitHub,Source code repository provider owned by Microsoft.
|
||||||
Heroku
|
Heroku,Some other cloud hosting provider.
|
||||||
Stripe
|
Stripe,Payment provider.
|
||||||
PayPal
|
PayPal,Another payment provider.
|
||||||
Verizon
|
Verizon,American phone carrier.
|
||||||
ALDI
|
ALDI,Multinational minimalist grocery store.
|
||||||
IKEA
|
IKEA,Swedish build-it-yourself furniture store.
|
||||||
Primark
|
Primark,Irish budget clothing store.
|
||||||
H&M
|
H&M,Budget european clothing store.
|
||||||
Petco
|
Petco,Pet store.
|
||||||
China King
|
China King,Some random chinese store.
|
||||||
GEICO
|
GEICO,Car insurance.
|
||||||
Bank of America
|
Bank of America,A bank.
|
||||||
Citi
|
Citi,Another bank.
|
||||||
|
|
|
|
@ -32,4 +32,5 @@ interface AccountJournalEntryRepository {
|
||||||
Currency currency
|
Currency currency
|
||||||
);
|
);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,6 +83,7 @@ SQL",
|
||||||
|
|
||||||
void deleteById(ulong id) {
|
void deleteById(ulong id) {
|
||||||
doTransaction(db, () {
|
doTransaction(db, () {
|
||||||
|
// Delete associated history.
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"DELETE FROM history
|
"DELETE FROM history
|
||||||
|
@ -92,6 +93,14 @@ SQL",
|
||||||
)",
|
)",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
// Delete all associated transactions.
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM \"transaction\" WHERE id IN " ~
|
||||||
|
"(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
// Finally delete the account itself (and all cascaded entities, like journal entries).
|
||||||
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
|
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -229,6 +238,14 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
||||||
util.sqlite.deleteById(db, "account_journal_entry", id);
|
util.sqlite.deleteById(db, "account_journal_entry", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
|
||||||
|
accountId, transactionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static AccountJournalEntry parseEntry(Row row) {
|
static AccountJournalEntry parseEntry(Row row) {
|
||||||
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
||||||
AccountJournalEntryType type;
|
AccountJournalEntryType type;
|
||||||
|
|
|
@ -62,12 +62,20 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
|
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor);
|
||||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
|
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor);
|
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor);
|
||||||
|
// Transaction category endpoints:
|
||||||
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories);
|
||||||
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory);
|
||||||
|
a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory);
|
||||||
|
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory);
|
||||||
|
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", &handleGetTransactions);
|
||||||
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);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleDeleteTransaction);
|
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleDeleteTransaction);
|
||||||
|
|
||||||
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags", &handleGetAllTags);
|
||||||
|
|
||||||
import data_api;
|
import data_api;
|
||||||
// Various other data endpoints:
|
// Various other data endpoints:
|
||||||
|
|
|
@ -39,16 +39,32 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||||
addTransaction(ds, payload);
|
TransactionDetail txn = addTransaction(ds, payload);
|
||||||
|
import asdf : serializeToJson;
|
||||||
|
string jsonStr = serializeToJson(txn);
|
||||||
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong txnId = getTransactionIdOrThrow(request);
|
||||||
|
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||||
|
TransactionDetail txn = updateTransaction(ds, txnId, payload);
|
||||||
|
import asdf : serializeToJson;
|
||||||
|
string jsonStr = serializeToJson(txn);
|
||||||
|
response.writeBodyString(jsonStr, "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
// TODO
|
ulong txnId = getTransactionIdOrThrow(request);
|
||||||
|
deleteTransaction(ds, txnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
string[] tags = ds.getTransactionTagRepository().findAll();
|
||||||
|
writeJsonBody(response, tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
|
private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
|
||||||
|
@ -98,3 +114,24 @@ private ulong getVendorId(in ServerHttpRequest request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories API
|
// Categories API
|
||||||
|
|
||||||
|
void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request));
|
||||||
|
writeJsonBody(response, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ interface TransactionVendorRepository {
|
||||||
interface TransactionCategoryRepository {
|
interface TransactionCategoryRepository {
|
||||||
Optional!TransactionCategory findById(ulong id);
|
Optional!TransactionCategory findById(ulong id);
|
||||||
bool existsById(ulong id);
|
bool existsById(ulong id);
|
||||||
|
TransactionCategory[] findAll();
|
||||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
|
@ -37,5 +38,6 @@ interface TransactionRepository {
|
||||||
Page!TransactionsListItem findAll(PageRequest pr);
|
Page!TransactionsListItem findAll(PageRequest pr);
|
||||||
Optional!TransactionDetail findById(ulong id);
|
Optional!TransactionDetail findById(ulong id);
|
||||||
TransactionDetail insert(in AddTransactionPayload data);
|
TransactionDetail insert(in AddTransactionPayload data);
|
||||||
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,6 +85,14 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TransactionCategory[] findAll() {
|
||||||
|
return util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM transaction_category ORDER BY parent_id, name",
|
||||||
|
&parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
return util.sqlite.findAll(
|
return util.sqlite.findAll(
|
||||||
|
@ -346,7 +354,7 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
db,
|
db,
|
||||||
import("sql/insert_transaction.sql"),
|
import("sql/insert_transaction.sql"),
|
||||||
data.timestamp,
|
data.timestamp,
|
||||||
Clock.currTime(UTC()),
|
Clock.currTime(UTC()).toISOExtString(),
|
||||||
data.amount,
|
data.amount,
|
||||||
data.currencyCode,
|
data.currencyCode,
|
||||||
data.description,
|
data.description,
|
||||||
|
@ -354,19 +362,29 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
data.categoryId
|
data.categoryId
|
||||||
);
|
);
|
||||||
ulong transactionId = db.lastInsertRowid();
|
ulong transactionId = db.lastInsertRowid();
|
||||||
// Insert line items:
|
insertLineItems(transactionId, data);
|
||||||
foreach (size_t idx, lineItem; data.lineItems) {
|
return findById(transactionId).orElseThrow();
|
||||||
util.sqlite.update(
|
}
|
||||||
db,
|
|
||||||
import("sql/insert_line_item.sql"),
|
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
|
||||||
transactionId,
|
util.sqlite.update(
|
||||||
idx,
|
db,
|
||||||
lineItem.valuePerItem,
|
import("sql/update_transaction.sql"),
|
||||||
lineItem.quantity,
|
data.timestamp,
|
||||||
lineItem.description,
|
data.amount,
|
||||||
lineItem.categoryId
|
data.currencyCode,
|
||||||
);
|
data.description,
|
||||||
}
|
data.vendorId,
|
||||||
|
data.categoryId,
|
||||||
|
transactionId
|
||||||
|
);
|
||||||
|
// Re-write all line items:
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM transaction_line_item WHERE transaction_id = ?",
|
||||||
|
transactionId
|
||||||
|
);
|
||||||
|
insertLineItems(transactionId, data);
|
||||||
return findById(transactionId).orElseThrow();
|
return findById(transactionId).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -387,4 +405,19 @@ class SqliteTransactionRepository : TransactionRepository {
|
||||||
toOptional(row.peek!(Nullable!ulong)(7))
|
toOptional(row.peek!(Nullable!ulong)(7))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
|
||||||
|
foreach (size_t idx, lineItem; data.lineItems) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
import("sql/insert_line_item.sql"),
|
||||||
|
transactionId,
|
||||||
|
idx,
|
||||||
|
lineItem.valuePerItem,
|
||||||
|
lineItem.quantity,
|
||||||
|
lineItem.description,
|
||||||
|
lineItem.categoryId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,3 +109,15 @@ struct AddTransactionPayload {
|
||||||
Nullable!ulong categoryId;
|
Nullable!ulong categoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structure for depicting an entire hierarchical tree structure of categories.
|
||||||
|
struct TransactionCategoryTree {
|
||||||
|
ulong id;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!ulong parentId;
|
||||||
|
string name;
|
||||||
|
string description;
|
||||||
|
string color;
|
||||||
|
TransactionCategoryTree[] children;
|
||||||
|
uint depth;
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import account.model;
|
||||||
import account.data;
|
import account.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
import core.internal.container.common;
|
||||||
|
|
||||||
// Transactions Services
|
// Transactions Services
|
||||||
|
|
||||||
|
@ -31,7 +32,114 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
AccountRepository accountRepo = ds.getAccountRepository();
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
|
|
||||||
// Validate transaction details:
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
|
||||||
|
// Add the transaction:
|
||||||
|
ulong txnId;
|
||||||
|
ds.doTransaction(() {
|
||||||
|
TransactionRepository txRepo = ds.getTransactionRepository();
|
||||||
|
TransactionDetail txn = txRepo.insert(payload);
|
||||||
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
|
if (!payload.creditedAccountId.isNull) {
|
||||||
|
jeRepo.insert(
|
||||||
|
timestamp,
|
||||||
|
payload.creditedAccountId.get,
|
||||||
|
txn.id,
|
||||||
|
txn.amount,
|
||||||
|
AccountJournalEntryType.CREDIT,
|
||||||
|
txn.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!payload.debitedAccountId.isNull) {
|
||||||
|
jeRepo.insert(
|
||||||
|
timestamp,
|
||||||
|
payload.debitedAccountId.get,
|
||||||
|
txn.id,
|
||||||
|
txn.amount,
|
||||||
|
AccountJournalEntryType.DEBIT,
|
||||||
|
txn.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
|
tagRepo.updateTags(txn.id, payload.tags);
|
||||||
|
txnId = txn.id;
|
||||||
|
});
|
||||||
|
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
|
||||||
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||||
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
|
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
||||||
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||||
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||||
|
|
||||||
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||||
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
|
||||||
|
const TransactionDetail prev = transactionRepo.findById(transactionId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
|
||||||
|
// Update the transaction:
|
||||||
|
ds.doTransaction(() {
|
||||||
|
TransactionDetail curr = transactionRepo.update(transactionId, payload);
|
||||||
|
bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
||||||
|
bool updateCreditEntry = amountOrCurrencyChanged || (
|
||||||
|
prev.creditedAccount != curr.creditedAccount
|
||||||
|
);
|
||||||
|
bool updateDebitEntry = amountOrCurrencyChanged || (
|
||||||
|
prev.debitedAccount != curr.debitedAccount
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update journal entries if necessary:
|
||||||
|
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
||||||
|
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, transactionId);
|
||||||
|
}
|
||||||
|
if (updateCreditEntry && !curr.creditedAccount.isNull) {
|
||||||
|
jeRepo.insert(
|
||||||
|
timestamp,
|
||||||
|
curr.creditedAccount.get.id,
|
||||||
|
transactionId,
|
||||||
|
curr.amount,
|
||||||
|
AccountJournalEntryType.CREDIT,
|
||||||
|
curr.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
||||||
|
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, transactionId);
|
||||||
|
}
|
||||||
|
if (updateDebitEntry && !curr.debitedAccount.isNull) {
|
||||||
|
jeRepo.insert(
|
||||||
|
timestamp,
|
||||||
|
curr.debitedAccount.get.id,
|
||||||
|
transactionId,
|
||||||
|
curr.amount,
|
||||||
|
AccountJournalEntryType.DEBIT,
|
||||||
|
curr.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tags.
|
||||||
|
tagRepo.updateTags(transactionId, payload.tags);
|
||||||
|
});
|
||||||
|
return transactionRepo.findById(transactionId).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||||
|
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||||
|
TransactionDetail txn = txnRepo.findById(transactionId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
txnRepo.deleteById(txn.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTransactionPayload(
|
||||||
|
TransactionVendorRepository vendorRepo,
|
||||||
|
TransactionCategoryRepository categoryRepo,
|
||||||
|
AccountRepository accountRepo,
|
||||||
|
in AddTransactionPayload payload
|
||||||
|
) {
|
||||||
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked.");
|
||||||
}
|
}
|
||||||
|
@ -46,7 +154,12 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0.");
|
||||||
}
|
}
|
||||||
SysTime now = Clock.currTime(UTC());
|
SysTime now = Clock.currTime(UTC());
|
||||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
SysTime timestamp;
|
||||||
|
try {
|
||||||
|
timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||||
|
} catch (TimeException e) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime.");
|
||||||
|
}
|
||||||
if (timestamp > now) {
|
if (timestamp > now) {
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future.");
|
||||||
}
|
}
|
||||||
|
@ -89,38 +202,6 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the transaction:
|
|
||||||
ulong txnId;
|
|
||||||
ds.doTransaction(() {
|
|
||||||
TransactionRepository txRepo = ds.getTransactionRepository();
|
|
||||||
TransactionDetail txn = txRepo.insert(payload);
|
|
||||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
|
||||||
if (!payload.creditedAccountId.isNull) {
|
|
||||||
jeRepo.insert(
|
|
||||||
timestamp,
|
|
||||||
payload.creditedAccountId.get,
|
|
||||||
txn.id,
|
|
||||||
txn.amount,
|
|
||||||
AccountJournalEntryType.CREDIT,
|
|
||||||
txn.currency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!payload.debitedAccountId.isNull) {
|
|
||||||
jeRepo.insert(
|
|
||||||
timestamp,
|
|
||||||
payload.debitedAccountId.get,
|
|
||||||
txn.id,
|
|
||||||
txn.amount,
|
|
||||||
AccountJournalEntryType.DEBIT,
|
|
||||||
txn.currency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
|
||||||
tagRepo.updateTags(txn.id, payload.tags);
|
|
||||||
txnId = txn.id;
|
|
||||||
});
|
|
||||||
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendors Services
|
// Vendors Services
|
||||||
|
@ -158,3 +239,31 @@ TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPa
|
||||||
void deleteVendor(ProfileDataSource ds, ulong vendorId) {
|
void deleteVendor(ProfileDataSource ds, ulong vendorId) {
|
||||||
ds.getTransactionVendorRepository().deleteById(vendorId);
|
ds.getTransactionVendorRepository().deleteById(vendorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Categories Services
|
||||||
|
|
||||||
|
TransactionCategoryTree[] getCategories(ProfileDataSource ds) {
|
||||||
|
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||||
|
return getCategoriesRecursive(repo, Optional!ulong.empty, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransactionCategoryTree[] getCategoriesRecursive(
|
||||||
|
TransactionCategoryRepository repo,
|
||||||
|
Optional!ulong parentId,
|
||||||
|
uint depth
|
||||||
|
) {
|
||||||
|
import util.data : toNullable;
|
||||||
|
TransactionCategoryTree[] nodes;
|
||||||
|
foreach (category; repo.findAllByParentId(parentId)) {
|
||||||
|
nodes ~= TransactionCategoryTree(
|
||||||
|
category.id,
|
||||||
|
parentId,
|
||||||
|
category.name,
|
||||||
|
category.description,
|
||||||
|
category.color,
|
||||||
|
getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1),
|
||||||
|
depth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,9 @@ import std.conv;
|
||||||
import std.array;
|
import std.array;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
import std.file;
|
||||||
|
import std.algorithm;
|
||||||
|
import std.csv;
|
||||||
|
|
||||||
void generateSampleData() {
|
void generateSampleData() {
|
||||||
UserRepository userRepo = new FileSystemUserRepository;
|
UserRepository userRepo = new FileSystemUserRepository;
|
||||||
|
@ -63,21 +66,42 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
||||||
generateRandomAccount(i, ds, preferredCurrency);
|
generateRandomAccount(i, ds, preferredCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ds.doTransaction(() {
|
||||||
|
generateVendors(ds);
|
||||||
|
generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty);
|
||||||
|
});
|
||||||
|
generateRandomTransactions(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
void generateVendors(ProfileDataSource ds) {
|
||||||
auto vendorRepo = ds.getTransactionVendorRepository();
|
auto vendorRepo = ds.getTransactionVendorRepository();
|
||||||
const int vendorCount = uniform(5, 30);
|
string vendorsCsv = readText("sample-data/vendors.csv");
|
||||||
for (int i = 0; i < vendorCount; i++) {
|
uint vendorCount = 0;
|
||||||
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
|
foreach (record; csvReader!(Tuple!(string, string))(vendorsCsv)) {
|
||||||
|
vendorRepo.insert(record[0], record[1]);
|
||||||
|
vendorCount++;
|
||||||
}
|
}
|
||||||
infoF!" Generated %d random vendors."(vendorCount);
|
infoF!" Generated %d random vendors."(vendorCount);
|
||||||
|
}
|
||||||
|
|
||||||
auto categoryRepo = ds.getTransactionCategoryRepository();
|
void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) {
|
||||||
const int categoryCount = uniform(5, 30);
|
const int categoryCount = uniform(5, 10);
|
||||||
for (int i = 0; i < categoryCount; i++) {
|
for (int i = 0; i < categoryCount; i++) {
|
||||||
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
|
string name = "Test Category " ~ to!string(i);
|
||||||
|
if (parentId) {
|
||||||
|
name ~= " (child of " ~ parentId.value.to!string ~ ")";
|
||||||
|
}
|
||||||
|
TransactionCategory category = repo.insert(
|
||||||
|
parentId,
|
||||||
|
name,
|
||||||
|
"Testing category.",
|
||||||
|
"FFFFFF"
|
||||||
|
);
|
||||||
|
infoF!" Generating child categories for %d, depth = %d"(i, depth);
|
||||||
|
if (depth < 2) {
|
||||||
|
generateCategories(repo, Optional!ulong.of(category.id), depth + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
infoF!" Generated %d random categories."(categoryCount);
|
|
||||||
|
|
||||||
generateRandomTransactions(ds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||||
|
|
|
@ -56,12 +56,6 @@ CREATE TABLE transaction_category (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE transaction_tag (
|
|
||||||
transaction_id INTEGER NOT NULL,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "transaction" (
|
CREATE TABLE "transaction" (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
|
@ -80,6 +74,15 @@ CREATE TABLE "transaction" (
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
||||||
|
|
||||||
|
CREATE TABLE transaction_tag (
|
||||||
|
transaction_id INTEGER NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag),
|
||||||
|
CONSTRAINT fk_transaction_tag_transaction
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE transaction_attachment (
|
CREATE TABLE transaction_attachment (
|
||||||
transaction_id INTEGER NOT NULL,
|
transaction_id INTEGER NOT NULL,
|
||||||
attachment_id INTEGER NOT NULL,
|
attachment_id INTEGER NOT NULL,
|
||||||
|
@ -121,7 +124,9 @@ CREATE TABLE account_journal_entry (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_account_journal_entry_transaction
|
CONSTRAINT fk_account_journal_entry_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_account_journal_entry_ids
|
||||||
|
UNIQUE (account_id, transaction_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Value records
|
-- Value records
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
UPDATE "transaction"
|
||||||
|
SET
|
||||||
|
timestamp = ?,
|
||||||
|
amount = ?,
|
||||||
|
currency = ?,
|
||||||
|
description = ?,
|
||||||
|
vendor_id = ?,
|
||||||
|
category_id = ?
|
||||||
|
WHERE id = ?
|
|
@ -22,6 +22,16 @@ export interface TransactionCategory {
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionCategoryTree {
|
||||||
|
id: number
|
||||||
|
parentId: number | null
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
children: TransactionCategoryTree[]
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: number
|
id: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
@ -90,10 +100,30 @@ export interface TransactionDetailLineItem {
|
||||||
idx: number
|
idx: number
|
||||||
valuePerItem: number
|
valuePerItem: number
|
||||||
quantity: number
|
quantity: number
|
||||||
description: number
|
description: string
|
||||||
category: TransactionCategory | null
|
category: TransactionCategory | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddTransactionPayload {
|
||||||
|
timestamp: string
|
||||||
|
amount: number
|
||||||
|
currencyCode: string
|
||||||
|
description: string
|
||||||
|
vendorId: number | null
|
||||||
|
categoryId: number | null
|
||||||
|
creditedAccountId: number | null
|
||||||
|
debitedAccountId: number | null
|
||||||
|
tags: string[]
|
||||||
|
lineItems: AddTransactionPayloadLineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddTransactionPayloadLineItem {
|
||||||
|
valuePerItem: number
|
||||||
|
quantity: number
|
||||||
|
description: string
|
||||||
|
categoryId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
@ -122,6 +152,10 @@ export class TransactionApiClient extends ApiClient {
|
||||||
return await super.delete(this.path + '/vendors/' + id)
|
return await super.delete(this.path + '/vendors/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategories(): Promise<TransactionCategoryTree[]> {
|
||||||
|
return await super.getJson(this.path + '/categories')
|
||||||
|
}
|
||||||
|
|
||||||
async getTransactions(
|
async getTransactions(
|
||||||
paginationOptions: PageRequest | undefined = undefined,
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
): Promise<Page<TransactionsListItem>> {
|
): Promise<Page<TransactionsListItem>> {
|
||||||
|
@ -131,4 +165,20 @@ export class TransactionApiClient extends ApiClient {
|
||||||
async getTransaction(id: number): Promise<TransactionDetail> {
|
async getTransaction(id: number): Promise<TransactionDetail> {
|
||||||
return await super.getJson(this.path + '/transactions/' + id)
|
return await super.getJson(this.path + '/transactions/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||||
|
return await super.postJson(this.path + '/transactions', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||||
|
return await super.putJson(this.path + '/transactions/' + id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTransaction(id: number): Promise<void> {
|
||||||
|
return await super.delete(this.path + '/transactions/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllTags(): Promise<string[]> {
|
||||||
|
return await super.getJson(this.path + '/transaction-tags')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,9 @@ defineProps<{
|
||||||
defineEmits(['click'])
|
defineEmits(['click'])
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<button class="app-button" :class="{ 'app-button-secondary': buttonStyle === 'secondary' }" @click="$emit('click')"
|
<button class="app-button"
|
||||||
:type="buttonType" :disabled="disabled ?? false">
|
:class="{ 'app-button-secondary': buttonStyle === 'secondary', 'app-button-disabled': disabled ?? false }"
|
||||||
|
@click="$emit('click')" :type="buttonType" :disabled="disabled ?? false">
|
||||||
<span v-if="icon">
|
<span v-if="icon">
|
||||||
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
|
@ -44,7 +45,12 @@ defineEmits(['click'])
|
||||||
margin: 0.25rem;
|
margin: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-button:hover {
|
.app-button-disabled {
|
||||||
|
color: #686868;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button:hover:not(.app-button-disabled) {
|
||||||
background-color: #374151;
|
background-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,9 +60,8 @@ defineEmits(['click'])
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-button:active {
|
.app-button:active:not(.app-button-disabled) {
|
||||||
background-color: #3b4968;
|
background-color: #3b4968;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
<!--
|
||||||
|
Part of the EditTransactionPage which controls the list of line items for the
|
||||||
|
transaction. This editor shows a table of current line items, and includes a
|
||||||
|
modal for adding a new one.
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction';
|
||||||
|
import AppButton from './AppButton.vue';
|
||||||
|
import FormGroup from './form/FormGroup.vue';
|
||||||
|
import { formatMoney, type Currency } from '@/api/data';
|
||||||
|
import ModalWrapper from './ModalWrapper.vue';
|
||||||
|
import FormControl from './form/FormControl.vue';
|
||||||
|
import { ref, type Ref, useTemplateRef } from 'vue';
|
||||||
|
|
||||||
|
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
|
||||||
|
defineProps<{
|
||||||
|
currency: Currency | null,
|
||||||
|
categories: TransactionCategoryTree[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const addLineItemDescription = ref('')
|
||||||
|
const addLineItemValuePerItem = ref(0)
|
||||||
|
const addLineItemQuantity = ref(0)
|
||||||
|
const addLineItemCategory: Ref<TransactionCategoryTree | null> = ref(null)
|
||||||
|
const addLineItemModal = useTemplateRef('addLineItemModal')
|
||||||
|
|
||||||
|
|
||||||
|
function canAddLineItem() {
|
||||||
|
return addLineItemDescription.value.length > 0 &&
|
||||||
|
addLineItemQuantity.value > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddLineItemModal() {
|
||||||
|
addLineItemDescription.value = ''
|
||||||
|
addLineItemValuePerItem.value = 1.00
|
||||||
|
addLineItemQuantity.value = 1
|
||||||
|
addLineItemCategory.value = null
|
||||||
|
addLineItemModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLineItem() {
|
||||||
|
const idxs: number[] = model.value.map(i => i.idx)
|
||||||
|
const newIdx = Math.max(...idxs)
|
||||||
|
model.value.push({
|
||||||
|
idx: newIdx,
|
||||||
|
description: addLineItemDescription.value,
|
||||||
|
quantity: addLineItemQuantity.value,
|
||||||
|
valuePerItem: addLineItemValuePerItem.value,
|
||||||
|
category: addLineItemCategory.value
|
||||||
|
})
|
||||||
|
addLineItemModal.value?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLineItem(idx: number) {
|
||||||
|
model.value = model.value.filter(i => i.idx !== idx)
|
||||||
|
for (let i = 0; i < model.value.length; i++) {
|
||||||
|
model.value[i].idx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<FormGroup>
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="lineItem in model" :key="lineItem.idx">
|
||||||
|
<td>{{ lineItem.description }}</td>
|
||||||
|
<td style="text-align: right;">{{ currency ? formatMoney(lineItem.valuePerItem, currency) :
|
||||||
|
lineItem.valuePerItem }}</td>
|
||||||
|
<td style="text-align: right;">{{ lineItem.quantity }}</td>
|
||||||
|
<td style="text-align: right;">{{ lineItem.category?.name ?? 'None' }}</td>
|
||||||
|
<td>
|
||||||
|
<AppButton icon="trash" @click="removeLineItem(lineItem.idx)"></AppButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="model.length === 0">
|
||||||
|
<td colspan="4">No line items present.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div>
|
||||||
|
<AppButton button-type="button" @click="showAddLineItemModal()">Add</AppButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for adding a new line item. -->
|
||||||
|
<ModalWrapper ref="addLineItemModal">
|
||||||
|
<template v-slot:default>
|
||||||
|
<h3>Add Line Item</h3>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControl label="Description">
|
||||||
|
<textarea v-model="addLineItemDescription"></textarea>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Value Per Item">
|
||||||
|
<input type="number" step="0.01" v-model="addLineItemValuePerItem" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Quantity">
|
||||||
|
<input type="number" step="1" min="1" v-model="addLineItemQuantity" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Category">
|
||||||
|
<select v-model="addLineItemCategory">
|
||||||
|
<option v-for="category in categories" :key="category.id" :value="category">
|
||||||
|
{{ " ".repeat(4 * category.depth) + category.name }}
|
||||||
|
</option>
|
||||||
|
<option :value="null">None</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
</template>
|
||||||
|
<template v-slot:buttons>
|
||||||
|
<AppButton @click="addLineItem()" :disabled="!canAddLineItem()">Add</AppButton>
|
||||||
|
<AppButton button-style="secondary" @click="addLineItemModal?.close()">Cancel</AppButton>
|
||||||
|
</template>
|
||||||
|
</ModalWrapper>
|
||||||
|
</FormGroup>
|
||||||
|
</template>
|
|
@ -7,8 +7,8 @@ defineProps<{ submitText?: string, cancelText?: string, disabled?: boolean }>()
|
||||||
<template>
|
<template>
|
||||||
<div class="app-form-actions">
|
<div class="app-form-actions">
|
||||||
<AppButton button-type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
|
<AppButton button-type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
|
||||||
<AppButton button-style="secondary" @click="$emit('cancel')" :disabled="disabled ?? false">{{ cancelText ?? 'Cancel'
|
<AppButton button-style="secondary" @click="$emit('cancel')">{{ cancelText ?? 'Cancel'
|
||||||
}}</AppButton>
|
}}</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|
|
@ -23,4 +23,24 @@ defineProps<{
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles for different form controls under here: */
|
||||||
|
|
||||||
|
.app-form-control>label>input {
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'OpenSans', sans-serif;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-form-control>label>textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'OpenSans', sans-serif;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-form-control>label>select {
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'OpenSans', sans-serif;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -36,7 +36,7 @@ async function deleteAccount() {
|
||||||
try {
|
try {
|
||||||
const api = new AccountApiClient(profileStore.state)
|
const api = new AccountApiClient(profileStore.state)
|
||||||
await api.deleteAccount(account.value.id)
|
await api.deleteAccount(account.value.id)
|
||||||
await router.replace('/')
|
await router.replace(`/profiles/${profileStore.state.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,11 @@
|
||||||
import { ApiError } from '@/api/base';
|
import { ApiError } from '@/api/base';
|
||||||
import { formatMoney } from '@/api/data';
|
import { formatMoney } from '@/api/data';
|
||||||
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
|
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
|
||||||
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import AppPage from '@/components/AppPage.vue';
|
import AppPage from '@/components/AppPage.vue';
|
||||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { showAlert } from '@/util/alert';
|
import { showAlert, showConfirm } from '@/util/alert';
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
@ -33,6 +34,18 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function deleteTransaction() {
|
||||||
|
if (!transaction.value || !profileStore.state) return
|
||||||
|
const conf = await showConfirm('Are you sure you want to delete this transaction? This will permanently delete all data pertaining to this transaction, and it cannot be recovered.')
|
||||||
|
if (!conf) return
|
||||||
|
try {
|
||||||
|
await new TransactionApiClient(profileStore.state).deleteTransaction(transaction.value.id)
|
||||||
|
await router.replace(`/profiles/${profileStore.state.name}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
|
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
|
||||||
|
@ -108,5 +121,11 @@ onMounted(async () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<AppButton icon="wrench"
|
||||||
|
@click="router.push(`/profiles/${profileStore.state?.name}/transactions/${transaction.id}/edit`)">Edit
|
||||||
|
</AppButton>
|
||||||
|
<AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton>
|
||||||
|
</div>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -26,7 +26,6 @@ const currency = ref('USD')
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log(route)
|
|
||||||
if (!profileStore.state) return
|
if (!profileStore.state) return
|
||||||
|
|
||||||
const accountIdStr = route.params.id
|
const accountIdStr = route.params.id
|
||||||
|
|
|
@ -0,0 +1,351 @@
|
||||||
|
<!--
|
||||||
|
This page is quite large, and handles the form in which users can create and
|
||||||
|
edit transactions. It's accessed through two routes:
|
||||||
|
- /profiles/:profileName/transactions/:transactionId for editing
|
||||||
|
- /profiles/:profileName/add-transaction for creating a new transaction
|
||||||
|
|
||||||
|
The form consists of a few main sections:
|
||||||
|
- Standard form controls for various fields like timestamp, amount, description, etc.
|
||||||
|
- Line items table for editing the list of line items.
|
||||||
|
- Tags editor for editing the set of tags.
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AccountApiClient, type Account } from '@/api/account';
|
||||||
|
import { DataApiClient, type Currency } from '@/api/data';
|
||||||
|
import { TransactionApiClient, type AddTransactionPayload, type TransactionCategoryTree, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
||||||
|
import AppPage from '@/components/AppPage.vue';
|
||||||
|
import AppForm from '@/components/form/AppForm.vue';
|
||||||
|
import FormActions from '@/components/form/FormActions.vue';
|
||||||
|
import FormControl from '@/components/form/FormControl.vue';
|
||||||
|
import FormGroup from '@/components/form/FormGroup.vue';
|
||||||
|
import LineItemsEditor from '@/components/LineItemsEditor.vue';
|
||||||
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
import { computed, onMounted, ref, watch, type Ref } from 'vue';
|
||||||
|
import { useRoute, useRouter, } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
|
const existingTransaction: Ref<TransactionDetail | null> = ref(null)
|
||||||
|
const editing = computed(() => {
|
||||||
|
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
|
||||||
|
})
|
||||||
|
|
||||||
|
// General data used to populate form controls.
|
||||||
|
const allCurrencies: Ref<Currency[]> = ref([])
|
||||||
|
const availableCurrencies = computed(() => {
|
||||||
|
return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency === c.code))
|
||||||
|
})
|
||||||
|
const availableVendors: Ref<TransactionVendor[]> = ref([])
|
||||||
|
const availableCategories: Ref<TransactionCategoryTree[]> = ref([])
|
||||||
|
const allAccounts: Ref<Account[]> = ref([])
|
||||||
|
const availableAccounts = computed(() => {
|
||||||
|
return allAccounts.value.filter(a => a.currency === currency.value?.code)
|
||||||
|
})
|
||||||
|
const allTags: Ref<string[]> = ref([])
|
||||||
|
const availableTags = computed(() => {
|
||||||
|
return allTags.value.filter(t => !tags.value.includes(t))
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Form data:
|
||||||
|
const timestamp = ref('')
|
||||||
|
const amount = ref(0)
|
||||||
|
const currency: Ref<Currency | null> = ref(null)
|
||||||
|
const description = ref('')
|
||||||
|
const vendorId: Ref<number | null> = ref(null)
|
||||||
|
const categoryId: Ref<number | null> = ref(null)
|
||||||
|
const creditedAccountId: Ref<number | null> = ref(null)
|
||||||
|
const debitedAccountId: Ref<number | null> = ref(null)
|
||||||
|
const lineItems: Ref<TransactionDetailLineItem[]> = ref([])
|
||||||
|
const tags: Ref<string[]> = ref([])
|
||||||
|
const selectedTagToAdd: Ref<string | null> = ref(null)
|
||||||
|
const customTagInput = ref('')
|
||||||
|
const customTagInputValid = ref(false)
|
||||||
|
|
||||||
|
watch(customTagInput, (newValue: string) => {
|
||||||
|
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
||||||
|
customTagInputValid.value = result !== null && result.length > 0
|
||||||
|
})
|
||||||
|
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||||
|
if (newValue.length === 1) {
|
||||||
|
currency.value = newValue[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
const dataClient = new DataApiClient()
|
||||||
|
const transactionClient = new TransactionApiClient(profileStore.state)
|
||||||
|
const accountClient = new AccountApiClient(profileStore.state)
|
||||||
|
|
||||||
|
// Fetch various collections of data needed for different user choices.
|
||||||
|
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
|
||||||
|
transactionClient.getVendors().then(vendors => availableVendors.value = vendors)
|
||||||
|
transactionClient.getAllTags().then(t => allTags.value = t)
|
||||||
|
transactionClient.getCategories().then(categories => {
|
||||||
|
// Flatten the recursive list of categories.
|
||||||
|
const flattened: TransactionCategoryTree[] = []
|
||||||
|
flattenCategories(flattened, categories)
|
||||||
|
availableCategories.value = flattened
|
||||||
|
})
|
||||||
|
accountClient.getAccounts().then(accounts => allAccounts.value = accounts)
|
||||||
|
|
||||||
|
|
||||||
|
const transactionIdStr = route.params.id
|
||||||
|
if (transactionIdStr && typeof (transactionIdStr) === 'string') {
|
||||||
|
const transactionId = parseInt(transactionIdStr)
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
existingTransaction.value = await transactionClient.getTransaction(transactionId)
|
||||||
|
loadValuesFromExistingTransaction(existingTransaction.value)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function flattenCategories(arr: TransactionCategoryTree[], tree: TransactionCategoryTree[]) {
|
||||||
|
for (const category of tree) {
|
||||||
|
arr.push(category)
|
||||||
|
flattenCategories(arr, category.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits the transaction. If the user is editing an existing transaction,
|
||||||
|
* then that transaction will be updated. Otherwise, a new transaction is
|
||||||
|
* created.
|
||||||
|
*/
|
||||||
|
async function doSubmit() {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
|
||||||
|
const localDate = new Date(timestamp.value)
|
||||||
|
const scaledAmount = amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0)
|
||||||
|
const payload: AddTransactionPayload = {
|
||||||
|
timestamp: localDate.toISOString(),
|
||||||
|
amount: scaledAmount,
|
||||||
|
currencyCode: currency.value?.code ?? '',
|
||||||
|
description: description.value,
|
||||||
|
vendorId: vendorId.value,
|
||||||
|
categoryId: categoryId.value,
|
||||||
|
creditedAccountId: creditedAccountId.value,
|
||||||
|
debitedAccountId: debitedAccountId.value,
|
||||||
|
tags: tags.value,
|
||||||
|
lineItems: lineItems.value.map(i => {
|
||||||
|
return { ...i, categoryId: i.category?.id ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionApi = new TransactionApiClient(profileStore.state)
|
||||||
|
let savedTransaction = null
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
if (existingTransaction.value) {
|
||||||
|
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
|
||||||
|
} else {
|
||||||
|
savedTransaction = await transactionApi.addTransaction(payload)
|
||||||
|
}
|
||||||
|
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels editing / submitting a transaction, and takes the user back to their
|
||||||
|
* profile's homepage.
|
||||||
|
*/
|
||||||
|
function doCancel() {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
if (editing.value) {
|
||||||
|
router.replace(`/profiles/${profileStore.state.name}/transactions/${existingTransaction.value?.id}`)
|
||||||
|
} else {
|
||||||
|
router.replace(`/profiles/${profileStore.state.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
if (customTagInput.value.trim().length > 0) {
|
||||||
|
tags.value.push(customTagInput.value.trim())
|
||||||
|
tags.value.sort()
|
||||||
|
customTagInput.value = ''
|
||||||
|
} else if (selectedTagToAdd.value !== null) {
|
||||||
|
tags.value.push(selectedTagToAdd.value)
|
||||||
|
tags.value.sort()
|
||||||
|
selectedTagToAdd.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadValuesFromExistingTransaction(t: TransactionDetail) {
|
||||||
|
timestamp.value = getLocalDateTimeStringFromUTCTimestamp(t.timestamp)
|
||||||
|
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
||||||
|
currency.value = t.currency
|
||||||
|
description.value = t.description
|
||||||
|
vendorId.value = t.vendor?.id ?? null
|
||||||
|
categoryId.value = t.category?.id ?? null
|
||||||
|
creditedAccountId.value = t.creditedAccount?.id ?? null
|
||||||
|
debitedAccountId.value = t.debitedAccount?.id ?? null
|
||||||
|
lineItems.value = [...t.lineItems]
|
||||||
|
tags.value = [...t.tags]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
date.setMilliseconds(0)
|
||||||
|
const timezoneOffset = new Date().getTimezoneOffset() * 60_000
|
||||||
|
return (new Date(date.getTime() - timezoneOffset)).toISOString().slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the form is valid, which if true, means the user is allowed
|
||||||
|
* to save the form.
|
||||||
|
*/
|
||||||
|
function isFormValid() {
|
||||||
|
return timestamp.value.length > 0 &&
|
||||||
|
amount.value > 0 &&
|
||||||
|
currency.value !== null &&
|
||||||
|
description.value.length > 0 &&
|
||||||
|
(
|
||||||
|
creditedAccountId.value !== null ||
|
||||||
|
debitedAccountId.value !== null
|
||||||
|
) &&
|
||||||
|
creditedAccountId.value !== debitedAccountId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the user's editing an existing transaction, and there is at
|
||||||
|
* least one edit to it. Otherwise, there's no point in saving.
|
||||||
|
*/
|
||||||
|
function isEdited() {
|
||||||
|
if (!existingTransaction.value) return false
|
||||||
|
const tagsEqual = tags.value.every(t => existingTransaction.value?.tags.includes(t)) &&
|
||||||
|
existingTransaction.value.tags.every(t => tags.value.includes(t))
|
||||||
|
let lineItemsEqual = false
|
||||||
|
if (lineItems.value.length === existingTransaction.value.lineItems.length) {
|
||||||
|
lineItemsEqual = true
|
||||||
|
for (let i = 0; i < lineItems.value.length; i++) {
|
||||||
|
const i1 = lineItems.value[i]
|
||||||
|
const i2 = existingTransaction.value.lineItems[i]
|
||||||
|
if (
|
||||||
|
i1.idx !== i2.idx ||
|
||||||
|
i1.quantity !== i2.quantity ||
|
||||||
|
i1.valuePerItem !== i2.valuePerItem ||
|
||||||
|
i1.description !== i2.description ||
|
||||||
|
(i1.category?.id ?? null) !== (i2.category?.id ?? null)
|
||||||
|
) {
|
||||||
|
lineItemsEqual = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
||||||
|
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
||||||
|
currency.value !== existingTransaction.value.currency ||
|
||||||
|
description.value !== existingTransaction.value.description ||
|
||||||
|
vendorId.value !== (existingTransaction.value.vendor?.id ?? null) ||
|
||||||
|
categoryId.value !== (existingTransaction.value.category?.id ?? null) ||
|
||||||
|
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
||||||
|
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
||||||
|
!tagsEqual ||
|
||||||
|
!lineItemsEqual
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppPage :title="editing ? 'Edit Transaction' : 'Add Transaction'">
|
||||||
|
<AppForm @submit="doSubmit()">
|
||||||
|
<FormGroup>
|
||||||
|
<!-- Basic properties -->
|
||||||
|
<FormControl label="Timestamp">
|
||||||
|
<input type="datetime-local" v-model="timestamp" step="1" :disabled="loading" style="min-width: 250px;" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Amount">
|
||||||
|
<input type="number" v-model="amount" step="0.01" min="0.01" :disabled="loading" style="max-width: 100px;" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Currency">
|
||||||
|
<select v-model="currency" :disabled="loading || availableCurrencies.length === 1">
|
||||||
|
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
||||||
|
{{ currency.code }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Description" style="min-width: 200px;">
|
||||||
|
<textarea v-model="description" :disabled="loading"></textarea>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<!-- Vendor & Category -->
|
||||||
|
<FormControl label="Vendor">
|
||||||
|
<select v-model="vendorId" :disabled="loading">
|
||||||
|
<option v-for="vendor in availableVendors" :key="vendor.id" :value="vendor.id">
|
||||||
|
{{ vendor.name }}
|
||||||
|
</option>
|
||||||
|
<option :value="null" :selected="vendorId === null">None</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Category">
|
||||||
|
<select v-model="categoryId" :disabled="loading">
|
||||||
|
<option v-for="category in availableCategories" :key="category.id" :value="category.id">
|
||||||
|
{{ " ".repeat(4 * category.depth) + category.name }}
|
||||||
|
</option>
|
||||||
|
<option :value="null" :selected="categoryId === null">None</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<!-- Accounts -->
|
||||||
|
<FormControl label="Credited Account">
|
||||||
|
<select v-model="creditedAccountId" :disabled="loading">
|
||||||
|
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||||
|
{{ account.name }} ({{ account.numberSuffix }})
|
||||||
|
</option>
|
||||||
|
<option :value="null">None</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Debited Account">
|
||||||
|
<select v-model="debitedAccountId" :disabled="loading">
|
||||||
|
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||||
|
{{ account.name }} ({{ account.numberSuffix }})
|
||||||
|
</option>
|
||||||
|
<option :value="null">None</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<LineItemsEditor v-model="lineItems" :currency="currency" :categories="availableCategories" />
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<!-- Tags -->
|
||||||
|
<FormControl label="Tags">
|
||||||
|
<div style="margin-top: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<span v-for="tag in tags" :key="tag"
|
||||||
|
style="margin: 0.25rem; padding: 0.25rem 0.75rem; background-color: var(--bg-secondary); border-radius: 0.5rem;">
|
||||||
|
{{ tag }}
|
||||||
|
<font-awesome-icon :icon="'fa-x'" style="color: gray; cursor: pointer;"
|
||||||
|
@click="tags = tags.filter(t => t !== tag)"></font-awesome-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select v-model="selectedTagToAdd">
|
||||||
|
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="customTagInput" placeholder="Custom tag..." />
|
||||||
|
<button type="button" @click="addTag()" :disabled="selectedTagToAdd === null && !customTagInputValid">Add
|
||||||
|
Tag</button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
||||||
|
:submit-text="editing ? 'Save' : 'Add'" />
|
||||||
|
</AppForm>
|
||||||
|
</AppPage>
|
||||||
|
</template>
|
|
@ -2,11 +2,14 @@
|
||||||
import { formatMoney } from '@/api/data';
|
import { formatMoney } from '@/api/data';
|
||||||
import type { Page, PageRequest } from '@/api/pagination';
|
import type { Page, PageRequest } from '@/api/pagination';
|
||||||
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction';
|
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction';
|
||||||
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import HomeModule from '@/components/HomeModule.vue';
|
import HomeModule from '@/components/HomeModule.vue';
|
||||||
import PaginationControls from '@/components/PaginationControls.vue';
|
import PaginationControls from '@/components/PaginationControls.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const transactions: Ref<Page<TransactionsListItem>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true })
|
const transactions: Ref<Page<TransactionsListItem>> = ref({ items: [], pageRequest: { page: 1, size: 10, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true })
|
||||||
|
|
||||||
|
@ -56,5 +59,9 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
</table>
|
</table>
|
||||||
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
|
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-slot:actions>
|
||||||
|
<AppButton icon="plus" @click="router.push(`/profiles/${profileStore.state?.name}/add-transaction`)">Add
|
||||||
|
Transaction</AppButton>
|
||||||
|
</template>
|
||||||
</HomeModule>
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -58,6 +58,16 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/TransactionPage.vue'),
|
component: () => import('@/pages/TransactionPage.vue'),
|
||||||
meta: { title: 'Transaction' },
|
meta: { title: 'Transaction' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'transactions/:id/edit',
|
||||||
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
|
meta: { title: 'Edit Transaction' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'add-transaction',
|
||||||
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
|
meta: { title: 'Add Transaction' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue