support importing transaction in frontend
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +25,8 @@ const maximumPicturesCountOfTransaction = 10
|
||||
type TransactionsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
ezBookKeepingCsvConverter converters.TransactionDataConverter
|
||||
ezBookKeepingTsvConverter converters.TransactionDataConverter
|
||||
transactions *services.TransactionService
|
||||
transactionCategories *services.TransactionCategoryService
|
||||
transactionTags *services.TransactionTagService
|
||||
@@ -40,6 +44,8 @@ var (
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter,
|
||||
ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter,
|
||||
transactions: services.Transactions,
|
||||
transactionCategories: services.TransactionCategories,
|
||||
transactionTags: services.TransactionTags,
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
account, exists := accountMap[accountName]
|
||||
|
||||
if !exists {
|
||||
currency := user.DefaultCurrency
|
||||
accountCurrency := user.DefaultCurrency
|
||||
|
||||
if accountCurrencyColumnExists {
|
||||
currency = dataRow.GetData(accountCurrencyColumnIdx)
|
||||
accountCurrency = 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)
|
||||
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 = c.createNewAccountModel(user.Uid, accountName, currency)
|
||||
account, exists := accountMap[accountName]
|
||||
|
||||
if !exists {
|
||||
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
|
||||
}
|
||||
|
||||
account2, exists := accountMap[account2Name]
|
||||
account2Currency = user.DefaultCurrency
|
||||
|
||||
if !exists {
|
||||
currency := user.DefaultCurrency
|
||||
if account2CurrencyColumnExists {
|
||||
account2Currency = dataRow.GetData(account2CurrencyColumnIdx)
|
||||
|
||||
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)
|
||||
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 = c.createNewAccountModel(user.Uid, account2Name, currency)
|
||||
account2, exists := accountMap[account2Name]
|
||||
|
||||
if !exists {
|
||||
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,7 +355,8 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
|
||||
description = dataRow.GetData(descriptionColumnIdx)
|
||||
}
|
||||
|
||||
transaction := &models.Transaction{
|
||||
transaction := &models.ImportTransaction{
|
||||
Transaction: &models.Transaction{
|
||||
Uid: user.Uid,
|
||||
Type: transactionDbType,
|
||||
CategoryId: categoryId,
|
||||
@@ -354,6 +371,14 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,4 @@ var (
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -138,6 +138,8 @@ const (
|
||||
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
|
||||
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
|
||||
|
||||
defaultImportFileMaxSize uint32 = 10485760 // 10MB
|
||||
|
||||
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
|
||||
)
|
||||
|
||||
@@ -283,6 +285,8 @@ type Config struct {
|
||||
|
||||
// Data
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
if (this.isClickable) {
|
||||
this.$emit('step:change', step.name);
|
||||
}
|
||||
},
|
||||
isStepActive(step) {
|
||||
return this.currentStep === step.name;
|
||||
|
||||
+16
-2
@@ -1,5 +1,19 @@
|
||||
const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp';
|
||||
|
||||
export default {
|
||||
supportedImageExtensions: supportedImageExtensions
|
||||
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,
|
||||
supportedImportFileTypes: supportedImportFileTypes
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -53,6 +53,10 @@ export function isDataExportingEnabled() {
|
||||
return getServerSetting('e') === '1';
|
||||
}
|
||||
|
||||
export function isDataImportingEnabled() {
|
||||
return getServerSetting('i') === '1';
|
||||
}
|
||||
|
||||
export function getMapProvider() {
|
||||
return getServerSetting('m');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user