support importing transaction in frontend

This commit is contained in:
MaysWind
2024-09-09 01:31:43 +08:00
parent 3d5a03a629
commit 470a74f420
32 changed files with 1772 additions and 197 deletions
+5
View File
@@ -317,6 +317,11 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
}
// Transaction Pictures
if config.EnableTransactionPictures {
apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler))
+6
View File
@@ -221,6 +221,12 @@ max_user_avatar_size = 1048576
# Set to true to allow users to export their data
enable_export = true
# Set to true to allow users to import their data
enable_import = true
# Maximum allowed import file size (1 - 4294967295 bytes)
max_import_file_size = 10485760
[notification]
# Set to true to display custom notification in home page every time users register
enable_notification_after_register = false
+242 -12
View File
@@ -1,11 +1,13 @@
package api
import (
"io"
"sort"
"strings"
orderedmap "github.com/wk8/go-ordered-map/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -23,12 +25,14 @@ const maximumPicturesCountOfTransaction = 10
type TransactionsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
transactions *services.TransactionService
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
transactionPictures *services.TransactionPictureService
accounts *services.AccountService
users *services.UserService
ezBookKeepingCsvConverter converters.TransactionDataConverter
ezBookKeepingTsvConverter converters.TransactionDataConverter
transactions *services.TransactionService
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
transactionPictures *services.TransactionPictureService
accounts *services.AccountService
users *services.UserService
}
// Initialize a transaction api singleton instance
@@ -40,12 +44,14 @@ var (
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
transactions: services.Transactions,
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
transactionPictures: services.TransactionPictures,
accounts: services.Accounts,
users: services.Users,
ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter,
ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter,
transactions: services.Transactions,
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
transactionPictures: services.TransactionPictures,
accounts: services.Accounts,
users: services.Users,
}
)
@@ -1004,6 +1010,230 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return true, nil
}
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
fileTypes := form.Value["fileType"]
if len(fileTypes) < 1 || fileTypes[0] == "" {
return nil, errs.ErrImportFileTypeIsEmpty
}
fileType := fileTypes[0]
var dataImporter converters.TransactionDataImporter
if fileType == "ezbookkeeping_csv" {
dataImporter = a.ezBookKeepingCsvConverter
} else if fileType == "ezbookkeeping_tsv" {
dataImporter = a.ezBookKeepingTsvConverter
} else {
return nil, errs.ErrImportFileTypeNotSupported
}
importFiles := form.File["file"]
if len(importFiles) < 1 {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] there is no import file in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoFilesUpload
}
if importFiles[0].Size < 1 {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrUploadedFileEmpty
}
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
return nil, errs.ErrExceedMaxUploadFileSize
}
importFile, err := importFiles[0].Open()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
fileData, err := io.ReadAll(importFile)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
accounts, err := a.accounts.GetAllAccountsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get accounts for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountNameMapByList(accounts)
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, user.Uid, 0, -1)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get categories for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
categoryMap := a.transactionCategories.GetCategoryNameMapByList(categories)
tags, err := a.transactionTags.GetAllTagsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get tags for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagMap := a.transactionTags.GetTagNameMapByList(tags)
parsedTransactions, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, categoryMap, tagMap)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse imported data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
parsedTransactionRespsList := parsedTransactions.ToImportTransactionResponseList()
if len(parsedTransactionRespsList) < 1 {
return nil, errs.ErrNoDataToImport
}
parsedTransactionResps := &models.ImportTransactionResponsePageWrapper{
Items: parsedTransactionRespsList,
TotalCount: int64(len(parsedTransactionRespsList)),
}
return parsedTransactionResps, nil
}
// TransactionImportHandler imports transactions by request parameters for current user
func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *errs.Error) {
var transactionImportReq models.TransactionImportRequest
err := c.ShouldBindJSON(&transactionImportReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionImportHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && transactionImportReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId)
if found {
log.Infof(c, "[transactions.TransactionImportHandler] another \"%s\" transactions has been imported for user \"uid:%d\"", remark, uid)
count, err := utils.StringToInt(remark)
if err == nil {
return count, nil
}
}
}
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
tagIds, err := utils.StringArrayToInt64Array(transactionCreateReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionImportHandler] parse tag ids failed of transaction \"index:%d\", because %s", i, err.Error())
return nil, errs.ErrTransactionTagIdInvalid
}
if len(tagIds) > maximumTagsCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyTags
}
if transactionCreateReq.Type < models.TRANSACTION_TYPE_MODIFY_BALANCE || transactionCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transactions.TransactionImportHandler] transaction type of transaction \"index:%d\" is invalid", i)
return nil, errs.ErrTransactionTypeInvalid
}
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
}
if transactionCreateReq.Type != models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.DestinationAccountId != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] non-transfer transaction \"index:%d\" destination account cannot be set", i)
return nil, errs.ErrTransactionDestinationAccountCannotBeSet
} else if transactionCreateReq.Type == models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.SourceAccountId == transactionCreateReq.DestinationAccountId {
log.Warnf(c, "[transactions.TransactionImportHandler] transfer transaction \"index:%d\" source account must not be destination account", i)
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
}
if transactionCreateReq.Type != models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.DestinationAmount != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] non-transfer transaction \"index:%d\" destination amount cannot be set", i)
return nil, errs.ErrTransactionDestinationAmountCannotBeSet
}
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionImportHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
newTransactions := make([]*models.Transaction, len(transactionImportReq.Transactions))
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
transaction := a.createNewTransactionModel(uid, transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transactionCreateReq.UtcOffset)
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
}
newTransactions[i] = transaction
}
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions)
count := len(newTransactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionImportHandler] failed to import %d transactions for user \"uid:%d\", because %s", count, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionImportHandler] user \"uid:%d\" has imported %d transactions successfully", uid, count)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId, utils.IntToString(count))
return count, nil
}
func (a *TransactionsApi) filterTransactions(c *core.WebContext, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account) []*models.Transaction {
finalTransactions := make([]*models.Transaction, 0, len(transactions))
+1 -1
View File
@@ -720,7 +720,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
return errs.ErrOperationFailed
}
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions)
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions.ToTransactionsList())
if err != nil {
log.BootErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
@@ -82,7 +82,7 @@ func (c *DataTableTransactionDataConverter) buildExportedContent(ctx core.Contex
return nil
}
func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context, user *models.User, dataTable ImportedDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context, user *models.User, dataTable ImportedDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
if dataTable.DataRowCount() < 1 {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
return nil, nil, nil, nil, errs.ErrOperationFailed
@@ -127,7 +127,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
tagMap = make(map[string]*models.TransactionTag)
}
allNewTransactions := make(ImportedTransactionSlice, 0, dataTable.DataRowCount())
allNewTransactions := make(models.ImportedTransactionSlice, 0, dataTable.DataRowCount())
allNewAccounts := make([]*models.Account, 0)
allNewSubCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0)
@@ -177,6 +177,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
}
categoryId := int64(0)
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
@@ -186,7 +187,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
return nil, nil, nil, nil, err
}
subCategoryName := dataRow.GetData(subCategoryColumnIdx)
subCategoryName = dataRow.GetData(subCategoryColumnIdx)
if subCategoryName == "" {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] sub category type is empty in data row \"index:%d\" for user \"uid:%d\"", dataRowIndex, user.Uid)
@@ -211,30 +212,32 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
accountCurrency := user.DefaultCurrency
if accountCurrencyColumnExists {
accountCurrency = dataRow.GetData(accountCurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[accountCurrency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", accountCurrency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account, exists := accountMap[accountName]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = dataRow.GetData(accountCurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account = c.createNewAccountModel(user.Uid, accountName, currency)
account = c.createNewAccountModel(user.Uid, accountName, accountCurrency)
allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account
}
if accountCurrencyColumnExists {
if account.Currency != dataRow.GetData(accountCurrencyColumnIdx) {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", dataRow.GetData(accountCurrencyColumnIdx), dataRowIndex, account.Currency, user.Uid)
if account.Currency != accountCurrency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", accountCurrency, dataRowIndex, account.Currency, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
accountCurrency = account.Currency
}
amount, err := utils.ParseAmount(dataRow.GetData(amountColumnIdx))
@@ -246,39 +249,43 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
relatedAccountId := int64(0)
relatedAccountAmount := int64(0)
account2Name := ""
account2Currency := ""
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name := dataRow.GetData(account2ColumnIdx)
account2Name = dataRow.GetData(account2ColumnIdx)
if account2Name == "" {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 name is empty in data row \"index:%d\" for user \"uid:%d\"", dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
account2Currency = user.DefaultCurrency
if account2CurrencyColumnExists {
account2Currency = dataRow.GetData(account2CurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[account2Currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", account2Currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2, exists := accountMap[account2Name]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = dataRow.GetData(account2CurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2 = c.createNewAccountModel(user.Uid, account2Name, currency)
account2 = c.createNewAccountModel(user.Uid, account2Name, account2Currency)
allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2
}
if account2CurrencyColumnExists {
if account2.Currency != dataRow.GetData(account2CurrencyColumnIdx) {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", dataRow.GetData(account2CurrencyColumnIdx), dataRowIndex, account2.Currency, user.Uid)
if account2.Currency != account2Currency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", account2Currency, dataRowIndex, account2.Currency, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
account2Currency = account2.Currency
}
relatedAccountId = account2.AccountId
@@ -313,11 +320,14 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
}
}
if tagsColumnExists {
tagNames := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator)
var tagIds []string
var tagNames []string
for i := 0; i < len(tagNames); i++ {
tagName := tagNames[i]
if tagsColumnExists {
tagNameItems := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator)
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
if tagName == "" {
continue
@@ -330,6 +340,12 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
if tag != nil {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
tagNames = append(tagNames, tagName)
}
}
@@ -339,21 +355,30 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
description = dataRow.GetData(descriptionColumnIdx)
}
transaction := &models.Transaction{
Uid: user.Uid,
Type: transactionDbType,
CategoryId: categoryId,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
TimezoneUtcOffset: timezoneOffset,
AccountId: account.AccountId,
Amount: amount,
HideAmount: false,
RelatedAccountId: relatedAccountId,
RelatedAccountAmount: relatedAccountAmount,
Comment: description,
GeoLongitude: geoLongitude,
GeoLatitude: geoLatitude,
CreatedIp: "127.0.0.1",
transaction := &models.ImportTransaction{
Transaction: &models.Transaction{
Uid: user.Uid,
Type: transactionDbType,
CategoryId: categoryId,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
TimezoneUtcOffset: timezoneOffset,
AccountId: account.AccountId,
Amount: amount,
HideAmount: false,
RelatedAccountId: relatedAccountId,
RelatedAccountAmount: relatedAccountAmount,
Comment: description,
GeoLongitude: geoLongitude,
GeoLatitude: geoLatitude,
CreatedIp: "127.0.0.1",
},
TagIds: tagIds,
OriginalCategoryName: subCategoryName,
OriginalSourceAccountName: accountName,
OriginalSourceAccountCurrency: accountCurrency,
OriginalDestinationAccountName: account2Name,
OriginalDestinationAccountCurrency: account2Currency,
OriginalTagNames: tagNames,
}
allNewTransactions = append(allNewTransactions, transaction)
@@ -73,7 +73,7 @@ func (c *ezBookKeepingTransactionDataPlainTextConverter) ToExportedContent(ctx c
}
// ParseImportedData returns the imported data by parsing the transaction plain text data
func (c *ezBookKeepingTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
func (c *ezBookKeepingTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
dataTable, err := createNewezbookkeepingTransactionPlainTextDataTable(string(data), c.columnSeparator, c.lineSeparator)
if err != nil {
@@ -147,7 +147,9 @@ func createNewezbookkeepingTransactionPlainTextDataTable(content string, columnS
return nil, errs.ErrOperationFailed
}
headerLineItems := strings.Split(allLines[0], columnSeparator)
headerLine := allLines[0]
headerLine = strings.ReplaceAll(headerLine, "\r", "")
headerLineItems := strings.Split(headerLine, columnSeparator)
return &ezBookKeepingTransactionPlainTextDataTable{
columnSeparator: columnSeparator,
-29
View File
@@ -1,29 +0,0 @@
package converters
import "github.com/mayswind/ezbookkeeping/pkg/models"
// ImportedTransactionSlice represents the slice data structure of import transaction data
type ImportedTransactionSlice []*models.Transaction
// Len returns the count of items
func (s ImportedTransactionSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s ImportedTransactionSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less reports whether the first item is less than the second one
func (s ImportedTransactionSlice) Less(i, j int) bool {
if s[i].Type != s[j].Type && (s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE || s[j].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE) {
if s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return true
} else if s[j].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return false
}
}
return s[i].TransactionTime < s[j].TransactionTime
}
@@ -1,53 +0,0 @@
package converters
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestImportTransactionSliceLess(t *testing.T) {
var transactionSlice ImportedTransactionSlice
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 1,
Type: models.TRANSACTION_DB_TYPE_EXPENSE,
TransactionTime: 1,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 2,
Type: models.TRANSACTION_DB_TYPE_INCOME,
TransactionTime: 2,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 3,
Type: models.TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 10,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 4,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_IN,
TransactionTime: 3,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 5,
Type: models.TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 11,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 6,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_OUT,
TransactionTime: 4,
})
sort.Sort(transactionSlice)
assert.Equal(t, int64(3), transactionSlice[0].TransactionId)
assert.Equal(t, int64(5), transactionSlice[1].TransactionId)
assert.Equal(t, int64(1), transactionSlice[2].TransactionId)
assert.Equal(t, int64(2), transactionSlice[3].TransactionId)
assert.Equal(t, int64(4), transactionSlice[4].TransactionId)
assert.Equal(t, int64(6), transactionSlice[5].TransactionId)
}
+1 -1
View File
@@ -14,7 +14,7 @@ type TransactionDataExporter interface {
// TransactionDataImporter defines the structure of transaction data importer
type TransactionDataImporter interface {
// ParseImportedData returns the imported data
ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error)
ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error)
}
// TransactionDataConverter defines the structure of transaction data converter
@@ -11,4 +11,5 @@ const (
DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 6
)
+3
View File
@@ -22,6 +22,9 @@ var (
ErrParameterInvalid = NewNormalError(NormalSubcategoryGlobal, 12, http.StatusBadRequest, "parameter invalid")
ErrFormatInvalid = NewNormalError(NormalSubcategoryGlobal, 13, http.StatusBadRequest, "format invalid")
ErrNumberInvalid = NewNormalError(NormalSubcategoryGlobal, 14, http.StatusBadRequest, "number invalid")
ErrNoFilesUpload = NewNormalError(NormalSubcategoryGlobal, 15, http.StatusBadRequest, "no files uploaded")
ErrUploadedFileEmpty = NewNormalError(NormalSubcategoryGlobal, 16, http.StatusBadRequest, "uploaded file is empty")
ErrExceedMaxUploadFileSize = NewNormalError(NormalSubcategoryGlobal, 17, http.StatusBadRequest, "uploaded file size exceeds the maximum allowed size")
)
// GetParameterInvalidMessage returns specific error message for invalid parameter error
+7 -8
View File
@@ -4,12 +4,11 @@ import "net/http"
// Error codes related to transaction categories
var (
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
ErrImportFileTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 7, http.StatusBadRequest, "import file type not supported")
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
)
+3
View File
@@ -29,4 +29,7 @@ var (
ErrCannotUseHiddenTransactionTag = NewNormalError(NormalSubcategoryTransaction, 22, http.StatusBadRequest, "cannot use hidden transaction tag")
ErrTransactionHasTooManyTags = NewNormalError(NormalSubcategoryTransaction, 23, http.StatusBadRequest, "transaction has too many tags")
ErrTransactionHasTooManyPictures = NewNormalError(NormalSubcategoryTransaction, 24, http.StatusBadRequest, "transaction has too many pictures")
ErrImportFileTypeIsEmpty = NewSystemError(NormalSubcategoryTransaction, 25, http.StatusBadRequest, "import file type is empty")
ErrImportFileTypeNotSupported = NewSystemError(NormalSubcategoryTransaction, 26, http.StatusBadRequest, "import file type not supported")
ErrNoDataToImport = NewSystemError(NormalSubcategoryTransaction, 27, http.StatusBadRequest, "no data to import")
)
@@ -22,6 +22,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
buildBooleanSetting("p", config.EnableTransactionPictures),
buildBooleanSetting("s", config.EnableScheduledTransaction),
buildBooleanSetting("e", config.EnableDataExport),
buildBooleanSetting("i", config.EnableDataImport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
}
+145
View File
@@ -0,0 +1,145 @@
package models
import "github.com/mayswind/ezbookkeeping/pkg/utils"
// ImportTransaction represents the imported transaction data
type ImportTransaction struct {
*Transaction
TagIds []string
OriginalCategoryName string
OriginalSourceAccountName string
OriginalSourceAccountCurrency string
OriginalDestinationAccountName string
OriginalDestinationAccountCurrency string
OriginalTagNames []string
}
// ImportTransactionResponse represents a view-object of the imported transaction data
type ImportTransactionResponse struct {
Type TransactionType `json:"type"`
CategoryId int64 `json:"categoryId,string"`
OriginalCategoryName string `json:"originalCategoryName"`
Time int64 `json:"time"`
UtcOffset int16 `json:"utcOffset"`
SourceAccountId int64 `json:"sourceAccountId,string"`
OriginalSourceAccountName string `json:"originalSourceAccountName"`
OriginalSourceAccountCurrency string `json:"originalSourceAccountCurrency"`
DestinationAccountId int64 `json:"destinationAccountId,string,omitempty"`
OriginalDestinationAccountName string `json:"originalDestinationAccountName,omitempty"`
OriginalDestinationAccountCurrency string `json:"originalDestinationAccountCurrency,omitempty"`
SourceAmount int64 `json:"sourceAmount"`
DestinationAmount int64 `json:"destinationAmount,omitempty"`
TagIds []string `json:"tagIds"`
OriginalTagNames []string `json:"originalTagNames"`
Comment string `json:"comment"`
GeoLocation *TransactionGeoLocationResponse `json:"geoLocation,omitempty"`
}
// ImportTransactionResponsePageWrapper represents a response of imported transaction which contains items and count
type ImportTransactionResponsePageWrapper struct {
Items []*ImportTransactionResponse `json:"items"`
TotalCount int64 `json:"totalCount"`
}
// ToImportTransactionResponse returns the a view-objects according to imported transaction data
func (t ImportTransaction) ToImportTransactionResponse() *ImportTransactionResponse {
var transactionType TransactionType
if t.Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionType = TRANSACTION_TYPE_MODIFY_BALANCE
} else if t.Type == TRANSACTION_DB_TYPE_EXPENSE {
transactionType = TRANSACTION_TYPE_EXPENSE
} else if t.Type == TRANSACTION_DB_TYPE_INCOME {
transactionType = TRANSACTION_TYPE_INCOME
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT {
transactionType = TRANSACTION_TYPE_TRANSFER
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN {
transactionType = TRANSACTION_TYPE_TRANSFER
} else {
return nil
}
geoLocation := &TransactionGeoLocationResponse{}
if t.GeoLongitude != 0 || t.GeoLatitude != 0 {
geoLocation.Longitude = t.GeoLongitude
geoLocation.Latitude = t.GeoLatitude
} else {
geoLocation = nil
}
return &ImportTransactionResponse{
Type: transactionType,
CategoryId: t.CategoryId,
OriginalCategoryName: t.OriginalCategoryName,
Time: utils.GetUnixTimeFromTransactionTime(t.TransactionTime),
UtcOffset: t.TimezoneUtcOffset,
SourceAccountId: t.AccountId,
OriginalSourceAccountName: t.OriginalSourceAccountName,
OriginalSourceAccountCurrency: t.OriginalSourceAccountCurrency,
DestinationAccountId: t.RelatedAccountId,
OriginalDestinationAccountName: t.OriginalDestinationAccountName,
OriginalDestinationAccountCurrency: t.OriginalDestinationAccountCurrency,
SourceAmount: t.Amount,
DestinationAmount: t.RelatedAccountAmount,
TagIds: t.TagIds,
OriginalTagNames: t.OriginalTagNames,
Comment: t.Comment,
GeoLocation: geoLocation,
}
}
// ImportedTransactionSlice represents the slice data structure of import transaction data
type ImportedTransactionSlice []*ImportTransaction
// Len returns the count of items
func (s ImportedTransactionSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s ImportedTransactionSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less reports whether the first item is less than the second one
func (s ImportedTransactionSlice) Less(i, j int) bool {
if s[i].Type != s[j].Type && (s[i].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE || s[j].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE) {
if s[i].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return true
} else if s[j].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return false
}
}
return s[i].TransactionTime < s[j].TransactionTime
}
// ToTransactionsList returns the a list of transactions
func (s ImportedTransactionSlice) ToTransactionsList() []*Transaction {
transactions := make([]*Transaction, s.Len())
for i := 0; i < s.Len(); i++ {
transactions[i] = s[i].Transaction
}
return transactions
}
// ToImportTransactionResponseList returns the a list of view-objects according to imported transaction data
func (s ImportedTransactionSlice) ToImportTransactionResponseList() []*ImportTransactionResponse {
transactionResps := make([]*ImportTransactionResponse, 0, s.Len())
for i := 0; i < s.Len(); i++ {
importedTransaction := s[i]
importedTransactionResp := importedTransaction.ToImportTransactionResponse()
if importedTransactionResp == nil {
continue
}
transactionResps = append(transactionResps, importedTransactionResp)
}
return transactionResps
}
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestImportTransactionSliceLess(t *testing.T) {
var transactionSlice ImportedTransactionSlice
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 1,
Type: TRANSACTION_DB_TYPE_EXPENSE,
TransactionTime: 1,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 2,
Type: TRANSACTION_DB_TYPE_INCOME,
TransactionTime: 2,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 3,
Type: TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 10,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 4,
Type: TRANSACTION_DB_TYPE_TRANSFER_IN,
TransactionTime: 3,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 5,
Type: TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 11,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 6,
Type: TRANSACTION_DB_TYPE_TRANSFER_OUT,
TransactionTime: 4,
},
})
sort.Sort(transactionSlice)
assert.Equal(t, int64(3), transactionSlice[0].TransactionId)
assert.Equal(t, int64(5), transactionSlice[1].TransactionId)
assert.Equal(t, int64(1), transactionSlice[2].TransactionId)
assert.Equal(t, int64(2), transactionSlice[3].TransactionId)
assert.Equal(t, int64(4), transactionSlice[4].TransactionId)
assert.Equal(t, int64(6), transactionSlice[5].TransactionId)
}
+6
View File
@@ -115,6 +115,12 @@ type TransactionModifyRequest struct {
GeoLocation *TransactionGeoLocationRequest `json:"geoLocation" binding:"omitempty"`
}
// TransactionImportRequest represents all parameters of transaction import request
type TransactionImportRequest struct {
Transactions []*TransactionCreateRequest `json:"transactions"`
ClientSessionId string `json:"clientSessionId"`
}
// TransactionCountRequest represents transaction count request
type TransactionCountRequest struct {
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
+7 -1
View File
@@ -138,6 +138,8 @@ const (
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
defaultImportFileMaxSize uint32 = 10485760 // 10MB
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
)
@@ -282,7 +284,9 @@ type Config struct {
MaxAvatarFileSize uint32
// Data
EnableDataExport bool
EnableDataExport bool
EnableDataImport bool
MaxImportFileSize uint32
// Notification
AfterRegisterNotification NotificationConfig
@@ -768,6 +772,8 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str
func loadDataConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableDataExport = getConfigItemBoolValue(configFile, sectionName, "enable_export", false)
config.EnableDataImport = getConfigItemBoolValue(configFile, sectionName, "enable_import", false)
config.MaxImportFileSize = getConfigItemUint32Value(configFile, sectionName, "max_import_file_size", defaultImportFileMaxSize)
return nil
}
+4 -4
View File
@@ -52,12 +52,12 @@ export default {
this.showState = true;
if (isString(text)) {
this.titleContent = this.$t(title);
this.textContent = this.$t(text);
this.titleContent = this.$t(title, options);
this.textContent = this.$t(text, options);
} else {
this.titleContent = this.$t('global.app.title');
this.textContent = this.$t(title);
options = text;
this.titleContent = this.$t('global.app.title');
this.textContent = this.$t(title, options);
}
if (options && options.color) {
+13 -5
View File
@@ -2,8 +2,8 @@
<div class="d-flex" :style="`min-width: ${minWidth}px`" v-if="minWidth"></div>
<v-slide-group class="slide-group-with-stepper mb-10 hidden-xs" show-arrows>
<v-slide-group-item :key="idx" v-for="(step, idx) in steps">
<div class="cursor-pointer mx-1"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx) }"
<div class="mx-1"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx), 'cursor-pointer': isClickable }"
@click="changeStep(step)">
<div class="d-flex align-center gap-x-2">
<div class="d-flex align-center gap-2">
@@ -23,8 +23,8 @@
</v-slide-group>
<v-slide-group class="slide-group-with-stepper mb-3 hidden-sm-and-up" direction="vertical">
<v-slide-group-item :key="idx" v-for="(step, idx) in steps">
<div class="cursor-pointer mx-1 mb-3"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx) }"
<div class="mx-1 mb-3"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx), 'cursor-pointer': isClickable }"
@click="changeStep(step)">
<div class="d-flex align-center gap-x-2">
<div class="d-flex align-center gap-2">
@@ -48,14 +48,22 @@ export default {
props: [
'steps',
'currentStep',
'clickable',
'minWidth'
],
emits: [
'step:change'
],
computed: {
isClickable() {
return this.clickable !== 'false' && this.clickable !== false;
}
},
methods: {
changeStep(step) {
this.$emit('step:change', step.name);
if (this.isClickable) {
this.$emit('step:change', step.name);
}
},
isStepActive(step) {
return this.currentStep === step.name;
+15 -1
View File
@@ -1,5 +1,19 @@
const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp';
const supportedImportFileTypes = [
{
type: 'ezbookkeeping_csv',
name: 'ezbookkeeping Data Export File (CSV)',
extensions: '.csv'
},
{
type: 'ezbookkeeping_tsv',
name: 'ezbookkeeping Data Export File (TSV)',
extensions: '.tsv'
}
];
export default {
supportedImageExtensions: supportedImageExtensions
supportedImageExtensions: supportedImageExtensions,
supportedImportFileTypes: supportedImportFileTypes
}
+2
View File
@@ -14,6 +14,7 @@ import { VBtnToggle } from 'vuetify/components/VBtnToggle';
import { VCard, VCardActions, VCardItem, VCardSubtitle, VCardText, VCardTitle } from 'vuetify/components/VCard';
import { VCheckbox, VCheckboxBtn } from 'vuetify/components/VCheckbox';
import { VChip } from 'vuetify/components/VChip';
import { VDataTable } from 'vuetify/components/VDataTable';
import { VDialog } from 'vuetify/components/VDialog';
import { VDivider } from 'vuetify/components/VDivider';
import { VExpansionPanel, VExpansionPanelText, VExpansionPanelTitle, VExpansionPanels } from 'vuetify/components/VExpansionPanel';
@@ -131,6 +132,7 @@ const vuetify = createVuetify({
VCheckbox,
VCheckboxBtn,
VChip,
VDataTable,
VDialog,
VDivider,
VExpansionPanel,
+18
View File
@@ -7,6 +7,7 @@ import timezoneConstants from '@/consts/timezone.js';
import currencyConstants from '@/consts/currency.js';
import colorConstants from '@/consts/color.js';
import accountConstants from '@/consts/account.js';
import fileConstants from '@/consts/file.js';
import categoryConstants from '@/consts/category.js';
import transactionConstants from '@/consts/transaction.js';
import templateConstants from '@/consts/template.js';
@@ -1252,6 +1253,22 @@ function getAllDisplayExchangeRates(exchangeRatesData, translateFn) {
return availableExchangeRates;
}
function getAllSupportedImportFileTypes(translateFn) {
const allSupportedImportFileTypes = [];
for (let i = 0; i < fileConstants.supportedImportFileTypes.length; i++) {
const fileType = fileConstants.supportedImportFileTypes[i];
allSupportedImportFileTypes.push({
type: fileType.type,
displayName: translateFn(fileType.name),
extensions: fileType.extensions
});
}
return allSupportedImportFileTypes;
}
function getEnableDisableOptions(translateFn) {
return [{
value: true,
@@ -1595,6 +1612,7 @@ export function i18nFunctions(i18nGlobal) {
getAllTransactionScheduledFrequencyTypes: () => getAllTransactionScheduledFrequencyTypes(i18nGlobal.t),
getAllTransactionDefaultCategories: (categoryType, locale) => getAllTransactionDefaultCategories(categoryType, locale, i18nGlobal.t),
getAllDisplayExchangeRates: (exchangeRatesData) => getAllDisplayExchangeRates(exchangeRatesData, i18nGlobal.t),
getAllSupportedImportFileTypes: () => getAllSupportedImportFileTypes(i18nGlobal.t),
getEnableDisableOptions: () => getEnableDisableOptions(i18nGlobal.t),
getCategorizedAccountsWithDisplayBalance: (allVisibleAccounts, showAccountBalance, defaultCurrency, settingsStore, userStore, exchangeRatesStore) => getCategorizedAccountsWithDisplayBalance(allVisibleAccounts, showAccountBalance, defaultCurrency, userStore, settingsStore, exchangeRatesStore, i18nGlobal.t),
joinMultiText: (textArray) => joinMultiText(textArray, i18nGlobal.t),
+4
View File
@@ -53,6 +53,10 @@ export function isDataExportingEnabled() {
return getServerSetting('e') === '1';
}
export function isDataImportingEnabled() {
return getServerSetting('i') === '1';
}
export function getMapProvider() {
return getServerSetting('m');
}
+12
View File
@@ -437,6 +437,18 @@ export default {
id
});
},
parseImportTransaction: ({ fileType, importFile }) => {
return axios.postForm('v1/transactions/parse_import.json', {
fileType: fileType,
file: importFile
});
},
importTransactions: ({ transactions, clientSessionId }) => {
return axios.post('v1/transactions/import.json', {
transactions: transactions,
clientSessionId: clientSessionId
});
},
uploadTransactionPicture: ({ pictureFile, clientSessionId }) => {
return axios.postForm('v1/transaction/pictures/upload.json', {
picture: pictureFile,
+27 -2
View File
@@ -79,6 +79,9 @@
"everyMultiDaysOfWeek": "Every {days}",
"everyMultiDaysOfMonth": "Every {days} of month",
"youHaveAccounts": "You have recorded {count} accounts",
"selectedCount": "Selected {count} of {totalCount}",
"confirmImportTransactions": "Are you sure you want to import {count} transactions?",
"importTransactionResult": "You have imported {count} transactions successfully.",
"accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.",
"resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}"
}
@@ -970,7 +973,6 @@
"system is busy": "System is busy",
"not supported": "Not supported",
"image type not supported": "Image type is not supported",
"import file type not supported": "Import file type is not supported",
"database operation failed": "Database operation failed",
"SMTP server is not enabled": "SMTP server is not enabled",
"incomplete or incorrect submission": "Incomplete or incorrect submission",
@@ -1068,6 +1070,9 @@
"cannot use hidden transaction tag": "You cannot use hidden transaction tag",
"transaction has too many tags": "There are too many tags in this transaction",
"transaction has too many pictures": "There are too many pictures in this transaction",
"import file type is empty": "Import file type is empty",
"import file type not supported": "Import file type is not supported",
"no data to import": "No data to import",
"exceed the maximum size of transaction picture file": "The uploaded transaction picture exceeds the maximum allowed file size",
"transaction category id is invalid": "Transaction category ID is invalid",
"transaction category not found": "Transaction category is not found",
@@ -1106,7 +1111,10 @@
"query items have invalid item": "There is invalid item in query items",
"parameter invalid": "Parameter is invalid",
"format invalid": "Format is invalid",
"number invalid": "Number is invalid"
"number invalid": "Number is invalid",
"no files uploaded": "No files uploaded",
"uploaded file is empty": "Uploaded file is empty",
"uploaded file size exceeds the maximum allowed size": "Uploaded file size exceeds the maximum allowed size"
},
"parameter": {
"id": "ID",
@@ -1163,6 +1171,7 @@
"Close": "Close",
"Submit": "Submit",
"Add": "Add",
"Import": "Import",
"Apply": "Apply",
"Save": "Save",
"Save Changes": "Save Changes",
@@ -1253,6 +1262,9 @@
"Select All": "Select All",
"Select None": "Select None",
"Invert Selection": "Invert Selection",
"Select All in This Page": "Select All in This Page",
"Select None in This Page": "Select None in This Page",
"Invert Selection in This Page": "Invert Selection in This Page",
"Back": "Back",
"Load More": "Load More",
"No data": "No data",
@@ -1463,6 +1475,19 @@
"Cannot Initialize Map": "Cannot Initialize Map",
"Unsupported Map Provider": "Unsupported Map Provider",
"Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.",
"Import Transactions": "Import Transactions",
"Upload File": "Upload File",
"Upload Transaction Data File": "Upload Transaction Data File",
"Check & Modify": "Check & Modify",
"Check and Modify Your Data": "Check and Modify Your Data",
"Data Import Completed": "Data Import Completed",
"File Type": "File Type",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)",
"Data File": "Data File",
"Click to select import file": "Click to select import file",
"No data to import": "No data to import",
"Unable to parse import file": "Unable to parse import file",
"Tags": "Tags",
"Your transaction description (optional)": "Your transaction description (optional)",
"Transaction category cannot be blank": "Transaction category cannot be blank",
+27 -2
View File
@@ -79,6 +79,9 @@
"everyMultiDaysOfWeek": "每{days}",
"everyMultiDaysOfMonth": "每月{days}",
"youHaveAccounts": "您已经记录了 {count} 个账户",
"selectedCount": "已选择 {count} / {totalCount}",
"confirmImportTransactions": "您确定要导入 {count} 个交易?",
"importTransactionResult": "您已经成功导入 {count} 个交易。",
"accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。",
"resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}"
}
@@ -970,7 +973,6 @@
"system is busy": "系统繁忙",
"not supported": "不支持",
"image type not supported": "图片类型不支持",
"import file type not supported": "导入文件类型不支持",
"database operation failed": "数据库操作失败",
"SMTP server is not enabled": "SMTP 服务器没有启用",
"incomplete or incorrect submission": "提交不完整或不正确",
@@ -1068,6 +1070,9 @@
"cannot use hidden transaction tag": "您不能使用隐藏的交易标签",
"transaction has too many tags": "交易中的标签过多",
"transaction has too many pictures": "交易中的图片过多",
"import file type is empty": "导入文件类型为空",
"import file type not supported": "导入文件类型不支持",
"no data to import": "没有可以导入的数据",
"exceed the maximum size of transaction picture file": "上传的交易图片超出了允许的最大文件大小",
"transaction category id is invalid": "交易分类ID无效",
"transaction category not found": "交易分类不存在",
@@ -1106,7 +1111,10 @@
"query items have invalid item": "请求项目中有非法项目",
"parameter invalid": "参数错误",
"format invalid": "格式错误",
"number invalid": "数字错误"
"number invalid": "数字错误",
"no files uploaded": "没有上传文件",
"uploaded file is empty": "上传的文件为空",
"uploaded file size exceeds the maximum allowed size": "上传的文件大小超出了允许的最大大小"
},
"parameter": {
"id": "ID",
@@ -1163,6 +1171,7 @@
"Close": "关闭",
"Submit": "提交",
"Add": "添加",
"Import": "导入",
"Apply": "应用",
"Save": "保存",
"Save Changes": "保存修改",
@@ -1253,6 +1262,9 @@
"Select All": "全部选择",
"Select None": "全部不选",
"Invert Selection": "反向选择",
"Select All in This Page": "本页全选",
"Select None in This Page": "本页不选",
"Invert Selection in This Page": "本页反选",
"Back": "返回",
"Load More": "加载更多",
"No data": "没有数据",
@@ -1463,6 +1475,19 @@
"Cannot Initialize Map": "无法初始化地图",
"Unsupported Map Provider": "不支持的地图提供方",
"Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "请刷新页面并重试。如果仍然显示错误,请确保正确设置了服务器地图设置。",
"Import Transactions": "导入交易",
"Upload File": "上传文件",
"Upload Transaction Data File": "上传交易数据文件",
"Check & Modify": "检查及修改",
"Check and Modify Your Data": "检查及修改您的数据",
"Data Import Completed": "数据导入完成",
"File Type": "文件类型",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)",
"Data File": "数据文件",
"Click to select import file": "点击选择导入文件",
"No data to import": "没有可以导入的数据",
"Unable to parse import file": "无法解析导入的文件",
"Tags": "标签",
"Your transaction description (optional)": "你的交易描述 (可选)",
"Transaction category cannot be blank": "交易分类不能为空",
+110 -25
View File
@@ -292,6 +292,36 @@ function fillTransactionObject(state, transaction, currentUtcOffset) {
return transaction;
}
function buildBasicSubmitTransaction(transaction, dummyTime) {
const submitTransaction = {
type: transaction.type,
time: dummyTime ? getActualUnixTimeForStore(transaction.time, transaction.utcOffset, getBrowserTimezoneOffsetMinutes()) : transaction.time,
sourceAccountId: transaction.sourceAccountId,
sourceAmount: transaction.sourceAmount,
destinationAccountId: '0',
destinationAmount: 0,
hideAmount: transaction.hideAmount,
tagIds: transaction.tagIds,
comment: transaction.comment,
geoLocation: transaction.geoLocation,
utcOffset: transaction.utcOffset
};
if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
submitTransaction.categoryId = transaction.expenseCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Income) {
submitTransaction.categoryId = transaction.incomeCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
submitTransaction.categoryId = transaction.transferCategory;
submitTransaction.destinationAccountId = transaction.destinationAccountId;
submitTransaction.destinationAmount = transaction.destinationAmount;
} else {
return null;
}
return submitTransaction;
}
export const useTransactionsStore = defineStore('transactions', {
state: () => ({
transactionsFilter: {
@@ -835,36 +865,16 @@ export const useTransactionsStore = defineStore('transactions', {
const settingsStore = useSettingsStore();
const exchangeRatesStore = useExchangeRatesStore();
const submitTransaction = {
type: transaction.type,
time: getActualUnixTimeForStore(transaction.time, transaction.utcOffset, getBrowserTimezoneOffsetMinutes()),
sourceAccountId: transaction.sourceAccountId,
sourceAmount: transaction.sourceAmount,
destinationAccountId: '0',
destinationAmount: 0,
hideAmount: transaction.hideAmount,
tagIds: transaction.tagIds,
comment: transaction.comment,
geoLocation: transaction.geoLocation,
utcOffset: transaction.utcOffset
};
const submitTransaction = buildBasicSubmitTransaction(transaction, true);
if (!submitTransaction) {
return Promise.reject('An error occurred');
}
if (clientSessionId) {
submitTransaction.clientSessionId = clientSessionId;
}
if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
submitTransaction.categoryId = transaction.expenseCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Income) {
submitTransaction.categoryId = transaction.incomeCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
submitTransaction.categoryId = transaction.transferCategory;
submitTransaction.destinationAccountId = transaction.destinationAccountId;
submitTransaction.destinationAmount = transaction.destinationAmount;
} else {
return Promise.reject('An error occurred');
}
if (transaction.pictures && transaction.pictures.length > 0) {
const pictureIds = [];
@@ -1004,6 +1014,81 @@ export const useTransactionsStore = defineStore('transactions', {
});
});
},
parseImportTransaction({ fileType, importFile }) {
return new Promise((resolve, reject) => {
services.parseImportTransaction({ fileType, importFile }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to parse import file' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('Unable to parse import file', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to parse import file' });
} else {
reject(error);
}
});
});
},
importTransactions({ transactions, clientSessionId }) {
const submitTransactions = [];
if (transactions) {
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
if (transaction.type === transactionConstants.allTransactionTypes.Income) {
transaction.incomeCategory = transaction.categoryId;
} else if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
transaction.expenseCategory = transaction.categoryId;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
transaction.transferCategory = transaction.categoryId;
}
const submitTransaction = buildBasicSubmitTransaction(transaction, false);
if (!submitTransaction) {
return Promise.reject('An error occurred');
}
submitTransactions.push(submitTransaction);
}
}
return new Promise((resolve, reject) => {
services.importTransactions({
transactions: submitTransactions,
clientSessionId: clientSessionId
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to import transactions' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('Unable to import transactions', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to import transactions' });
} else {
reject(error);
}
});
});
},
uploadTransactionPicture({ pictureFile, clientSessionId }) {
return new Promise((resolve, reject) => {
services.uploadTransactionPicture({ pictureFile, clientSessionId }).then(response => {
+11
View File
@@ -143,6 +143,17 @@ input[type=number] {
cursor: default !important;
}
.always-cursor-pointer,
.always-cursor-pointer.v-input.v-input--readonly input,
.always-cursor-pointer.v-input.v-input--readonly textarea,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-text-field__prefix,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-text-field__suffix,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-field__input,
.always-cursor-pointer.v-input.v-input--readonly .v-field.v-field,
.always-cursor-pointer.v-input.v-input--readonly .cursor-pointer {
cursor: pointer !important;
}
.v-table {
th {
background: rgb(var(--v-table-header-background)) !important;
@@ -53,6 +53,11 @@
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-3" color="default" variant="outlined"
:disabled="loading" @click="importTransaction"
v-if="isDataImportingEnabled">
{{ $t('Import') }}
</v-btn>
<v-btn density="compact" color="default" variant="text" size="24"
class="ml-2" :icon="true" :loading="loading" @click="reload">
<template #loader>
@@ -494,6 +499,7 @@
v-model:show="showCustomDateRangeDialog"
@dateRange:change="changeCustomDateFilter" />
<edit-dialog ref="editDialog" type="transaction" :persistent="true" />
<import-dialog ref="importDialog" :persistent="true" />
<v-dialog width="800" v-model="showFilterAccountDialog">
<account-filter-settings-card type="transactionListCurrent" :dialog-mode="true"
@@ -516,6 +522,7 @@
<script>
import EditDialog from './list/dialogs/EditDialog.vue';
import ImportDialog from './list/dialogs/ImportDialog.vue';
import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue';
import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue';
import TransactionTagFilterSettingsCard from '@/views/desktop/common/cards/TransactionTagFilterSettingsCard.vue';
@@ -562,6 +569,7 @@ import {
} from '@/lib/category.js';
import { getUnifiedSelectedAccountsCurrencyOrDefaultCurrency } from '@/lib/account.js';
import { getTransactionDisplayAmount } from '@/lib/transaction.js';
import { isDataImportingEnabled } from '@/lib/server_settings.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import {
@@ -585,6 +593,7 @@ export default {
components: {
TransactionTagFilterSettingsCard,
EditDialog,
ImportDialog,
AccountFilterSettingsCard,
CategoryFilterSettingsCard
},
@@ -658,6 +667,9 @@ export default {
return true;
},
isDataImportingEnabled() {
return isDataImportingEnabled();
},
currentTimezoneOffsetMinutes() {
return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone);
},
@@ -1376,6 +1388,21 @@ export default {
}
});
},
importTransaction() {
const self = this;
self.$refs.importDialog.open().then(result => {
if (result && result.message) {
self.$refs.snackbar.showMessage(result.message);
}
self.reload(false);
}).catch(error => {
if (error) {
self.$refs.snackbar.showError(error);
}
});
},
show(transaction) {
const self = this;
@@ -0,0 +1,931 @@
<template>
<v-dialog width="1000" :persistent="!!persistent" v-model="showState">
<v-card class="pa-6 pa-sm-10 pa-md-12">
<template #title>
<div class="d-flex align-center justify-center">
<div class="d-flex w-100 align-center justify-center">
<h4 class="text-h4">{{ $t('Import Transactions') }}</h4>
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</div>
</div>
</template>
<div class="mt-4 cursor-default">
<steps-bar min-width="700" :clickable="false" :steps="allSteps" :current-step="currentStep" />
</div>
<v-window class="disable-tab-transition" v-model="currentStep">
<v-window-item value="uploadFile">
<v-row>
<v-col cols="12" md="12">
<v-select
item-title="displayName"
item-value="type"
:disabled="submitting"
:label="$t('File Type')"
:placeholder="$t('File Type')"
:items="allSupportedImportFileTypes"
v-model="fileType"
/>
</v-col>
<v-col cols="12" md="12">
<v-text-field
readonly
persistent-placeholder
type="text"
class="always-cursor-pointer"
:disabled="submitting"
:label="$t('Data File')"
:placeholder="$t('Click to select import file')"
v-model="fileName"
@click="showOpenFileDialog"
/>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="checkData">
<v-data-table
fixed-header
fixed-footer
show-select
show-expand
class="import-transaction-table"
density="compact"
item-value="index"
:height="importTransactionsTableHeight"
:headers="importTransactionHeaders"
:items="importTransactions"
:no-data-text="$t('No data to import')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
v-model:expanded="expandedTransactions"
>
<template #header.data-table-select>
<v-checkbox readonly class="cursor-pointer"
density="compact" width="28"
:indeterminate="anyButNotAllTransactionSelected"
v-model="allTransactionSelected"
>
<v-menu activator="parent" location="bottom">
<v-list>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All')"
@click="selectAll"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None')"
@click="selectNone"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection')"
@click="selectInvert"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All in This Page')"
@click="selectAllInThisPage"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None in This Page')"
@click="selectNoneInThisPage"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection in This Page')"
@click="selectInvertInThisPage"></v-list-item>
</v-list>
</v-menu>
</v-checkbox>
</template>
<template #item.data-table-select="{ item }">
<v-checkbox density="compact"
:disabled="!item.valid"
v-model="item.selected"></v-checkbox>
</template>
<template #item.data-table-expand="{ item, internalItem, toggleExpand }">
<v-icon size="small" :class="{ 'text-error': !item.valid }"
:icon="icons.edit" @click="toggleExpand(internalItem)">
</v-icon>
<v-tooltip activator="parent">{{ $t('Edit') }}</v-tooltip>
</template>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ml-1" variant="flat" color="secondary" size="x-small"
v-if="item.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(item) }}</v-chip>
</template>
<template #item.type="{ value }">
<v-chip label color="secondary" variant="outlined" size="x-small" v-if="value === allTransactionTypes.ModifyBalance">{{ $t('Modify Balance') }}</v-chip>
<v-chip label class="text-income" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Income">{{ $t('Income') }}</v-chip>
<v-chip label class="text-expense" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Expense">{{ $t('Expense') }}</v-chip>
<v-chip label color="primary" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Transfer">{{ $t('Transfer') }}</v-chip>
<v-chip label color="default" variant="outlined" size="x-small" v-else>{{ $t('Unknown') }}</v-chip>
</template>
<template #item.categoryId="{ item }">
<div class="d-flex align-center">
<span v-if="item.type === allTransactionTypes.ModifyBalance">-</span>
<ItemIcon size="24px" icon-type="category"
:icon-id="allCategoriesMap[item.categoryId].icon"
:color="allCategoriesMap[item.categoryId].color"
v-if="item.type !== allTransactionTypes.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]"></ItemIcon>
<span class="ml-2" v-if="item.type !== allTransactionTypes.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]">
{{ allCategoriesMap[item.categoryId].name }}
</span>
<div class="text-error font-italic" v-else-if="item.type !== allTransactionTypes.ModifyBalance && (!item.categoryId || item.categoryId === '0' || !allCategoriesMap[item.categoryId])">
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalCategoryName }}</span>
</div>
</div>
</template>
<template #item.sourceAmount="{ item }">
<span>{{ getTransactionDisplayAmount(item) }}</span>
<v-icon class="mx-1" size="13" :icon="icons.arrowRight" v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId"></v-icon>
<span v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId">{{ getTransactionDisplayDestinationAmount(item) }}</span>
</template>
<template #item.sourceAccountId="{ item }">
<div class="d-flex align-center">
<span v-if="item.sourceAccountId && item.sourceAccountId !== '0' && allAccountsMap[item.sourceAccountId]">{{ allAccountsMap[item.sourceAccountId].name }}</span>
<div class="text-error font-italic" v-else>
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalSourceAccountName }}</span>
</div>
<v-icon class="mx-1" size="13" :icon="icons.arrowRight" v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId"></v-icon>
<span v-if="item.type === allTransactionTypes.Transfer && item.destinationAccountId && item.destinationAccountId !== '0' && allAccountsMap[item.destinationAccountId]">{{allAccountsMap[item.destinationAccountId].name }}</span>
<div class="text-error font-italic" v-else-if="item.type === allTransactionTypes.Transfer && (!item.destinationAccountId || item.destinationAccountId === '0' || !allAccountsMap[item.destinationAccountId])">
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalDestinationAccountName }}</span>
</div>
</div>
</template>
<template #item.geoLocation="{ item }">
<span class="cursor-pointer" v-if="item.geoLocation">{{ `(${item.geoLocation.longitude}, ${item.geoLocation.latitude})` }}</span>
<span class="cursor-pointer" v-else-if="!item.geoLocation">{{ $t('None') }}</span>
</template>
<template #item.tagIds="{ item }">
<v-chip class="transaction-tag" size="small"
:class="{ 'font-italic': !tagId || tagId === '0' || !allTagsMap[tagId] }"
:prepend-icon="tagId && tagId !== '0' && allTagsMap[tagId] ? icons.tag : icons.alert"
:color="tagId && tagId !== '0' && allTagsMap[tagId] ? 'default' : 'error'"
:text="tagId && tagId !== '0' && allTagsMap[tagId] ? allTagsMap[tagId].name : item.originalTagNames[index]"
:key="tagId"
v-for="(tagId, index) in item.tagIds"/>
<v-chip class="transaction-tag" size="small"
:text="$t('None')"
v-if="!item.tagIds || !item.tagIds.length"/>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length">
<v-row class="py-4" style="width: 400px">
<v-col cols="12" v-if="item.type === allTransactionTypes.Expense">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableExpenseCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Expense])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Expense])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Expense]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Income">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableIncomeCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Income])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Income])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Income]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Transfer">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableTransferCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Transfer])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Transfer])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Transfer]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12">
<two-column-select primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="loading || submitting || !allVisibleAccounts.length"
:custom-selection-primary-text="getSourceAccountDisplayName(item)"
:label="getSourceAccountTitle(item)"
:placeholder="getSourceAccountTitle(item)"
:items="categorizedAccounts"
v-model="item.sourceAccountId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Transfer">
<two-column-select primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="loading || submitting || !allVisibleAccounts.length"
:custom-selection-primary-text="getDestinationAccountDisplayName(item)"
:label="$t('Destination Account')"
:placeholder="$t('Destination Account')"
:items="categorizedAccounts"
v-model="item.destinationAccountId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12">
<v-autocomplete
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
:disabled="loading || submitting"
:label="$t('Tags')"
:placeholder="$t('None')"
:items="allTags"
:no-data-text="$t('No available tag')"
v-model="item.tagIds"
@update:model-value="updateTransactionData(item)"
>
<template #chip="{ props, index }">
<v-chip :class="{ 'font-italic': !isTagValid(item, index) }"
:prepend-icon="isTagValid(item, index) ? icons.tag : icons.alert"
:color="isTagValid(item, index) ? 'default' : 'error'"
:text="isTagValid(item, index) ? allTagsMap[item.tagIds[index]].name : item.originalTagNames[index]"
v-bind="props"/>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="!item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="icons.tag"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-col>
</v-row>
</td>
</tr>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2"
v-if="importTransactions && importTransactions.length > 10">
<span>{{ $t('format.misc.selectedCount', { count: selectedImportTransactionCount, totalCount: importTransactions.length }) }}</span>
<v-spacer/>
<span>{{ $t('Transactions Per Page') }}</span>
<v-select class="ml-2" density="compact" max-width="100"
item-title="title"
item-value="value"
:disabled="loading"
:items="importTransactionsTablePageOptions"
v-model="countPerPage"
/>
<v-pagination density="compact"
:total-visible="6"
:length="totalPageCount"
v-model="currentPage"></v-pagination>
</div>
</template>
</v-data-table>
</v-window-item>
<v-window-item value="finalResult">
<h4 class="text-h4 mb-1">{{ $t('Data Import Completed') }}</h4>
<p class="my-5">{{ $t('format.misc.importTransactionResult', { count: importedCount }) }}</p>
</v-window-item>
</v-window>
<div class="d-flex justify-sm-space-between gap-4 flex-wrap justify-center mt-10">
<v-btn color="secondary" variant="tonal" :disabled="loading || submitting"
:prepend-icon="icons.previous" @click="cancel(false)"
v-if="currentStep !== 'finalResult'">{{ $t('Cancel') }}</v-btn>
<v-btn color="primary" :disabled="loading || submitting || !importFile"
:append-icon="!submitting ? icons.next : null" @click="parseData"
v-if="currentStep === 'uploadFile'">
{{ $t('Next') }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="teal" :disabled="submitting || selectedImportTransactionCount < 1"
:append-icon="!submitting ? icons.next : null" @click="submit"
v-if="currentStep === 'checkData'">
{{ $t('Import') }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal"
:append-icon="icons.complete"
@click="cancel(true)"
v-if="currentStep === 'finalResult'">{{ $t('Close') }}</v-btn>
</div>
</v-card>
</v-dialog>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
<input ref="fileInput" type="file" style="display: none" :accept="supportedImportFileExtensions" @change="setImportFile($event)" />
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useAccountsStore } from '@/stores/account.js';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import { useTransactionTagsStore } from '@/stores/transactionTag.js';
import { useTransactionsStore } from '@/stores/transaction.js';
import { useOverviewStore } from '@/stores/overview.js';
import { useStatisticsStore } from '@/stores/statistics.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import categoryConstants from '@/consts/category.js';
import transactionConstants from '@/consts/transaction.js';
import { getNameByKeyValue } from '@/lib/common.js';
import { generateRandomUUID } from '@/lib/misc.js';
import logger from '@/lib/logger.js';
import {
parseDateFromUnixTime,
getUnixTime,
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes
} from '@/lib/datetime.js';
import {
getTransactionPrimaryCategoryName,
getTransactionSecondaryCategoryName,
getFirstAvailableCategoryId
} from '@/lib/category.js';
import {
mdiClose,
mdiArrowRight,
mdiCheck,
mdiSelectAll,
mdiSelect,
mdiSelectInverse,
mdiPencilOutline,
mdiAlertOutline,
mdiPound
} from '@mdi/js';
export default {
props: [
'persistent',
'show'
],
expose: [
'open'
],
data() {
return {
showState: false,
clientSessionId: '',
currentStep: 'uploadFile',
fileType: 'ezbookkeeping_csv',
importFile: null,
importTransactions: null,
expandedTransactions: [],
currentPage: 1,
countPerPage: 10,
importedCount: null,
loading: true,
submitting: false,
resolve: null,
reject: null,
icons: {
previous: mdiClose,
next: mdiArrowRight,
complete: mdiCheck,
select: mdiSelect,
selectAll: mdiSelectAll,
selectNone: mdiSelect,
selectInverse: mdiSelectInverse,
edit: mdiPencilOutline,
arrowRight: mdiArrowRight,
alert: mdiAlertOutline,
tag: mdiPound
}
}
},
computed: {
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useTransactionTagsStore, useTransactionsStore, useOverviewStore, useStatisticsStore, useExchangeRatesStore),
defaultCurrency() {
return this.userStore.currentUserDefaultCurrency;
},
allSteps() {
return [
{
name: 'uploadFile',
title: this.$t('Upload File'),
subTitle: this.$t('Upload Transaction Data File')
},
{
name: 'checkData',
title: this.$t('Check & Modify'),
subTitle: this.$t('Check and Modify Your Data')
},
{
name: 'finalResult',
title: this.$t('Complete'),
subTitle: this.$t('Data Import Completed')
}
];
},
allSupportedImportFileTypes() {
return this.$locale.getAllSupportedImportFileTypes();
},
allTransactionTypes() {
return transactionConstants.allTransactionTypes;
},
allCategoryTypes() {
return categoryConstants.allCategoryTypes;
},
allAccounts() {
return this.accountsStore.allPlainAccounts;
},
allVisibleAccounts() {
return this.accountsStore.allVisiblePlainAccounts;
},
categorizedAccounts() {
return this.$locale.getCategorizedAccountsWithDisplayBalance(this.allVisibleAccounts, this.showAccountBalance, this.defaultCurrency, this.settingsStore, this.userStore, this.exchangeRatesStore);
},
allAccountsMap() {
return this.accountsStore.allAccountsMap;
},
allCategories() {
return this.transactionCategoriesStore.allTransactionCategories;
},
allCategoriesMap() {
return this.transactionCategoriesStore.allTransactionCategoriesMap;
},
allTags() {
return this.transactionTagsStore.allTransactionTags;
},
allTagsMap() {
return this.transactionTagsStore.allTransactionTagsMap;
},
hasAvailableExpenseCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Expense] || !this.allCategories[this.allCategoryTypes.Expense].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Expense]);
return firstAvailableCategoryId !== '';
},
hasAvailableIncomeCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Income] || !this.allCategories[this.allCategoryTypes.Income].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Income]);
return firstAvailableCategoryId !== '';
},
hasAvailableTransferCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Transfer] || !this.allCategories[this.allCategoryTypes.Transfer].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Transfer]);
return firstAvailableCategoryId !== '';
},
showAccountBalance() {
return this.settingsStore.appSettings.showAccountBalance;
},
currentTimezoneOffsetMinutes() {
return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone);
},
supportedImportFileExtensions() {
return getNameByKeyValue(this.allSupportedImportFileTypes, this.fileType, 'type', 'extensions');
},
fileName: {
get: function () {
if (this.importFile == null) {
return '';
} else {
return this.importFile.name;
}
}
},
importTransactionsTableHeight() {
if (this.countPerPage <= 10 || !this.importTransactions || this.importTransactions.length <= 10) {
return undefined;
} else {
return 400;
}
},
importTransactionHeaders() {
return [
{ value: 'valid', key: 'data-table-expand', sortable: true, nowrap: true },
{ value: 'time', title: this.$t('Transaction Time'), sortable: true, nowrap: true },
{ value: 'type', title: this.$t('Type'), sortable: true, nowrap: true },
{ value: 'categoryId', title: this.$t('Category'), sortable: true, nowrap: true },
{ value: 'sourceAmount', title: this.$t('Amount'), sortable: true, nowrap: true },
{ value: 'sourceAccountId', title: this.$t('Account'), sortable: true, nowrap: true },
{ value: 'geoLocation', title: this.$t('Geographic Location'), sortable: true, nowrap: true },
{ value: 'tagIds', title: this.$t('Tags'), sortable: true, nowrap: true },
{ value: 'comment', title: this.$t('Description'), sortable: true, nowrap: true },
];
},
importTransactionsTablePageOptions() {
const pageOptions = [];
if (!this.importTransactions || this.importTransactions.length < 1) {
pageOptions.push({ value: -1, title: this.$t('All') });
return pageOptions;
}
const availableCountPerPage = [ 5, 10, 15, 20, 25, 30, 50 ];
for (let i = 0; i < availableCountPerPage.length; i++) {
const count = availableCountPerPage[i];
if (this.importTransactions.length < count) {
break;
}
pageOptions.push({ value: count, title: count.toString() });
}
pageOptions.push({ value: -1, title: this.$t('All') });
return pageOptions;
},
totalPageCount() {
if (!this.importTransactions || this.importTransactions.length < 1) {
return 1;
}
return Math.ceil(this.importTransactions.length / this.countPerPage);
},
selectedImportTransactionCount() {
let count = 0;
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].selected) {
count++;
}
}
return count;
},
anyButNotAllTransactionSelected: {
get: function () {
return this.selectedImportTransactionCount > 0 && this.selectedImportTransactionCount !== this.importTransactions.length;
}
},
allTransactionSelected: {
get: function () {
return this.selectedImportTransactionCount === this.importTransactions.length;
}
}
},
watch: {
fileType: function () {
this.importFile = null;
this.importTransactions = null;
this.expandedTransactions = [];
this.currentPage = 1;
this.countPerPage = 10;
}
},
methods: {
open() {
const self = this;
self.fileType = 'ezbookkeeping_csv';
self.currentStep = 'uploadFile';
self.importFile = null;
self.importTransactions = null;
self.expandedTransactions = [];
self.currentPage = 1;
self.countPerPage = 10;
self.showState = true;
self.clientSessionId = generateRandomUUID();
const promises = [
self.accountsStore.loadAllAccounts({ force: false }),
self.transactionCategoriesStore.loadAllCategories({ force: false }),
self.transactionTagsStore.loadAllTags({ force: false })
];
Promise.all(promises).then(function () {
self.loading = false;
}).catch(error => {
logger.error('failed to load essential data for importing transaction', error);
self.loading = false;
self.showState = false;
if (!error.processed) {
if (self.reject) {
self.reject(error);
}
}
});
return new Promise((resolve, reject) => {
self.resolve = resolve;
self.reject = reject;
});
},
showOpenFileDialog() {
if (this.submitting) {
return;
}
this.$refs.fileInput.click();
},
setImportFile(event) {
if (!event || !event.target || !event.target.files || !event.target.files.length) {
return;
}
this.importFile = event.target.files[0];
event.target.value = null;
},
parseData() {
const self = this;
self.submitting = true;
self.transactionsStore.parseImportTransaction({
fileType: self.fileType,
importFile: self.importFile
}).then(response => {
const parsedTransactions = response.items;
if (parsedTransactions) {
for (let i = 0; i < parsedTransactions.length; i++) {
const transaction = parsedTransactions[i];
transaction.index = i;
transaction.selected = false;
transaction.valid = self.isTransactionValid(transaction);
}
}
self.importTransactions = parsedTransactions;
self.expandedTransactions = [];
self.currentPage = 1;
if (self.importTransactions && self.importTransactions.length >= 0 && self.importTransactions.length < 10) {
self.countPerPage = -1;
} else {
self.countPerPage = 10;
}
self.currentStep = 'checkData';
self.submitting = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
submit() {
const self = this;
const transactions = [];
for (let i = 0; i < self.importTransactions.length; i++) {
const transaction = self.importTransactions[i];
if (transaction.valid && transaction.selected) {
transactions.push(transaction);
}
}
if (transactions.length < 1) {
self.$refs.snackbar.showError('No data to import');
return;
}
self.$refs.confirmDialog.open('format.misc.confirmImportTransactions', {
count: transactions.length
}).then(() => {
self.submitting = true;
self.transactionsStore.importTransactions({
transactions: transactions,
clientSessionId: self.clientSessionId
}).then(response => {
self.importedCount = response;
self.currentStep = 'finalResult';
self.accountsStore.updateAccountListInvalidState(true);
self.transactionsStore.updateTransactionListInvalidState(true);
self.overviewStore.updateTransactionOverviewInvalidState(true);
self.statisticsStore.updateTransactionStatisticsInvalidState(true);
self.submitting = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
cancel(completed) {
if (completed) {
if (this.resolve) {
this.resolve();
}
} else {
if (this.reject) {
this.reject();
}
}
this.showState = false;
},
selectAll() {
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].valid) {
this.importTransactions[i].selected = true;
}
}
},
selectNone() {
for (let i = 0; i < this.importTransactions.length; i++) {
this.importTransactions[i].selected = false;
}
},
selectInvert() {
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].valid || this.importTransactions[i].selected) {
this.importTransactions[i].selected = !this.importTransactions[i].selected;
}
}
},
selectAllInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i] && this.importTransactions[i].valid) {
this.importTransactions[i].selected = true;
}
}
},
selectNoneInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i]) {
this.importTransactions[i].selected = false;
}
}
},
selectInvertInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i] && (this.importTransactions[i].valid || this.importTransactions[i].selected)) {
this.importTransactions[i].selected = !this.importTransactions[i].selected;
}
}
},
updateTransactionData(transaction) {
transaction.valid = this.isTransactionValid(transaction);
},
isTransactionValid(transaction) {
if (!transaction) {
return false;
}
if (transaction.type !== this.allTransactionTypes.ModifyBalance && (!transaction.categoryId || transaction.categoryId === '0')) {
return false;
}
if (!transaction.sourceAccountId || transaction.sourceAccountId === '0') {
return false;
}
if (transaction.type === this.allTransactionTypes.Transfer && (!transaction.destinationAccountId || transaction.destinationAccountId === '0')) {
return false;
}
if (transaction.tagIds && transaction.tagIds.length) {
for (let j = 0; j < transaction.tagIds.length; j++) {
if (!transaction.tagIds[j] || transaction.tagIds[j] === '0') {
return false;
}
}
}
return true;
},
isTagValid(transaction, tagIndex) {
if (!transaction || !transaction.tagIds || !transaction.tagIds[tagIndex]) {
return false;
}
if (transaction.tagIds[tagIndex] === '0') {
return false;
}
const tagId = transaction.tagIds[tagIndex];
return !!this.allTagsMap[tagId];
},
getDisplayDateTime(transaction) {
const transactionTime = getUnixTime(parseDateFromUnixTime(transaction.time, transaction.utcOffset, this.currentTimezoneOffsetMinutes));
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, transactionTime);
},
getDisplayTimezone(transaction) {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
},
getTransactionDisplayAmount(transaction) {
let currency = transaction.originalSourceAccountCurrency || this.userStore.currentUserDefaultCurrency;
if (transaction.sourceAccountId && transaction.sourceAccountId !== '0' && this.allAccountsMap[transaction.sourceAccountId]) {
currency = this.allAccountsMap[transaction.sourceAccountId].currency;
}
return this.getDisplayCurrency(transaction.sourceAmount, currency);
},
getTransactionDisplayDestinationAmount(transaction) {
if (transaction.type !== this.allTransactionTypes.Transfer) {
return '-';
}
let currency = transaction.originalDestinationAccountCurrency || this.userStore.currentUserDefaultCurrency;
if (transaction.destinationAccountId && transaction.destinationAccountId !== '0' && this.allAccountsMap[transaction.destinationAccountId]) {
currency = this.allAccountsMap[transaction.destinationAccountId].currency;
}
return this.getDisplayCurrency(transaction.destinationAmount, currency);
},
getPrimaryCategoryName(categoryId, allCategories) {
return getTransactionPrimaryCategoryName(categoryId, allCategories);
},
getSecondaryCategoryName(categoryId, allCategories) {
return getTransactionSecondaryCategoryName(categoryId, allCategories);
},
getSourceAccountTitle(transaction) {
if (transaction.type === this.allTransactionTypes.Expense || transaction.type === this.allTransactionTypes.Income) {
return this.$t('Account');
} else if (transaction.type === this.allTransactionTypes.Transfer) {
return this.$t('Source Account');
} else {
return this.$t('Account');
}
},
getSourceAccountDisplayName(transaction) {
if (transaction.sourceAccountId) {
return getNameByKeyValue(this.allAccounts, transaction.sourceAccountId, 'id', 'name');
} else {
return this.$t('None');
}
},
getDestinationAccountDisplayName(transaction) {
if (transaction.destinationAccountId) {
return getNameByKeyValue(this.allAccounts, transaction.destinationAccountId, 'id', 'name');
} else {
return this.$t('None');
}
}
}
}
</script>
<style>
.import-transaction-table .v-chip.transaction-tag {
margin-right: 4px;
margin-top: 2px;
margin-bottom: 2px;
}
.import-transaction-table .v-chip.transaction-tag > .v-chip__content {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>