Add file selector, attachments support.
This commit is contained in:
parent
2dae054950
commit
683d11a9a4
|
|
@ -15,12 +15,14 @@ import account.model;
|
|||
import account.service;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
import account.data;
|
||||
import attachment.data;
|
||||
import attachment.dto;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
import asdf : serdeTransformOut;
|
||||
import util.data;
|
||||
|
||||
ulong id;
|
||||
string createdAt;
|
||||
|
|
@ -28,7 +30,7 @@ struct AccountResponse {
|
|||
string type;
|
||||
string numberSuffix;
|
||||
string name;
|
||||
string currency;
|
||||
Currency currency;
|
||||
string description;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!long currentBalance;
|
||||
|
|
@ -41,7 +43,7 @@ struct AccountResponse {
|
|||
r.type = account.type.id;
|
||||
r.numberSuffix = account.numberSuffix;
|
||||
r.name = account.name;
|
||||
r.currency = account.currency.code.dup;
|
||||
r.currency = account.currency;
|
||||
r.description = account.description;
|
||||
r.currentBalance = currentBalance;
|
||||
return r;
|
||||
|
|
@ -124,15 +126,21 @@ struct AccountValueRecordResponse {
|
|||
string type;
|
||||
long value;
|
||||
Currency currency;
|
||||
AttachmentResponse[] attachments;
|
||||
|
||||
static AccountValueRecordResponse of(in AccountValueRecord vr) {
|
||||
static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) {
|
||||
import std.algorithm : map;
|
||||
import std.array : array;
|
||||
return AccountValueRecordResponse(
|
||||
vr.id,
|
||||
vr.timestamp.toISOExtString(),
|
||||
vr.accountId,
|
||||
vr.type,
|
||||
vr.value,
|
||||
vr.currency
|
||||
vr.currency,
|
||||
attachmentRepo.findAllByValueRecordId(vr.id)
|
||||
.map!(AttachmentResponse.of)
|
||||
.array
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -140,9 +148,10 @@ struct AccountValueRecordResponse {
|
|||
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||
auto ds = getProfileDataSource(request);
|
||||
scope attachmentRepo = ds.getAttachmentRepository();
|
||||
auto page = ds.getAccountValueRecordRepository()
|
||||
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
|
||||
.mapTo!()(&AccountValueRecordResponse.of);
|
||||
.mapTo!()((vr) => AccountValueRecordResponse.of(vr, attachmentRepo));
|
||||
writeJsonBody(response, page);
|
||||
}
|
||||
|
||||
|
|
@ -150,9 +159,10 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||
auto ds = getProfileDataSource(request);
|
||||
auto attachmentRepo = ds.getAttachmentRepository();
|
||||
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
||||
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
|
||||
}
|
||||
|
||||
struct ValueRecordCreationPayload {
|
||||
|
|
@ -166,23 +176,50 @@ void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpRespon
|
|||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
Account account = ds.getAccountRepository().findById(accountId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
ValueRecordCreationPayload payload = readJsonBodyAs!ValueRecordCreationPayload(request);
|
||||
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
auto fullPayload = parseMultipartFilesAndBody!ValueRecordCreationPayload(request);
|
||||
ValueRecordCreationPayload payload = fullPayload.payload;
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
|
||||
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
|
||||
AccountValueRecord record = ds.getAccountValueRecordRepository().insert(
|
||||
timestamp,
|
||||
account.id,
|
||||
type,
|
||||
payload.value,
|
||||
account.currency
|
||||
ulong valueRecordId;
|
||||
ds.doTransaction(() {
|
||||
AccountValueRecord record = valueRecordRepo.insert(
|
||||
timestamp,
|
||||
account.id,
|
||||
type,
|
||||
payload.value,
|
||||
account.currency
|
||||
);
|
||||
foreach (attachment; fullPayload.files) {
|
||||
ulong attachmentId = attachmentRepo.save(
|
||||
timestamp, attachment.name, attachment.contentType, attachment.content);
|
||||
valueRecordRepo.linkAttachment(record.id, attachmentId);
|
||||
}
|
||||
valueRecordId = record.id;
|
||||
});
|
||||
writeJsonBody(
|
||||
response,
|
||||
AccountValueRecordResponse.of(
|
||||
valueRecordRepo.findById(accountId, valueRecordId).orElseThrow(),
|
||||
attachmentRepo
|
||||
)
|
||||
);
|
||||
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
||||
}
|
||||
|
||||
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
ds.getAccountValueRecordRepository()
|
||||
.deleteById(accountId, valueRecordId);
|
||||
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
AccountValueRecord valueRecord = valueRecordRepo.findById(accountId, valueRecordId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
ds.doTransaction(() {
|
||||
// First delete all attachments.
|
||||
foreach (a; attachmentRepo.findAllByValueRecordId(valueRecord.id)) {
|
||||
attachmentRepo.remove(a.id);
|
||||
}
|
||||
valueRecordRepo.deleteById(accountId, valueRecordId);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ interface AccountValueRecordRepository {
|
|||
long value,
|
||||
Currency currency
|
||||
);
|
||||
void linkAttachment(ulong valueRecordId, ulong attachmentId);
|
||||
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr);
|
||||
void deleteById(ulong accountId, ulong id);
|
||||
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp);
|
||||
|
|
|
|||
|
|
@ -335,6 +335,15 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
|||
return findById(accountId, id).orElseThrow();
|
||||
}
|
||||
|
||||
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)",
|
||||
valueRecordId,
|
||||
attachmentId
|
||||
);
|
||||
}
|
||||
|
||||
void deleteById(ulong accountId, ulong id) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import std.datetime;
|
|||
interface AttachmentRepository {
|
||||
Optional!Attachment findById(ulong id);
|
||||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
|
||||
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content);
|
||||
Attachment[] findAllByTransactionId(ulong transactionId);
|
||||
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
||||
void remove(ulong id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,18 +19,34 @@ class SqliteAttachmentRepository : AttachmentRepository {
|
|||
Optional!Attachment findById(ulong id) {
|
||||
return findOne(
|
||||
db,
|
||||
"SELECT * FROM attachment WHERE id = ?",
|
||||
"SELECT id, uploaded_at, filename, content_type, size FROM attachment WHERE id = ?",
|
||||
&parseAttachment,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) {
|
||||
const query = format!"SELECT * FROM attachment WHERE id IN (%s)"(subquery);
|
||||
const query = format!("SELECT id, uploaded_at, filename, content_type, size " ~
|
||||
"FROM attachment WHERE id IN (%s) " ~
|
||||
"ORDER BY filename ASC, uploaded_at DESC")(subquery);
|
||||
return findAll(db, query, &parseAttachment, entityId);
|
||||
}
|
||||
|
||||
Attachment[] findAllByTransactionId(ulong transactionId) {
|
||||
return findAllByLinkedEntity(
|
||||
"SELECT attachment_id FROM transaction_attachment WHERE transaction_id = ?",
|
||||
transactionId
|
||||
);
|
||||
}
|
||||
|
||||
Attachment[] findAllByValueRecordId(ulong valueRecordId) {
|
||||
return findAllByLinkedEntity(
|
||||
"SELECT attachment_id FROM account_value_record_attachment WHERE value_record_id = ?",
|
||||
valueRecordId
|
||||
);
|
||||
}
|
||||
|
||||
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content) {
|
||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
q"SQL
|
||||
|
|
@ -61,8 +77,7 @@ SQL",
|
|||
parseISOTimestamp(row, 1),
|
||||
row.peek!string(2),
|
||||
row.peek!string(3),
|
||||
row.peek!ulong(4),
|
||||
parseBlob(row, 5)
|
||||
row.peek!ulong(4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
module attachment.dto;
|
||||
|
||||
import attachment.model;
|
||||
|
||||
struct AttachmentResponse {
|
||||
ulong id;
|
||||
string uploadedAt;
|
||||
string filename;
|
||||
string contentType;
|
||||
ulong size;
|
||||
|
||||
static AttachmentResponse of(Attachment a) {
|
||||
return AttachmentResponse(
|
||||
a.id,
|
||||
a.uploadedAt.toISOExtString(),
|
||||
a.filename,
|
||||
a.contentType,
|
||||
a.size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,5 +8,4 @@ struct Attachment {
|
|||
immutable string filename;
|
||||
immutable string contentType;
|
||||
immutable ulong size;
|
||||
immutable ubyte[] content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ interface PropertiesRepository {
|
|||
interface ProfileDataSource {
|
||||
import account.data;
|
||||
import transaction.data;
|
||||
import attachment.data;
|
||||
|
||||
PropertiesRepository getPropertiesRepository();
|
||||
AttachmentRepository getAttachmentRepository();
|
||||
|
||||
AccountRepository getAccountRepository();
|
||||
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
import account.data_impl_sqlite;
|
||||
import transaction.data;
|
||||
import transaction.data_impl_sqlite;
|
||||
import attachment.data;
|
||||
import attachment.data_impl_sqlite;
|
||||
|
||||
const SCHEMA = import("sql/schema.sql");
|
||||
private const string dbPath;
|
||||
|
|
@ -159,35 +161,39 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
}
|
||||
}
|
||||
|
||||
PropertiesRepository getPropertiesRepository() {
|
||||
PropertiesRepository getPropertiesRepository() return scope {
|
||||
return new SqlitePropertiesRepository(db);
|
||||
}
|
||||
|
||||
AccountRepository getAccountRepository() {
|
||||
AttachmentRepository getAttachmentRepository() return scope {
|
||||
return new SqliteAttachmentRepository(db);
|
||||
}
|
||||
|
||||
AccountRepository getAccountRepository() return scope {
|
||||
return new SqliteAccountRepository(db);
|
||||
}
|
||||
|
||||
AccountJournalEntryRepository getAccountJournalEntryRepository() {
|
||||
AccountJournalEntryRepository getAccountJournalEntryRepository() return scope {
|
||||
return new SqliteAccountJournalEntryRepository(db);
|
||||
}
|
||||
|
||||
AccountValueRecordRepository getAccountValueRecordRepository() {
|
||||
AccountValueRecordRepository getAccountValueRecordRepository() return scope {
|
||||
return new SqliteAccountValueRecordRepository(db);
|
||||
}
|
||||
|
||||
TransactionVendorRepository getTransactionVendorRepository() {
|
||||
TransactionVendorRepository getTransactionVendorRepository() return scope {
|
||||
return new SqliteTransactionVendorRepository(db);
|
||||
}
|
||||
|
||||
TransactionCategoryRepository getTransactionCategoryRepository() {
|
||||
TransactionCategoryRepository getTransactionCategoryRepository() return scope {
|
||||
return new SqliteTransactionCategoryRepository(db);
|
||||
}
|
||||
|
||||
TransactionTagRepository getTransactionTagRepository() {
|
||||
TransactionTagRepository getTransactionTagRepository() return scope {
|
||||
return new SqliteTransactionTagRepository(db);
|
||||
}
|
||||
|
||||
TransactionRepository getTransactionRepository() {
|
||||
TransactionRepository getTransactionRepository() return scope {
|
||||
return new SqliteTransactionRepository(db);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,20 +37,20 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
|
|||
}
|
||||
|
||||
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||
TransactionDetail txn = addTransaction(ds, payload);
|
||||
import asdf : serializeToJson;
|
||||
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
TransactionDetail txn = addTransaction(ds, fullPayload.payload, fullPayload.files);
|
||||
string jsonStr = serializeToJson(txn);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
||||
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
import asdf : serializeToJson;
|
||||
ProfileDataSource ds = getProfileDataSource(request);
|
||||
ulong txnId = getTransactionIdOrThrow(request);
|
||||
auto payload = readJsonBodyAs!AddTransactionPayload(request);
|
||||
TransactionDetail txn = updateTransaction(ds, txnId, payload);
|
||||
import asdf : serializeToJson;
|
||||
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
|
||||
TransactionDetail txn = updateTransaction(ds, txnId, fullPayload.payload, fullPayload.files);
|
||||
string jsonStr = serializeToJson(txn);
|
||||
response.writeBodyString(jsonStr, "application/json");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface TransactionRepository {
|
|||
Page!TransactionsListItem findAll(PageRequest pr);
|
||||
Optional!TransactionDetail findById(ulong id);
|
||||
TransactionDetail insert(in AddTransactionPayload data);
|
||||
void linkAttachment(ulong transactionId, ulong attachmentId);
|
||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,15 @@ class SqliteTransactionRepository : TransactionRepository {
|
|||
return findById(transactionId).orElseThrow();
|
||||
}
|
||||
|
||||
void linkAttachment(ulong transactionId, ulong attachmentId) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
|
||||
transactionId,
|
||||
attachmentId
|
||||
);
|
||||
}
|
||||
|
||||
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import asdf : serdeTransformOut;
|
|||
import std.typecons;
|
||||
|
||||
import transaction.model : TransactionCategory;
|
||||
import attachment.dto;
|
||||
import util.data;
|
||||
import util.money;
|
||||
|
||||
|
|
@ -59,6 +60,7 @@ struct TransactionDetail {
|
|||
Nullable!Account debitedAccount;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
AttachmentResponse[] attachments;
|
||||
|
||||
static struct Vendor {
|
||||
ulong id;
|
||||
|
|
@ -102,6 +104,7 @@ struct AddTransactionPayload {
|
|||
Nullable!ulong debitedAccountId;
|
||||
string[] tags;
|
||||
LineItem[] lineItems;
|
||||
ulong[] attachmentIdsToRemove;
|
||||
|
||||
static struct LineItem {
|
||||
long valuePerItem;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import account.data;
|
|||
import util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
import core.internal.container.common;
|
||||
import attachment.data;
|
||||
import attachment.dto;
|
||||
|
||||
// Transactions Services
|
||||
|
||||
|
|
@ -24,14 +25,22 @@ Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest p
|
|||
}
|
||||
|
||||
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
return ds.getTransactionRepository().findById(transactionId)
|
||||
import std.algorithm : map;
|
||||
import std.array : array;
|
||||
TransactionDetail txn = ds.getTransactionRepository().findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id)
|
||||
.map!(AttachmentResponse.of)
|
||||
.array;
|
||||
return txn;
|
||||
}
|
||||
|
||||
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
|
||||
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) {
|
||||
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
|
|
@ -39,8 +48,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
// Add the transaction:
|
||||
ulong txnId;
|
||||
ds.doTransaction(() {
|
||||
TransactionRepository txRepo = ds.getTransactionRepository();
|
||||
TransactionDetail txn = txRepo.insert(payload);
|
||||
TransactionDetail txn = txnRepo.insert(payload);
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
if (!payload.creditedAccountId.isNull) {
|
||||
jeRepo.insert(
|
||||
|
|
@ -64,18 +72,25 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
|
|||
}
|
||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||
tagRepo.updateTags(txn.id, payload.tags);
|
||||
updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
|
||||
txnId = txn.id;
|
||||
});
|
||||
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
||||
return getTransaction(ds, txnId);
|
||||
}
|
||||
|
||||
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
|
||||
TransactionDetail updateTransaction(
|
||||
ProfileDataSource ds,
|
||||
ulong transactionId,
|
||||
in AddTransactionPayload payload,
|
||||
in MultipartFile[] files
|
||||
) {
|
||||
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
||||
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
||||
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
||||
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
|
||||
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
||||
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
||||
|
|
@ -122,17 +137,24 @@ TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, i
|
|||
);
|
||||
}
|
||||
|
||||
// Update tags.
|
||||
tagRepo.updateTags(transactionId, payload.tags);
|
||||
updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
|
||||
});
|
||||
return transactionRepo.findById(transactionId).orElseThrow();
|
||||
return getTransaction(ds, transactionId);
|
||||
}
|
||||
|
||||
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
||||
TransactionRepository txnRepo = ds.getTransactionRepository();
|
||||
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
|
||||
TransactionDetail txn = txnRepo.findById(transactionId)
|
||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
txnRepo.deleteById(txn.id);
|
||||
ds.doTransaction(() {
|
||||
// First delete all attachments.
|
||||
foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) {
|
||||
attachmentRepo.remove(a.id);
|
||||
}
|
||||
txnRepo.deleteById(txn.id);
|
||||
});
|
||||
}
|
||||
|
||||
private void validateTransactionPayload(
|
||||
|
|
@ -205,6 +227,28 @@ private void validateTransactionPayload(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add / remove attachments for a transaction.
|
||||
*/
|
||||
void updateAttachments(
|
||||
ulong transactionId,
|
||||
SysTime timestamp,
|
||||
in ulong[] attachmentIdsToRemove,
|
||||
in MultipartFile[] attachmentsToAdd,
|
||||
AttachmentRepository attachmentRepo,
|
||||
TransactionRepository txnRepo
|
||||
) {
|
||||
// Save & link attachment files:
|
||||
foreach (file; attachmentsToAdd) {
|
||||
ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
|
||||
txnRepo.linkAttachment(transactionId, attachmentId);
|
||||
}
|
||||
// Delete attachments (this cascades to delete the link record in transaction_attachment).
|
||||
foreach (idToRemove; attachmentIdsToRemove) {
|
||||
attachmentRepo.remove(idToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
// Vendors Services
|
||||
|
||||
TransactionVendor[] getAllVendors(ProfileDataSource ds) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
module util.data;
|
||||
|
||||
import handy_http_primitives;
|
||||
import handy_http_data.multipart;
|
||||
import std.typecons;
|
||||
|
||||
Optional!T toOptional(T)(Nullable!T value) {
|
||||
|
|
@ -41,3 +42,86 @@ ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) {
|
|||
// No params matched, so throw a NOT FOUND error.
|
||||
throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\".");
|
||||
}
|
||||
|
||||
struct MultipartFile {
|
||||
string name;
|
||||
string contentType;
|
||||
ubyte[] content;
|
||||
}
|
||||
|
||||
struct MultipartFilesAndBody(T) {
|
||||
T payload;
|
||||
MultipartFile[] files;
|
||||
}
|
||||
|
||||
MultipartFilesAndBody!T parseMultipartFilesAndBody(T)(
|
||||
ref ServerHttpRequest request,
|
||||
string payloadPartName = "payload",
|
||||
string filesPartName = "file"
|
||||
) {
|
||||
import asdf : deserialize, SerdeException;
|
||||
import std.uni : toLower;
|
||||
import std.array;
|
||||
|
||||
MultipartFormData formData;
|
||||
try {
|
||||
formData = readBodyAsMultipartFormData(request);
|
||||
} catch (MultipartFormatException e) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid multipart form data.");
|
||||
}
|
||||
|
||||
MultipartFilesAndBody!T result;
|
||||
RefAppender!(MultipartFile[]) app = appender(&result.files);
|
||||
foreach (element; formData.elements) {
|
||||
if (toLower(element.name) == payloadPartName) {
|
||||
try {
|
||||
result.payload = deserialize!T(element.content);
|
||||
} catch (SerdeException e) {
|
||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST, e.msg);
|
||||
}
|
||||
} else if (toLower(element.name) == filesPartName) {
|
||||
app ~= parseMultipartFile(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private MultipartFile parseMultipartFile(in MultipartElement e) {
|
||||
import std.algorithm : splitter, count;
|
||||
import std.array;
|
||||
import std.string : strip;
|
||||
const requiredHeaders = ["Content-Disposition", "Content-Type"];
|
||||
static foreach (headerName; requiredHeaders) {
|
||||
if (headerName !in e.headers || e.headers[headerName].length < 1) {
|
||||
throw new HttpStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Missing required \"" ~ headerName ~ "\" header for multipart file."
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("Content-Disposition" !in e.headers) {
|
||||
throw new HttpStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Missing required \"Content-Disposition\" header for multipart file."
|
||||
);
|
||||
}
|
||||
string filename;
|
||||
foreach (part; e.headers["Content-Disposition"].splitter(";")) {
|
||||
string[] partSegments = part.splitter("=").array;
|
||||
if (count(partSegments) == 2 && partSegments[0].strip() == "filename") {
|
||||
filename = partSegments[1].strip().strip("\"");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (filename is null || filename.length < 1) {
|
||||
throw new HttpStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Missing filename for multipart file."
|
||||
);
|
||||
}
|
||||
return MultipartFile(
|
||||
filename,
|
||||
e.headers["Content-Type"],
|
||||
cast(ubyte[]) e.content
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ struct Page(T) {
|
|||
bool isLast;
|
||||
|
||||
|
||||
Page!U mapTo(U)(U function(T) fn) {
|
||||
Page!U mapTo(U)(U delegate(T) fn) {
|
||||
import std.algorithm : map;
|
||||
import std.array : array;
|
||||
return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast);
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
}
|
||||
}
|
||||
|
||||
auto txn = addTransaction(ds, data);
|
||||
auto txn = addTransaction(ds, data, []);
|
||||
infoF!" Generated transaction %d"(txn.id);
|
||||
timestamp -= seconds(uniform(10, 1_000_000));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export interface Account {
|
|||
type: string
|
||||
numberSuffix: string
|
||||
name: string
|
||||
currency: string
|
||||
currency: Currency
|
||||
description: string
|
||||
currentBalance: number | null
|
||||
}
|
||||
|
|
@ -133,8 +133,14 @@ export class AccountApiClient extends ApiClient {
|
|||
createValueRecord(
|
||||
accountId: number,
|
||||
payload: AccountValueRecordCreationPayload,
|
||||
attachments: File[],
|
||||
): Promise<AccountValueRecord> {
|
||||
return super.postJson(this.path + '/' + accountId + '/value-records', payload)
|
||||
const formData = new FormData()
|
||||
formData.append('payload', JSON.stringify(payload))
|
||||
for (const file of attachments) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
return super.postFormData(`${this.path}/${accountId}/value-records`, formData)
|
||||
}
|
||||
|
||||
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,37 @@ export abstract class ApiClient {
|
|||
return await r.json()
|
||||
}
|
||||
|
||||
protected async postFormData<T>(path: string, body: FormData): Promise<T> {
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
const response = await fetch(this.baseUrl + path, {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + authStore.state?.token,
|
||||
},
|
||||
body: body,
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new StatusError(response.status, message)
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
let message = 'Request to ' + path + ' failed.'
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
message = error.message
|
||||
}
|
||||
console.error(error)
|
||||
throw new NetworkError(message)
|
||||
}
|
||||
}
|
||||
|
||||
protected async postText(path: string, body: object | undefined = undefined): Promise<string> {
|
||||
const r = await this.doRequest('POST', path, body)
|
||||
return await r.text()
|
||||
|
|
@ -70,6 +101,37 @@ export abstract class ApiClient {
|
|||
return await r.json()
|
||||
}
|
||||
|
||||
protected async putFormData<T>(path: string, body: FormData): Promise<T> {
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
const response = await fetch(this.baseUrl + path, {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + authStore.state?.token,
|
||||
},
|
||||
body: body,
|
||||
method: 'PUT',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new StatusError(response.status, message)
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
let message = 'Request to ' + path + ' failed.'
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
message = error.message
|
||||
}
|
||||
console.error(error)
|
||||
throw new NetworkError(message)
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStatus(): Promise<boolean> {
|
||||
try {
|
||||
await this.doRequest('GET', '/status')
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export interface TransactionDetail {
|
|||
debitedAccount: TransactionDetailAccount | null
|
||||
tags: string[]
|
||||
lineItems: TransactionDetailLineItem[]
|
||||
attachments: TransactionDetailAttachment[]
|
||||
}
|
||||
|
||||
export interface TransactionDetailAccount {
|
||||
|
|
@ -104,6 +105,14 @@ export interface TransactionDetailLineItem {
|
|||
category: TransactionCategory | null
|
||||
}
|
||||
|
||||
export interface TransactionDetailAttachment {
|
||||
id: number
|
||||
uploadedAt: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AddTransactionPayload {
|
||||
timestamp: string
|
||||
amount: number
|
||||
|
|
@ -115,6 +124,7 @@ export interface AddTransactionPayload {
|
|||
debitedAccountId: number | null
|
||||
tags: string[]
|
||||
lineItems: AddTransactionPayloadLineItem[]
|
||||
attachmentIdsToRemove: number[]
|
||||
}
|
||||
|
||||
export interface AddTransactionPayloadLineItem {
|
||||
|
|
@ -205,12 +215,26 @@ export class TransactionApiClient extends ApiClient {
|
|||
return super.getJson(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
||||
addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return super.postJson(this.path + '/transactions', data)
|
||||
addTransaction(data: AddTransactionPayload, files: File[] = []): Promise<TransactionDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('payload', JSON.stringify(data))
|
||||
for (const file of files) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
return super.postFormData(this.path + '/transactions', formData)
|
||||
}
|
||||
|
||||
updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return super.putJson(this.path + '/transactions/' + id, data)
|
||||
updateTransaction(
|
||||
id: number,
|
||||
data: AddTransactionPayload,
|
||||
files: File[] = [],
|
||||
): Promise<TransactionDetail> {
|
||||
const formData = new FormData()
|
||||
formData.append('payload', JSON.stringify(data))
|
||||
for (const file of files) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
return super.putFormData(this.path + '/transactions/' + id, formData)
|
||||
}
|
||||
|
||||
deleteTransaction(id: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import AppButton from './AppButton.vue';
|
|||
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time';
|
||||
import FileSelector from './FileSelector.vue';
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const profileStore = useProfileStore()
|
||||
|
|
@ -17,6 +18,7 @@ const savedValueRecord: Ref<AccountValueRecord | undefined> = ref(undefined)
|
|||
// Form data:
|
||||
const timestamp = ref('')
|
||||
const amount = ref(0)
|
||||
const attachments: Ref<File[]> = ref([])
|
||||
|
||||
async function show(): Promise<AccountValueRecord | undefined> {
|
||||
if (!modal.value) return Promise.resolve(undefined)
|
||||
|
|
@ -39,7 +41,7 @@ async function addValueRecord() {
|
|||
}
|
||||
const api = new AccountApiClient(profileStore.state)
|
||||
try {
|
||||
savedValueRecord.value = await api.createValueRecord(props.account.id, payload)
|
||||
savedValueRecord.value = await api.createValueRecord(props.account.id, payload, attachments.value)
|
||||
modal.value?.close('saved')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -66,6 +68,10 @@ defineExpose({ show })
|
|||
<input type="number" v-model="amount" step="0.01" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<h5>Attachments</h5>
|
||||
<FileSelector v-model:uploaded-files="attachments" />
|
||||
</FormGroup>
|
||||
</AppForm>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
||||
import AppButton from './AppButton.vue';
|
||||
|
||||
interface ExistingFile {
|
||||
id: number
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
abstract class FileListItem {
|
||||
constructor(
|
||||
public readonly filename: string,
|
||||
public readonly contentType: string,
|
||||
public readonly size: number
|
||||
) { }
|
||||
}
|
||||
|
||||
class ExistingFileListItem extends FileListItem {
|
||||
public readonly id: number
|
||||
constructor(file: ExistingFile) {
|
||||
super(file.filename, file.contentType, file.size)
|
||||
this.id = file.id
|
||||
}
|
||||
}
|
||||
|
||||
class NewFileListItem extends FileListItem {
|
||||
public readonly file: File
|
||||
constructor(file: File) {
|
||||
super(file.name, file.type, file.size)
|
||||
this.file = file
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean,
|
||||
initialFiles?: ExistingFile[]
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
initialFiles: () => []
|
||||
})
|
||||
const fileInput = useTemplateRef('fileInput')
|
||||
|
||||
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
||||
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
||||
|
||||
// Internal file list model:
|
||||
const files: Ref<FileListItem[]> = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||
// If input initial files change, reset the file selector to just those.
|
||||
watch(() => props.initialFiles, () => {
|
||||
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||
})
|
||||
// When our internal model changes, update the defined uploaded/removed files models.
|
||||
watch(() => files, () => {
|
||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||
uploadedFiles.value = files.value.filter(f => f instanceof NewFileListItem).map(f => f.file)
|
||||
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
|
||||
const retainedExistingFileIds = files.value
|
||||
.filter(f => f instanceof ExistingFileListItem)
|
||||
.map(f => f.id)
|
||||
removedFiles.value = props.initialFiles
|
||||
.filter(f => !retainedExistingFileIds.includes(f.id))
|
||||
.map(f => f.id)
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
function onFileInputChanged(e: Event) {
|
||||
if (props.disabled) return
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
const fileList = inputElement.files
|
||||
if (fileList === null) {
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < fileList?.length; i++) {
|
||||
const file = fileList.item(i)
|
||||
if (file !== null) {
|
||||
files.value = [...files.value, new NewFileListItem(file)]
|
||||
}
|
||||
}
|
||||
// Reset the input element after we've consumed the selected files.
|
||||
inputElement.value = ''
|
||||
}
|
||||
|
||||
function onFileDeleteClicked(idx: number) {
|
||||
if (props.disabled) return
|
||||
if (idx === 0 && files.value.length === 1) {
|
||||
files.value = []
|
||||
return
|
||||
}
|
||||
files.value.splice(idx, 1)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="file-selector">
|
||||
<div @click.prevent="">
|
||||
<div v-for="file, idx in files" :key="idx" class="file-selector-item">
|
||||
<div style="display: flex; align-items: center; margin-left: 1rem;">
|
||||
<div>
|
||||
<div>{{ file.filename }}</div>
|
||||
<div style="font-size: 0.75rem;">{{ file.contentType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AppButton v-if="!disabled" icon="trash" button-type="button" @click="onFileDeleteClicked(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
|
||||
:disabled="disabled" />
|
||||
<label for="fileInput">
|
||||
<AppButton icon="upload" button-type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
|
||||
</AppButton>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="css">
|
||||
.file-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-selector-item {
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
||||
import AppButton from '@/components/AppButton.vue';
|
||||
import AppPage from '@/components/AppPage.vue';
|
||||
|
|
@ -81,12 +82,16 @@ async function addValueRecord() {
|
|||
</tr>
|
||||
<tr>
|
||||
<th>Currency</th>
|
||||
<td>{{ account.currency }}</td>
|
||||
<td>{{ account.currency.code }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{{ account.description }}</td>
|
||||
</tr>
|
||||
<tr v-if="account.currentBalance">
|
||||
<th>Current Balance</th>
|
||||
<td>{{ formatMoney(account.currentBalance, account.currency) }}</td>
|
||||
</tr>
|
||||
</PropertiesTable>
|
||||
<div>
|
||||
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { DataApiClient, type Currency } from '@/api/data';
|
|||
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
||||
import AppPage from '@/components/AppPage.vue';
|
||||
import CategorySelect from '@/components/CategorySelect.vue';
|
||||
import FileSelector from '@/components/FileSelector.vue';
|
||||
import AppForm from '@/components/form/AppForm.vue';
|
||||
import FormActions from '@/components/form/FormActions.vue';
|
||||
import FormControl from '@/components/form/FormControl.vue';
|
||||
|
|
@ -65,6 +66,8 @@ const tags: Ref<string[]> = ref([])
|
|||
const selectedTagToAdd: Ref<string | null> = ref(null)
|
||||
const customTagInput = ref('')
|
||||
const customTagInputValid = ref(false)
|
||||
const attachmentsToUpload: Ref<File[]> = ref([])
|
||||
const removedAttachmentIds: Ref<number[]> = ref([])
|
||||
|
||||
watch(customTagInput, (newValue: string) => {
|
||||
const result = newValue.match("^[a-z0-9-_]{3,32}$")
|
||||
|
|
@ -130,7 +133,8 @@ async function doSubmit() {
|
|||
tags: tags.value,
|
||||
lineItems: lineItems.value.map(i => {
|
||||
return { ...i, categoryId: i.category?.id ?? null }
|
||||
})
|
||||
}),
|
||||
attachmentIdsToRemove: removedAttachmentIds.value
|
||||
}
|
||||
|
||||
const transactionApi = new TransactionApiClient()
|
||||
|
|
@ -138,9 +142,9 @@ async function doSubmit() {
|
|||
try {
|
||||
loading.value = true
|
||||
if (existingTransaction.value) {
|
||||
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
|
||||
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload, attachmentsToUpload.value)
|
||||
} else {
|
||||
savedTransaction = await transactionApi.addTransaction(payload)
|
||||
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
|
||||
}
|
||||
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
|
||||
} catch (err) {
|
||||
|
|
@ -237,6 +241,7 @@ function isEdited() {
|
|||
}
|
||||
}
|
||||
}
|
||||
const attachmentsChanged = attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
|
||||
|
||||
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
|
||||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
|
||||
|
|
@ -247,7 +252,8 @@ function isEdited() {
|
|||
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
|
||||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
|
||||
!tagsEqual ||
|
||||
!lineItemsEqual
|
||||
!lineItemsEqual ||
|
||||
attachmentsChanged
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
|
@ -327,6 +333,12 @@ function isEdited() {
|
|||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<h5>Attachments</h5>
|
||||
<FileSelector :initial-files="existingTransaction?.attachments ?? []"
|
||||
v-model:uploaded-files="attachmentsToUpload" v-model:removed-files="removedAttachmentIds" />
|
||||
</FormGroup>
|
||||
|
||||
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
||||
:submit-text="editing ? 'Save' : 'Add'" />
|
||||
</AppForm>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import AppButton from '@/components/AppButton.vue'
|
||||
import HomeModule from '@/components/HomeModule.vue'
|
||||
import { useProfileStore } from '@/stores/profile-store'
|
||||
|
|
@ -32,6 +33,7 @@ onMounted(async () => {
|
|||
<th>Currency</th>
|
||||
<th>Number</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -40,9 +42,13 @@ onMounted(async () => {
|
|||
<RouterLink :to="`/profiles/${profileStore.state?.name}/accounts/${account.id}`">{{ account.name }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ account.currency }}</td>
|
||||
<td>{{ account.currency.code }}</td>
|
||||
<td>...{{ account.numberSuffix }}</td>
|
||||
<td>{{ account.type }}</td>
|
||||
<td>
|
||||
<span v-if="account.currentBalance">{{ formatMoney(account.currentBalance, account.currency) }}</span>
|
||||
<span v-if="!account.currentBalance">Unknown</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
Loading…
Reference in New Issue