support batch update categories for transactions in insights explorer
Some checks are pending
Docker Snapshot / build (push) Waiting to run

This commit is contained in:
MaysWind 2026-04-20 01:01:25 +08:00
parent f56b5c471d
commit 9c87436a36
32 changed files with 1166 additions and 179 deletions

View file

@ -393,6 +393,7 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/batch_update/category.json", bindApi(api.Transactions.TransactionBatchUpdateCategoriesHandler))
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))

View file

@ -1338,6 +1338,105 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return newTransactionResp, nil
}
// TransactionBatchUpdateCategoriesHandler batch updates categories of transactions by request parameters for current user
func (a *TransactionsApi) TransactionBatchUpdateCategoriesHandler(c *core.WebContext) (any, *errs.Error) {
var transactionBatchUpdateReq models.TransactionBatchUpdateCategoryRequest
err := c.ShouldBindJSON(&transactionBatchUpdateReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
clientTimezone, err := c.GetClientTimezone()
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] cannot get client timezone, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
transactionIds, err := utils.StringArrayToInt64Array(transactionBatchUpdateReq.TransactionIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] parse transaction ids failed, because %s", err.Error())
return nil, errs.ErrTransactionIdInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
category, err := a.transactionCategories.GetCategoryByCategoryId(c, uid, transactionBatchUpdateReq.CategoryId)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", transactionBatchUpdateReq.CategoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction category \"id:%d\" is not a sub category", category.CategoryId)
return nil, errs.ErrCannotUsePrimaryCategoryForTransaction
}
var expectedTransactionType models.TransactionDbType
if category.Type == models.CATEGORY_TYPE_EXPENSE {
expectedTransactionType = models.TRANSACTION_DB_TYPE_EXPENSE
} else if category.Type == models.CATEGORY_TYPE_INCOME {
expectedTransactionType = models.TRANSACTION_DB_TYPE_INCOME
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
expectedTransactionType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
}
transactions, err := a.transactions.GetTransactionsByTransactionIds(c, uid, transactionIds)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to get transactions for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allTransactionIds := make([]int64, 0, len(transactions))
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
if transaction.Type != expectedTransactionType {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" type is not expected type \"%d\" for user \"uid:%d\"", transaction.TransactionId, expectedTransactionType, uid)
return nil, errs.ErrTransactionTypeInvalid
}
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, clientTimezone)
if !transactionEditable {
log.Warnf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] transaction \"id:%d\" is not editable for user \"uid:%d\"", transaction.TransactionId, uid)
return nil, errs.ErrCannotModifyTransactionWithThisTransactionTime
}
allTransactionIds = append(allTransactionIds, transaction.TransactionId)
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
allTransactionIds = append(allTransactionIds, transaction.RelatedId)
}
}
err = a.transactions.BatchUpdateTransactionsCategory(c, uid, allTransactionIds, category.CategoryId)
if err != nil {
log.Errorf(c, "[transactions.TransactionBatchUpdateCategoriesHandler] failed to batch update transactions category for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionBatchUpdateCategoriesHandler] user \"uid:%d\" has batch updated category of %d transactions successfully", uid, len(transactionBatchUpdateReq.TransactionIds))
return true, nil
}
// TransactionMoveAllBetweenAccountsHandler moves all transactions from one account to another account for current user
func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionMoveReq models.TransactionMoveBetweenAccountsRequest

View file

@ -325,6 +325,12 @@ type TransactionGetRequest struct {
TrimTag bool `form:"trim_tag"`
}
// TransactionBatchUpdateCategoryRequest represents all parameters of transaction batch update category request
type TransactionBatchUpdateCategoryRequest struct {
TransactionIds []string `json:"transactionIds,string" binding:"required"`
CategoryId int64 `json:"categoryId,string" binding:"required"`
}
// TransactionMoveBetweenAccountsRequest represents all parameters of moving all transactions between accounts request
type TransactionMoveBetweenAccountsRequest struct {
FromAccountId int64 `json:"fromAccountId,string" binding:"required,min=1"`

View file

@ -434,6 +434,22 @@ func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid i
return transaction, nil
}
// GetTransactionsByTransactionIds returns transaction models according to transaction ids
func (s *TransactionService) GetTransactionsByTransactionIds(c core.Context, uid int64, transactionIds []int64) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
if len(transactionIds) <= 0 {
return nil, errs.ErrTransactionIdInvalid
}
var transactions []*models.Transaction
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).In("transaction_id", transactionIds).Find(&transactions)
return transactions, err
}
// GetAllTransactionCount returns total count of transactions
func (s *TransactionService) GetAllTransactionCount(c core.Context, uid int64) (int64, error) {
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "")
@ -1322,6 +1338,42 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
return nil
}
// BatchUpdateTransactionsCategory batch updates the categories of transactions
func (s *TransactionService) BatchUpdateTransactionsCategory(c core.Context, uid int64, transactionIds []int64, newCategoryId int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
if len(transactionIds) < 1 {
return errs.ErrTransactionIdInvalid
}
if newCategoryId <= 0 {
return errs.ErrTransactionCategoryIdInvalid
}
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
now := time.Now().Unix()
updateModel := &models.Transaction{
CategoryId: newCategoryId,
UpdatedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.Cols("category_id", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(updateModel)
if err != nil {
return err
} else if updatedRows < int64(len(uniqueTransactionIds)) {
return errs.ErrTransactionNotFound
}
return err
})
}
// MoveAllTransactionsBetweenAccounts moves all transactions from one account to another account, and combine balance modification transactions if necessary
func (s *TransactionService) MoveAllTransactionsBetweenAccounts(c core.Context, uid int64, fromAccountId int64, toAccountId int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid

View file

@ -191,6 +191,21 @@ export function getObjectOwnFieldCount(object: object): number {
return count;
}
export function getObjectOwnFieldWithValueCount(object: object, value: unknown): number {
let count = 0;
if (!object || !isObject(object)) {
return count;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of keysIfValueEquals(object, value)) {
count++;
}
return count;
}
export function replaceAll(value: string, originalValue: string, targetValue: string): string {
// Escape special characters in originalValue to safely use it in a regex pattern.
// This ensures that characters like . (dot), * (asterisk), +, ?, etc. are treated literally,

View file

@ -64,6 +64,7 @@ import type {
import type {
TransactionCreateRequest,
TransactionModifyRequest,
TransactionBatchUpdateCategoryRequest,
TransactionMoveBetweenAccountsRequest,
TransactionDeleteRequest,
TransactionImportRequest,
@ -611,6 +612,9 @@ export default {
modifyTransaction: (req: TransactionModifyRequest): ApiResponsePromise<TransactionInfoResponse> => {
return axios.post<ApiResponse<TransactionInfoResponse>>('v1/transactions/modify.json', req);
},
batchUpdateTransactionCategories: (req: TransactionBatchUpdateCategoryRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transactions/batch_update/category.json', req);
},
moveAllTransactionsBetweenAccounts: (req: TransactionMoveBetweenAccountsRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transactions/move/all.json', req);
},

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Als neuen Explorer speichern",
"Restore to Last Saved": "Auf letzten Speicherstand zurücksetzen",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Sind Sie sicher, dass Sie auf den letzten Speicherstand zurücksetzen möchten? Alle nicht gespeicherten Änderungen gehen verloren.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Explorer-Name festlegen",
"Rename Explorer": "Explorer umbenennen",
"Hide Explorer": "Explorer ausblenden",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Variationskoeffizient",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Kontoliste",
"This Week": "Diese Woche",
"This Month": "Dieser Monat",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Account List",
"This Week": "This Week",
"This Month": "This Month",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Guardar Como Nueva Exploración",
"Restore to Last Saved": "Restaurar al Último Guardado",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "¿Seguro que quieres restaurar al último estado guardado? Se perderán todos los cambios no guardados.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Asignar Nombre a la Exploración",
"Rename Explorer": "Renombrar Exploración",
"Hide Explorer": "Ocultar Exploración",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Lista de Cuentas",
"This Week": "Esta Semana",
"This Month": "Este Mes",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Liste des comptes",
"This Week": "Cette semaine",
"This Month": "Ce mois",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Elenco account",
"This Week": "Questa settimana",
"This Month": "Questo mese",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "口座リスト",
"This Week": "今週",
"This Month": "今月",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "ಖಾತೆಗಳ ಪಟ್ಟಿ",
"This Week": "ಈ ವಾರ",
"This Month": "ಈ ತಿಂಗಳು",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "새 탐색기로 저장",
"Restore to Last Saved": "마지막 저장 상태로 복원",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "마지막 저장 상태로 복원하시겠습니까? 저장되지 않은 모든 변경 사항이 손실됩니다.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "탐색기 이름 설정",
"Rename Explorer": "탐색기 이름 바꾸기",
"Hide Explorer": "탐색기 숨기기",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "계좌 목록",
"This Week": "이번 주",
"This Month": "이번 달",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Rekeningenlijst",
"This Week": "Deze week",
"This Month": "Deze maand",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Salvar como Novo Explorador",
"Restore to Last Saved": "Restaurar para o Último Salvo",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Tem certeza de que deseja restaurar para o último estado salvo? Todas as alterações não salvas serão perdidas.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Definir Nome do Explorador",
"Rename Explorer": "Renomear Explorador",
"Hide Explorer": "Ocultar Explorador",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coeficiente de Variação",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Lista de Contas",
"This Week": "Esta Semana",
"This Month": "Este Mês",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Сохранить как новое исследование",
"Restore to Last Saved": "Восстановить с последнего сохранения",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Вы действительно хотите восстановить из последнего сохранения? Вы несохранённые изминения будут потеряны.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Установить название исследования",
"Rename Explorer": "Переименовать исследование",
"Hide Explorer": "Скрыть исследование",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Список счетов",
"This Week": "На этой неделе",
"This Month": "В этом месяце",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Shrani kot novo raziskovanje",
"Restore to Last Saved": "Povrni na zadnje shranjeno stanje",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Ali ste prepričani, da želite povrniti na zadnje shranjeno stanje? Vse neshranjene spremembe bodo izgubljene.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Nastavi ime raziskovanja",
"Rename Explorer": "Preimenuj raziskovanje",
"Hide Explorer": "Skrij raziskovanje",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Seznam računov",
"This Week": "Ta teden",
"This Month": "Ta mesec",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "புதிய ஆய்வுக்கருவியாக சேமி",
"Restore to Last Saved": "கடைசி சேமிப்புக்கு மீட்டமை",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "கடைசி சேமிக்கப்பட்ட நிலைக்கு மீட்டமைக்க விரும்புகிறீர்களா? சேமிக்கப்படாத அனைத்து மாற்றங்களும் இழக்கப்படும்.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "ஆய்வுக்கருவி பெயர் அமை",
"Rename Explorer": "ஆய்வுக்கருவி மறுபெயரிடு",
"Hide Explorer": "ஆய்வுக்கருவி மறை",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "கணக்குகளின் பட்டியல்",
"This Week": "இந்த வாரம்",
"This Month": "இந்த மாதம்",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "รายการบัญชี",
"This Week": "สัปดาห์นี้",
"This Month": "เดือนนี้",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Hesap Listesi",
"This Week": "Bu Hafta",
"This Month": "Bu Ay",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Список рахунків",
"This Week": "Цього тижня",
"This Month": "Цього місяця",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "Save As New Explorer",
"Restore to Last Saved": "Restore to Last Saved",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "Are you sure you want to restore to last saved state? All unsaved changes will be lost.",
"Enter Edit Mode": "Enter Edit Mode",
"Exit Edit Mode": "Exit Edit Mode",
"Set Explorer Name": "Set Explorer Name",
"Rename Explorer": "Rename Explorer",
"Hide Explorer": "Hide Explorer",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "Coefficient of Variation",
"Skewness": "Skewness",
"Kurtosis": "Kurtosis",
"Update Categories for Expense Transactions": "Update Categories for Expense Transactions",
"Update Categories for Income Transactions": "Update Categories for Income Transactions",
"Update Categories for Transfer Transactions": "Update Categories for Transfer Transactions",
"Unable to update categories for transactions": "Unable to update categories for transactions",
"Account List": "Danh sách tài khoản",
"This Week": "Tuần này",
"This Month": "Tháng này",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "另存为新的探索",
"Restore to Last Saved": "恢复到上次保存",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "您确定要恢复到上次保存的状态吗?所有未保存的更改将会丢失。",
"Enter Edit Mode": "进入编辑模式",
"Exit Edit Mode": "退出编辑模式",
"Set Explorer Name": "设置探索名称",
"Rename Explorer": "重命名探索",
"Hide Explorer": "隐藏探索",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "变异系数",
"Skewness": "偏度",
"Kurtosis": "峰度",
"Update Categories for Expense Transactions": "更新支出交易的分类",
"Update Categories for Income Transactions": "更新收入交易的分类",
"Update Categories for Transfer Transactions": "更新转账交易的分类",
"Unable to update categories for transactions": "无法更新交易的分类",
"Account List": "账户列表",
"This Week": "本周",
"This Month": "本月",

View file

@ -1757,6 +1757,8 @@
"Save As New Explorer": "另存新探索",
"Restore to Last Saved": "還原到上次儲存",
"Are you sure you want to restore to last saved state? All unsaved changes will be lost.": "您確定要還原到上次儲存的狀態嗎?所有未儲存的更改將會遺失。",
"Enter Edit Mode": "進入編輯模式",
"Exit Edit Mode": "退出編輯模式",
"Set Explorer Name": "設定探索名稱",
"Rename Explorer": "重新命名探索",
"Hide Explorer": "隱藏探索",
@ -1843,6 +1845,10 @@
"Coefficient of Variation": "變異係數",
"Skewness": "偏度",
"Kurtosis": "峰度",
"Update Categories for Expense Transactions": "更新支出交易的分類",
"Update Categories for Income Transactions": "更新收入交易的分類",
"Update Categories for Transfer Transactions": "更新轉帳交易的分類",
"Unable to update categories for transactions": "無法更新交易的分類",
"Account List": "帳戶清單",
"This Week": "本週",
"This Month": "本月",

View file

@ -558,6 +558,11 @@ export interface TransactionModifyRequest {
readonly geoLocation?: TransactionGeoLocationRequest;
}
export interface TransactionBatchUpdateCategoryRequest {
readonly transactionIds: string[];
readonly categoryId: string;
}
export interface TransactionMoveBetweenAccountsRequest {
readonly fromAccountId: string;
readonly toAccountId: string;

View file

@ -1117,6 +1117,51 @@ export const useTransactionsStore = defineStore('transactions', () => {
});
}
function batchUpdateTransactionCategories({ transactionIds, categoryId }: { transactionIds: string[], categoryId: string }): Promise<boolean> {
return new Promise((resolve, reject) => {
services.batchUpdateTransactionCategories({ transactionIds, categoryId }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to update categories for transactions' });
return;
}
if (!transactionListStateInvalid.value) {
updateTransactionListInvalidState(true);
}
if (!transactionReconciliationStatementStateInvalid.value) {
updateTransactionReconciliationStatementInvalidState(true);
}
if (!overviewStore.transactionOverviewStateInvalid) {
overviewStore.updateTransactionOverviewInvalidState(true);
}
if (!statisticsStore.transactionStatisticsStateInvalid) {
statisticsStore.updateTransactionStatisticsInvalidState(true);
}
if (!explorersStore.transactionExplorerStateInvalid) {
explorersStore.updateTransactionExplorerInvalidState(true);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to update categories for 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 update categories for transactions' });
} else {
reject(error);
}
});
});
}
function moveAllTransactionsBetweenAccounts({ fromAccountId, toAccountId }: { fromAccountId: string, toAccountId: string }): Promise<boolean> {
return new Promise((resolve, reject) => {
services.moveAllTransactionsBetweenAccounts({ fromAccountId, toAccountId }).then(response => {
@ -1472,6 +1517,7 @@ export const useTransactionsStore = defineStore('transactions', () => {
getReconciliationStatements,
getTransaction,
saveTransaction,
batchUpdateTransactionCategories,
moveAllTransactionsBetweenAccounts,
deleteTransaction,
recognizeReceiptImage,

View file

@ -0,0 +1,208 @@
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts';
import { useExplorersStore } from '@/stores/explorer.ts';
import { type NameValue, type NameNumeralValue, itemAndIndex } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import { TransactionType } from '@/core/transaction.ts';
import type { TransactionInsightDataItem } from '@/models/transaction.ts';
import type { InsightsExplorer} from '@/models/explorer.ts';
import {
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes,
parseDateTimeFromUnixTimeWithTimezoneOffset
} from '@/lib/datetime.ts';
export function useExplorerDataTablePageBase() {
const {
tt,
getCurrentNumeralSystemType,
formatDateTimeToLongDateTime,
formatAmountToLocalizedNumeralsWithCurrency
} = useI18n();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const explorersStore = useExplorersStore();
const currentPage = ref<number>(1);
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const currentExplorer = computed<InsightsExplorer>(() => explorersStore.currentInsightsExplorer);
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => explorersStore.filteredTransactionsInDataTable);
const allDataTableQuerySources = computed<NameValue[]>(() => {
const sources: NameValue[] = [];
sources.push({
name: tt('All Queries'),
value: ''
});
for (const [query, index] of itemAndIndex(currentExplorer.value.queries)) {
if (query.name) {
sources.push({
name: query.name,
value: query.id
});
} else {
sources.push({
name: tt('format.misc.queryIndex', { index: index + 1 }),
value: query.id
});
}
}
return sources;
});
const allPageCounts = computed<NameNumeralValue[]>(() => {
const pageCounts: NameNumeralValue[] = [];
const availableCountPerPage: number[] = [ 5, 10, 15, 20, 25, 30, 50 ];
for (const count of availableCountPerPage) {
pageCounts.push({ value: count, name: numeralSystem.value.formatNumber(count) });
}
pageCounts.push({ value: -1, name: tt('All') });
return pageCounts;
});
const skeletonData = computed<number[]>(() => {
const data: number[] = [];
for (let i = 0; i < currentExplorer.value.countPerPage; i++) {
data.push(i);
}
return data;
});
const totalPageCount = computed<number>(() => {
if (!filteredTransactions.value || filteredTransactions.value.length < 1) {
return 1;
}
const count = filteredTransactions.value.length;
return Math.ceil(count / currentExplorer.value.countPerPage);
});
const dataTableHeaders = computed<object[]>(() => {
const headers: object[] = [];
headers.push({ key: 'time', value: 'time', title: tt('Transaction Time'), sortable: true, nowrap: true });
headers.push({ key: 'type', value: 'type', title: tt('Type'), sortable: true, nowrap: true });
headers.push({ key: 'secondaryCategoryName', value: 'secondaryCategoryName', title: tt('Category'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAmount', value: 'sourceAmount', title: tt('Amount'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAccountName', value: 'sourceAccountName', title: tt('Account'), sortable: true, nowrap: true });
if (settingsStore.appSettings.showTagInInsightsExplorerPage) {
headers.push({ key: 'tags', value: 'tags', title: tt('Tags'), sortable: true, nowrap: true });
}
headers.push({ key: 'comment', value: 'comment', title: tt('Description'), sortable: true, nowrap: true });
headers.push({ key: 'operation', title: tt('Operation'), sortable: false, nowrap: true, align: 'center' });
return headers;
});
function getDisplayDateTime(transaction: TransactionInsightDataItem): string {
const dateTime = parseDateTimeFromUnixTimeWithTimezoneOffset(transaction.time, transaction.utcOffset);
return formatDateTimeToLongDateTime(dateTime);
}
function isSameAsDefaultTimezoneOffsetMinutes(transaction: TransactionInsightDataItem): boolean {
return transaction.utcOffset === getTimezoneOffsetMinutes(transaction.time);
}
function getDisplayTimezone(transaction: TransactionInsightDataItem): string {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
}
function getDisplayTimeInDefaultTimezone(transaction: TransactionInsightDataItem): string {
const timezoneOffsetMinutes = getTimezoneOffsetMinutes(transaction.time);
const dateTime = parseDateTimeFromUnixTimeWithTimezoneOffset(transaction.time, timezoneOffsetMinutes);
const utcOffset = numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(getUtcOffsetByUtcOffsetMinutes(timezoneOffsetMinutes));
return `${formatDateTimeToLongDateTime(dateTime)} (UTC${utcOffset})`;
}
function getDisplayTransactionType(transaction: TransactionInsightDataItem): string {
if (transaction.type === TransactionType.ModifyBalance) {
return tt('Modify Balance');
} else if (transaction.type === TransactionType.Income) {
return tt('Income');
} else if (transaction.type === TransactionType.Expense) {
return tt('Expense');
} else if (transaction.type === TransactionType.Transfer) {
return tt('Transfer');
} else {
return tt('Unknown');
}
}
function getTransactionTypeColor(transaction: TransactionInsightDataItem): string | undefined {
if (transaction.type === TransactionType.ModifyBalance) {
return 'secondary';
} else if (transaction.type === TransactionType.Income) {
return undefined;
} else if (transaction.type === TransactionType.Expense) {
return undefined;
} else if (transaction.type === TransactionType.Transfer) {
return 'primary';
} else {
return 'default';
}
}
function getDisplaySourceAmount(transaction: TransactionInsightDataItem): string {
let currency = defaultCurrency.value;
if (transaction.sourceAccount) {
currency = transaction.sourceAccount.currency;
}
return formatAmountToLocalizedNumeralsWithCurrency(transaction.sourceAmount, currency);
}
function getDisplayDestinationAmount(transaction: TransactionInsightDataItem): string {
let currency = defaultCurrency.value;
if (transaction.destinationAccount) {
currency = transaction.destinationAccount.currency;
}
return formatAmountToLocalizedNumeralsWithCurrency(transaction.destinationAmount, currency);
}
return {
// states
currentPage,
// computed states
currentExplorer,
filteredTransactions,
allDataTableQuerySources,
allPageCounts,
skeletonData,
totalPageCount,
dataTableHeaders,
// functions
getDisplayDateTime,
isSameAsDefaultTimezoneOffsetMinutes,
getDisplayTimezone,
getDisplayTimeInDefaultTimezone,
getDisplayTransactionType,
getTransactionTypeColor,
getDisplaySourceAmount,
getDisplayDestinationAmount
};
}

View file

@ -5,7 +5,7 @@
<v-layout>
<v-navigation-drawer :permanent="alwaysShowNav" v-model="showNav">
<div class="mx-6 my-4">
<btn-vertical-group :disabled="loading || updating" :buttons="allTabs" v-model="activeTab" />
<btn-vertical-group :disabled="loading || updating || isCurrentDataTableEditable" :buttons="allTabs" v-model="activeTab" />
</div>
<v-divider />
<v-tabs show-arrows
@ -13,13 +13,13 @@
style="max-height: calc(100% - 150px)"
direction="vertical"
:prev-icon="mdiMenuUp" :next-icon="mdiMenuDown"
:key="currentExplorer.id" :disabled="loading || updating"
:key="currentExplorer.id" :disabled="loading || updating || isCurrentDataTableEditable"
:model-value="currentExplorer.id">
<v-tab class="tab-text-truncate" key="new" value="" @click="createNewExplorer">
<span class="text-truncate">{{ tt('New Explorer') }}</span>
</v-tab>
<v-tab class="tab-text-truncate" :key="explorer.id" :value="explorer.id"
:disabled="loading || updating"
:disabled="loading || updating || isCurrentDataTableEditable"
v-for="explorer in allVisibleExplorers"
@click="loadExplorer(explorer.id)">
<span class="text-truncate">{{ explorer.name || tt('Untitled Explorer') }}</span>
@ -41,11 +41,11 @@
<span>{{ tt('Insights Explorer') }}</span>
<v-btn-group class="ms-4" color="default" density="comfortable" variant="outlined" divided>
<v-btn class="button-icon-with-direction" :icon="mdiArrowLeft"
:disabled="loading || updating || !canShiftDateRange"
:disabled="loading || updating || !canShiftDateRange || isCurrentDataTableEditable"
@click="shiftDateRange(-1)"/>
<v-menu location="bottom" max-height="500">
<template #activator="{ props }">
<v-btn :disabled="loading || updating"
<v-btn :disabled="loading || updating || isCurrentDataTableEditable"
v-bind="props">{{ displayQueryDateRangeName }}</v-btn>
</template>
<v-list :selected="[currentFilter.dateRangeType]">
@ -68,7 +68,7 @@
</v-list>
</v-menu>
<v-btn class="button-icon-with-direction" :icon="mdiArrowRight"
:disabled="loading || updating || !canShiftDateRange"
:disabled="loading || updating || !canShiftDateRange || isCurrentDataTableEditable"
@click="shiftDateRange(1)"/>
</v-btn-group>
@ -84,7 +84,7 @@
<v-btn class="ms-3"
:color="isCurrentExplorerModified ? 'primary' : 'default'"
:variant="isCurrentExplorerModified ? 'elevated' : 'outlined'"
:disabled="loading || updating" @click="saveExplorer(false)">
:disabled="loading || updating || isCurrentDataTableEditable" @click="saveExplorer(false)">
{{ tt('Save Explorer') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="updating"></v-progress-circular>
<v-menu activator="parent" :open-on-hover="true">
@ -113,29 +113,41 @@
v-for="timezoneType in allTimezoneTypesUsedForDateRange"
@click="currentExplorer.timezoneUsedForDateRange = timezoneType.type"></v-list-item>
</template>
<v-list-item :prepend-icon="mdiTableEdit"
:title="tt('Enter Edit Mode')"
:disabled="loading || updating || filteredTransactionsInDataTable.length < 1"
@click="isCurrentDataTableEditable = true"
v-if="activeTab === 'table' && !isCurrentDataTableEditable"></v-list-item>
<v-list-item :prepend-icon="mdiTableCheck"
:title="tt('Exit Edit Mode')"
:disabled="loading || updating"
@click="isCurrentDataTableEditable = false"
v-if="activeTab === 'table' && isCurrentDataTableEditable"></v-list-item>
<v-divider class="my-2" v-if="activeTab === 'table' && !isCurrentDataTableEditable"/>
<v-list-item :prepend-icon="mdiExport"
:title="tt('Export Results')"
:disabled="loading || updating || (activeTab === 'table' && (!filteredTransactionsInDataTable || filteredTransactionsInDataTable.length < 1))"
@click="exportResults"
v-if="activeTab === 'table' || activeTab === 'chart'"></v-list-item>
<v-divider class="my-2" v-if="currentExplorer.id" />
<v-list-item :prepend-icon="mdiPencilOutline" @click="setExplorerName" v-if="currentExplorer.id">
v-if="(activeTab === 'table' || activeTab === 'chart') && !isCurrentDataTableEditable"></v-list-item>
<v-divider class="my-2" v-if="currentExplorer.id && !isCurrentDataTableEditable" />
<v-list-item :prepend-icon="mdiPencilOutline" @click="setExplorerName" v-if="currentExplorer.id && !isCurrentDataTableEditable">
<v-list-item-title>{{ tt('Rename Explorer') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiEyeOffOutline" @click="hideExplorer(true)" v-if="currentExplorer.id && !currentExplorer.hidden">
<v-list-item :prepend-icon="mdiEyeOffOutline" @click="hideExplorer(true)" v-if="currentExplorer.id && !currentExplorer.hidden && !isCurrentDataTableEditable">
<v-list-item-title>{{ tt('Hide Explorer') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiEyeOutline" @click="hideExplorer(false)" v-if="currentExplorer.id && currentExplorer.hidden">
<v-list-item :prepend-icon="mdiEyeOutline" @click="hideExplorer(false)" v-if="currentExplorer.id && currentExplorer.hidden && !isCurrentDataTableEditable">
<v-list-item-title>{{ tt('Unhide Explorer') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiDeleteOutline" @click="removeExplorer" v-if="currentExplorer.id">
<v-list-item :prepend-icon="mdiDeleteOutline" @click="removeExplorer" v-if="currentExplorer.id && !isCurrentDataTableEditable">
<v-list-item-title>{{ tt('Delete Explorer') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2"/>
<v-divider class="my-2" v-if="!isCurrentDataTableEditable"/>
<v-list-item :prepend-icon="mdiSort"
:disabled="!allExplorers || allExplorers.length < 2"
:title="tt('Change Explorer Display Order')"
@click="showChangeExplorerDisplayOrderDialog"></v-list-item>
@click="showChangeExplorerDisplayOrderDialog"
v-if="!isCurrentDataTableEditable"></v-list-item>
</v-list>
</v-menu>
</v-btn>
@ -149,7 +161,13 @@
<v-window-item value="table">
<explorer-data-table-tab ref="explorerDataTableTab"
:loading="loading" :disabled="loading || updating"
@click:transaction="onShowTransaction" />
@click:transaction="onShowTransaction"
v-if="!isCurrentDataTableEditable" />
<explorer-editable-data-table-tab ref="explorerEditableDataTableTab"
:loading="loading" :disabled="loading || updating"
@click:transaction="onShowTransaction"
@update:transactions="onUpdateTransactions"
v-if="isCurrentDataTableEditable" />
</v-window-item>
<v-window-item value="chart">
<explorer-chart-tab ref="explorerChartTab"
@ -187,6 +205,7 @@ import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import ExplorerQueryTab from '@/views/desktop/insights/tabs/ExplorerQueryTab.vue';
import ExplorerDataTableTab from '@/views/desktop/insights/tabs/ExplorerDataTableTab.vue';
import ExplorerEditableDataTableTab from '@/views/desktop/insights/tabs/ExplorerEditableDataTableTab.vue';
import ExplorerChartTab from '@/views/desktop/insights/tabs/ExplorerChartTab.vue';
import ExplorerChangeDisplayOrderDialog from '@/views/desktop/insights/dialogs/ExplorerChangeDisplayOrderDialog.vue';
import EditDialog from '@/views/desktop/transactions/list/dialogs/EditDialog.vue';
@ -238,7 +257,9 @@ import {
mdiSort,
mdiHomeClockOutline,
mdiInvoiceTextClockOutline,
mdiExport
mdiExport,
mdiTableEdit,
mdiTableCheck
} from '@mdi/js';
interface InsightsExplorerProps {
@ -298,6 +319,7 @@ const initing = ref<boolean>(true);
const updating = ref<boolean>(false);
const clientSessionId = ref<string>('');
const isCurrentExplorerModified = ref<boolean>(false);
const isCurrentDataTableEditable = ref<boolean>(false);
const alwaysShowNav = ref<boolean>(display.mdAndUp.value);
const showNav = ref<boolean>(display.mdAndUp.value);
const activeTab = ref<ExplorerPageTabType>('query');
@ -726,6 +748,10 @@ function onShowTransaction(transaction: TransactionInsightDataItem): void {
});
}
function onUpdateTransactions(): void {
reload(false);
}
function onShowDateRangeError(message: string): void {
snackbar.value?.showError(message);
}

View file

@ -0,0 +1,193 @@
<template>
<v-dialog width="600" :persistent="true" v-model="showState">
<v-card class="pa-sm-1 pa-md-2">
<template #title>
<div class="d-flex flex-wrap align-center">
<h4 class="text-h4 text-wrap" v-if="type === CategoryType.Expense">{{ tt('Update Categories for Expense Transactions') }}</h4>
<h4 class="text-h4 text-wrap" v-if="type === CategoryType.Income">{{ tt('Update Categories for Income Transactions') }}</h4>
<h4 class="text-h4 text-wrap" v-if="type === CategoryType.Transfer">{{ tt('Update Categories for Transfer Transactions') }}</h4>
<v-btn class="ms-2" density="compact" color="default" variant="text" size="24"
:icon="true" :disabled="loading || submitting" :loading="loading"
@click="reload">
<template #loader>
<v-progress-circular indeterminate size="20"/>
</template>
<v-icon :icon="mdiRefresh" size="24" />
<v-tooltip activator="parent">{{ tt('Refresh') }}</v-tooltip>
</v-btn>
</div>
</template>
<v-card-text class="w-100 d-flex justify-center">
<v-row>
<v-col cols="12">
<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 || !hasVisibleExpenseCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(categoryId, allCategories[CategoryType.Expense])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(categoryId, allCategories[CategoryType.Expense])"
:label="tt('Target Category')"
:placeholder="tt('Target Category')"
:items="allCategories[CategoryType.Expense]"
v-model="categoryId"
v-if="type === CategoryType.Expense">
</two-column-select>
<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 || !hasVisibleIncomeCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(categoryId, allCategories[CategoryType.Income])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(categoryId, allCategories[CategoryType.Income])"
:label="tt('Target Category')"
:placeholder="tt('Target Category')"
:items="allCategories[CategoryType.Income]"
v-model="categoryId"
v-if="type === CategoryType.Income">
</two-column-select>
<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 || !hasVisibleTransferCategories"
:enable-filter="true" :filter-placeholder="tt('Find category')" :filter-no-items-text="tt('No available category')"
:show-selection-primary-text="true"
:custom-selection-primary-text="getTransactionPrimaryCategoryName(categoryId, allCategories[CategoryType.Transfer])"
:custom-selection-secondary-text="getTransactionSecondaryCategoryName(categoryId, allCategories[CategoryType.Transfer])"
:label="tt('Target Category')"
:placeholder="tt('Target Category')"
:items="allCategories[CategoryType.Transfer]"
v-model="categoryId"
v-if="type === CategoryType.Transfer">
</two-column-select>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<div class="w-100 d-flex justify-center flex-wrap mt-sm-1 mt-md-2 gap-4">
<v-btn :disabled="loading || submitting || updateIds.length < 1 || !categoryId" @click="confirm">
{{ tt('OK') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal" :disabled="loading || submitting" @click="cancel">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, computed, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { CategoryType } from '@/core/category.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
import {
getTransactionPrimaryCategoryName,
getTransactionSecondaryCategoryName
} from '@/lib/category.ts';
import {
mdiRefresh
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
const {
tt
} = useI18n();
const transactionCategoriesStore = useTransactionCategoriesStore();
const transactionsStore = useTransactionsStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const showState = ref<boolean>(false);
const loading = ref<boolean>(false);
const submitting = ref<boolean>(false);
const type = ref<CategoryType>(CategoryType.Expense);
const updateIds = ref<string[]>([]);
const categoryId = ref<string>('');
let resolveFunc: ((response: number) => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
const allCategories = computed<Record<number, TransactionCategory[]>>(() => transactionCategoriesStore.allTransactionCategories);
const hasVisibleExpenseCategories = computed<boolean>(() => transactionCategoriesStore.hasVisibleExpenseCategories);
const hasVisibleIncomeCategories = computed<boolean>(() => transactionCategoriesStore.hasVisibleIncomeCategories);
const hasVisibleTransferCategories = computed<boolean>(() => transactionCategoriesStore.hasVisibleTransferCategories);
function open(options: { type: CategoryType; updateIds: string[] }): Promise<number> {
type.value = options.type;
updateIds.value = options.updateIds;
categoryId.value = '';
showState.value = true;
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function reload(): void {
transactionCategoriesStore.loadAllCategories({ force: true }).then(() => {
loading.value = false;
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function confirm(): void {
submitting.value = true;
transactionsStore.batchUpdateTransactionCategories({
transactionIds: updateIds.value,
categoryId: categoryId.value
}).then(() => {
submitting.value = false;
showState.value = false;
resolveFunc?.(updateIds.value.length);
}).catch(error => {
submitting.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function cancel(): void {
rejectFunc?.();
showState.value = false;
}
defineExpose({
open
});
</script>

View file

@ -207,26 +207,20 @@
<script setup lang="ts">
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useExplorerDataTablePageBase } from '@/views/base/explorer/ExplorerDataTablePageBase.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts';
import { type InsightsExplorerTransactionStatisticData, useExplorersStore } from '@/stores/explorer.ts';
import { type NameValue, type NameNumeralValue, itemAndIndex } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import { TransactionType } from '@/core/transaction.ts';
import type { TransactionInsightDataItem } from '@/models/transaction.ts';
import type { InsightsExplorer} from '@/models/explorer.ts';
import { isDefined, replaceAll } from '@/lib/common.ts';
import {
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes,
parseDateTimeFromUnixTimeWithTimezoneOffset
} from '@/lib/datetime.ts';
@ -249,8 +243,6 @@ const emit = defineEmits<{
const {
tt,
getCurrentNumeralSystemType,
formatDateTimeToLongDateTime,
formatDateTimeToGregorianDefaultDateTime,
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
formatAmountToLocalizedNumeralsWithCurrency,
@ -258,163 +250,30 @@ const {
formatPercentToLocalizedNumerals
} = useI18n();
const {
currentPage,
currentExplorer,
filteredTransactions,
allDataTableQuerySources,
allPageCounts,
skeletonData,
totalPageCount,
dataTableHeaders,
getDisplayDateTime,
isSameAsDefaultTimezoneOffsetMinutes,
getDisplayTimezone,
getDisplayTimeInDefaultTimezone,
getDisplayTransactionType,
getTransactionTypeColor,
getDisplaySourceAmount,
getDisplayDestinationAmount
} = useExplorerDataTablePageBase();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const explorersStore = useExplorersStore();
const currentPage = ref<number>(1);
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const currentExplorer = computed<InsightsExplorer>(() => explorersStore.currentInsightsExplorer);
const filteredTransactions = computed<TransactionInsightDataItem[]>(() => explorersStore.filteredTransactionsInDataTable);
const filteredTransactionsStatistic = computed<InsightsExplorerTransactionStatisticData | undefined>(() => explorersStore.filteredTransactionsInDataTableStatistic);
const allDataTableQuerySources = computed<NameValue[]>(() => {
const sources: NameValue[] = [];
sources.push({
name: tt('All Queries'),
value: ''
});
for (const [query, index] of itemAndIndex(currentExplorer.value.queries)) {
if (query.name) {
sources.push({
name: query.name,
value: query.id
});
} else {
sources.push({
name: tt('format.misc.queryIndex', { index: index + 1 }),
value: query.id
});
}
}
return sources;
});
const allPageCounts = computed<NameNumeralValue[]>(() => {
const pageCounts: NameNumeralValue[] = [];
const availableCountPerPage: number[] = [ 5, 10, 15, 20, 25, 30, 50 ];
for (const count of availableCountPerPage) {
pageCounts.push({ value: count, name: numeralSystem.value.formatNumber(count) });
}
pageCounts.push({ value: -1, name: tt('All') });
return pageCounts;
});
const skeletonData = computed<number[]>(() => {
const data: number[] = [];
for (let i = 0; i < currentExplorer.value.countPerPage; i++) {
data.push(i);
}
return data;
});
const totalPageCount = computed<number>(() => {
if (!filteredTransactions.value || filteredTransactions.value.length < 1) {
return 1;
}
const count = filteredTransactions.value.length;
return Math.ceil(count / currentExplorer.value.countPerPage);
});
const dataTableHeaders = computed<object[]>(() => {
const headers: object[] = [];
headers.push({ key: 'time', value: 'time', title: tt('Transaction Time'), sortable: true, nowrap: true });
headers.push({ key: 'type', value: 'type', title: tt('Type'), sortable: true, nowrap: true });
headers.push({ key: 'secondaryCategoryName', value: 'secondaryCategoryName', title: tt('Category'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAmount', value: 'sourceAmount', title: tt('Amount'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAccountName', value: 'sourceAccountName', title: tt('Account'), sortable: true, nowrap: true });
if (settingsStore.appSettings.showTagInInsightsExplorerPage) {
headers.push({ key: 'tags', value: 'tags', title: tt('Tags'), sortable: true, nowrap: true });
}
headers.push({ key: 'comment', value: 'comment', title: tt('Description'), sortable: true, nowrap: true });
headers.push({ key: 'operation', title: tt('Operation'), sortable: false, nowrap: true, align: 'center' });
return headers;
});
function getDisplayDateTime(transaction: TransactionInsightDataItem): string {
const dateTime = parseDateTimeFromUnixTimeWithTimezoneOffset(transaction.time, transaction.utcOffset);
return formatDateTimeToLongDateTime(dateTime);
}
function isSameAsDefaultTimezoneOffsetMinutes(transaction: TransactionInsightDataItem): boolean {
return transaction.utcOffset === getTimezoneOffsetMinutes(transaction.time);
}
function getDisplayTimezone(transaction: TransactionInsightDataItem): string {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
}
function getDisplayTimeInDefaultTimezone(transaction: TransactionInsightDataItem): string {
const timezoneOffsetMinutes = getTimezoneOffsetMinutes(transaction.time);
const dateTime = parseDateTimeFromUnixTimeWithTimezoneOffset(transaction.time, timezoneOffsetMinutes);
const utcOffset = numeralSystem.value.replaceWesternArabicDigitsToLocalizedDigits(getUtcOffsetByUtcOffsetMinutes(timezoneOffsetMinutes));
return `${formatDateTimeToLongDateTime(dateTime)} (UTC${utcOffset})`;
}
function getDisplayTransactionType(transaction: TransactionInsightDataItem): string {
if (transaction.type === TransactionType.ModifyBalance) {
return tt('Modify Balance');
} else if (transaction.type === TransactionType.Income) {
return tt('Income');
} else if (transaction.type === TransactionType.Expense) {
return tt('Expense');
} else if (transaction.type === TransactionType.Transfer) {
return tt('Transfer');
} else {
return tt('Unknown');
}
}
function getTransactionTypeColor(transaction: TransactionInsightDataItem): string | undefined {
if (transaction.type === TransactionType.ModifyBalance) {
return 'secondary';
} else if (transaction.type === TransactionType.Income) {
return undefined;
} else if (transaction.type === TransactionType.Expense) {
return undefined;
} else if (transaction.type === TransactionType.Transfer) {
return 'primary';
} else {
return 'default';
}
}
function getDisplaySourceAmount(transaction: TransactionInsightDataItem): string {
let currency = defaultCurrency.value;
if (transaction.sourceAccount) {
currency = transaction.sourceAccount.currency;
}
return formatAmountToLocalizedNumeralsWithCurrency(transaction.sourceAmount, currency);
}
function getDisplayDestinationAmount(transaction: TransactionInsightDataItem): string {
let currency = defaultCurrency.value;
if (transaction.destinationAccount) {
currency = transaction.destinationAccount.currency;
}
return formatAmountToLocalizedNumeralsWithCurrency(transaction.destinationAmount, currency);
}
function showTransaction(transaction: TransactionInsightDataItem): void {
emit('click:transaction', transaction);
}

View file

@ -0,0 +1,359 @@
<template>
<v-card-text class="px-5 py-0 mb-4">
<v-row>
<v-col cols="12">
<div class="d-flex overflow-x-auto align-center gap-2 pt-2">
<v-select
class="flex-0-0"
min-width="150"
item-title="name"
item-value="value"
density="compact"
:disabled="true"
:label="tt('Data Source')"
:items="allDataTableQuerySources"
:model-value="currentExplorer.datatableQuerySource"
/>
<v-select
class="flex-0-0"
min-width="150"
item-title="name"
item-value="value"
density="compact"
:disabled="loading || disabled"
:label="tt('Transactions Per Page')"
:items="allPageCounts"
v-model="currentExplorer.countPerPage"
/>
<v-spacer/>
<div class="d-flex align-center">
<span class="text-subtitle-1">
{{ tt('format.misc.selectedCount', { count: formatNumberToLocalizedNumerals(selectedTransactionCount), totalCount: formatNumberToLocalizedNumerals(filteredTransactions.length) }) }}
</span>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
<v-data-table
fixed-header
fixed-footer
multi-sort
item-value="index"
:class="{ 'insights-editable-explorer-table': true, 'text-sm': true, 'disabled': loading || disabled, 'loading-skeleton': loading }"
:headers="editableDataTableHeaders"
:items="filteredTransactions"
:hover="true"
v-model:items-per-page="currentExplorer.countPerPage"
v-model:page="currentPage"
>
<template #header.data-table-select>
<v-checkbox readonly class="always-cursor-pointer"
density="compact" width="28"
:disabled="!!disabled"
:indeterminate="anyButNotAllTransactionSelected"
v-model="allTransactionSelected"
>
<v-menu activator="parent" location="bottom">
<v-list>
<v-list-item :prepend-icon="mdiSelectAll"
:title="tt('Select All')"
:disabled="loading || disabled"
@click="selectAll"></v-list-item>
<v-list-item :prepend-icon="mdiSelect"
:title="tt('Select None')"
:disabled="loading || disabled"
@click="selectNone"></v-list-item>
<v-list-item :prepend-icon="mdiSelectInverse"
:title="tt('Invert Selection')"
:disabled="loading || disabled"
@click="selectInvert"></v-list-item>
</v-list>
</v-menu>
</v-checkbox>
</template>
<template #header.operation>
<div>
<span>{{ tt('Operation') }}</span>
<v-icon :icon="mdiMenuDown" size="20" />
<v-menu activator="parent" location="bottom">
<v-list>
<v-list-item :prepend-icon="mdiTextBoxEditOutline"
:title="tt('Update Categories for Expense Transactions')"
:disabled="!isAllSelectedTransactionsExpense"
@click="batchUpdateTransactionCategories(CategoryType.Expense)"></v-list-item>
<v-list-item :prepend-icon="mdiTextBoxEditOutline"
:title="tt('Update Categories for Income Transactions')"
:disabled="!isAllSelectedTransactionsIncome"
@click="batchUpdateTransactionCategories(CategoryType.Income)"></v-list-item>
<v-list-item :prepend-icon="mdiTextBoxEditOutline"
:title="tt('Update Categories for Transfer Transactions')"
:disabled="!isAllSelectedTransactionsTransfer"
@click="batchUpdateTransactionCategories(CategoryType.Transfer)"></v-list-item>
</v-list>
</v-menu>
</div>
</template>
<template #item.data-table-select="{ item }">
<v-checkbox density="compact" :disabled="loading || disabled"
v-model="selectedTransactions[item.id]"></v-checkbox>
</template>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ms-1" variant="flat" color="grey" size="x-small"
v-if="!isSameAsDefaultTimezoneOffsetMinutes(item)">{{ getDisplayTimezone(item) }}</v-chip>
<v-tooltip activator="parent" v-if="!isSameAsDefaultTimezoneOffsetMinutes(item)">{{ getDisplayTimeInDefaultTimezone(item) }}</v-tooltip>
</template>
<template #item.type="{ item }">
<v-chip label variant="outlined" size="x-small"
:class="{ 'text-income' : item.type === TransactionType.Income, 'text-expense': item.type === TransactionType.Expense }"
:color="getTransactionTypeColor(item)">{{ getDisplayTransactionType(item) }}</v-chip>
</template>
<template #item.secondaryCategoryName="{ item }">
<div class="d-flex align-center">
<ItemIcon size="24px" icon-type="category"
:icon-id="item.secondaryCategory?.icon ?? ''"
:color="item.secondaryCategory?.color ?? ''"
v-if="item.secondaryCategory?.color"></ItemIcon>
<v-icon size="24" :icon="mdiPencilBoxOutline" v-else-if="!item.secondaryCategory || !item.secondaryCategory?.color" />
<span class="ms-2" v-if="item.type === TransactionType.ModifyBalance">
{{ tt('Modify Balance') }}
</span>
<span class="ms-2" v-else-if="item.type !== TransactionType.ModifyBalance && item.secondaryCategory">
{{ item.secondaryCategory?.name }}
</span>
</div>
</template>
<template #item.sourceAmount="{ item }">
<span :class="{ 'text-expense': item.type === TransactionType.Expense, 'text-income': item.type === TransactionType.Income }">{{ getDisplaySourceAmount(item) }}</span>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer && item.sourceAccount?.id !== item.destinationAccount?.id && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.sourceAccount?.id !== item.destinationAccount?.id && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)">{{ getDisplayDestinationAmount(item) }}</span>
</template>
<template #item.sourceAccountName="{ item }">
<div class="d-flex align-center">
<span v-if="item.sourceAccount">{{ item.sourceAccount?.name }}</span>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.destinationAccount">{{ item.destinationAccount?.name }}</span>
</div>
</template>
<template #item.tags="{ item }">
<div class="d-flex">
<v-chip class="transaction-tag" size="small"
:key="tag.id" :prepend-icon="mdiPound"
:text="tag.name"
v-for="tag in item.tags"/>
<v-chip class="transaction-tag" size="small"
:text="tt('None')"
v-if="!item.tagIds || !item.tagIds.length"/>
</div>
</template>
<template #item.operation="{ item }">
<v-btn density="compact" variant="text" color="default" :disabled="loading || disabled"
@click="showTransaction(item)">
{{ tt('View') }}
</v-btn>
</template>
<template #no-data>
<div v-if="loading && (!filteredTransactions || filteredTransactions.length < 1)">
<div class="ms-1" style="padding-top: 3px; padding-bottom: 3px" :key="itemIdx" v-for="itemIdx in skeletonData">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</div>
</div>
<div v-else>
{{ tt('No transaction data') }}
</div>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center justify-center text-no-wrap mt-2 mb-4">
<pagination-buttons :disabled="loading || disabled"
:totalPageCount="totalPageCount"
v-model="currentPage">
</pagination-buttons>
</div>
</template>
</v-data-table>
<batch-update-category-dialog ref="batchUpdateCategoryDialog" />
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import BatchUpdateCategoryDialog from '@/views/desktop/insights/dialogs/BatchUpdateCategoryDialog.vue';
import { ref, computed, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useExplorerDataTablePageBase } from '@/views/base/explorer/ExplorerDataTablePageBase.ts';
import { CategoryType } from '@/core/category.ts';
import { TransactionType } from '@/core/transaction.ts';
import type { TransactionInsightDataItem } from '@/models/transaction.ts';
import { getObjectOwnFieldWithValueCount } from '@/lib/common.ts';
import {
mdiArrowRight,
mdiPencilBoxOutline,
mdiPound,
mdiSelect,
mdiSelectAll,
mdiSelectInverse,
mdiMenuDown,
mdiTextBoxEditOutline
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
type BatchUpdateCategoryDialogType = InstanceType<typeof BatchUpdateCategoryDialog>;
interface InsightsExplorerDataTableTabProps {
loading?: boolean;
disabled?: boolean;
}
defineProps<InsightsExplorerDataTableTabProps>();
const emit = defineEmits<{
(e: 'click:transaction', value: TransactionInsightDataItem): void;
(e: 'update:transactions'): void;
}>();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const batchUpdateCategoryDialog = useTemplateRef<BatchUpdateCategoryDialogType>('batchUpdateCategoryDialog');
const {
tt,
formatNumberToLocalizedNumerals
} = useI18n();
const {
currentPage,
currentExplorer,
filteredTransactions,
allDataTableQuerySources,
allPageCounts,
skeletonData,
totalPageCount,
dataTableHeaders,
getDisplayDateTime,
isSameAsDefaultTimezoneOffsetMinutes,
getDisplayTimezone,
getDisplayTimeInDefaultTimezone,
getDisplayTransactionType,
getTransactionTypeColor,
getDisplaySourceAmount,
getDisplayDestinationAmount
} = useExplorerDataTablePageBase();
const selectedTransactions = ref<Record<string, boolean>>({});
const selectedTransactionCount = computed<number>(() => getObjectOwnFieldWithValueCount(selectedTransactions.value, true));
const allTransactionSelected = computed<boolean>(() => selectedTransactionCount.value > 0 && selectedTransactionCount.value === filteredTransactions.value.length);
const anyButNotAllTransactionSelected = computed<boolean>(() => selectedTransactionCount.value > 0 && selectedTransactionCount.value < filteredTransactions.value.length);
const isAllSelectedTransactionsExpense = computed<boolean>(() => isAllSelectedTransactionsSpecificType(TransactionType.Expense));
const isAllSelectedTransactionsIncome = computed<boolean>(() => isAllSelectedTransactionsSpecificType(TransactionType.Income));
const isAllSelectedTransactionsTransfer = computed<boolean>(() => isAllSelectedTransactionsSpecificType(TransactionType.Transfer));
const editableDataTableHeaders = computed<object[]>(() => {
const headers: object[] = [
{ key: 'data-table-select', fixed: true }
];
headers.push(...dataTableHeaders.value);
return headers;
});
function isAllSelectedTransactionsSpecificType(type: TransactionType): boolean {
for (const transaction of filteredTransactions.value) {
if (selectedTransactions.value[transaction.id] && transaction.type !== type) {
return false;
}
}
return selectedTransactionCount.value > 0;
}
function getAllSelectedTransactionIds(): string[] {
const selectedIds: string[] = [];
for (const transaction of filteredTransactions.value) {
if (selectedTransactions.value[transaction.id]) {
selectedIds.push(transaction.id);
}
}
return selectedIds;
}
function selectAll(): void {
for (const transaction of filteredTransactions.value) {
selectedTransactions.value[transaction.id] = true;
}
}
function selectNone(): void {
for (const transaction of filteredTransactions.value) {
selectedTransactions.value[transaction.id] = false;
}
}
function selectInvert(): void {
for (const transaction of filteredTransactions.value) {
selectedTransactions.value[transaction.id] = !selectedTransactions.value[transaction.id];
}
}
function batchUpdateTransactionCategories(type: CategoryType): void {
batchUpdateCategoryDialog.value?.open({
type: type,
updateIds: getAllSelectedTransactionIds() }
).then(updatedCount => {
if (updatedCount > 0) {
snackbar.value?.showMessage('format.misc.youHaveUpdatedTransactions', {
count: formatNumberToLocalizedNumerals(updatedCount)
});
}
selectedTransactions.value = {};
emit('update:transactions');
}).catch(error => {
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function showTransaction(transaction: TransactionInsightDataItem): void {
emit('click:transaction', transaction);
}
</script>
<style>
.v-table.insights-editable-explorer-table > .v-table__wrapper > table {
th:not(:nth-last-child(2)),
td:not(:nth-last-child(2)) {
width: auto !important;
white-space: nowrap;
}
th:nth-last-child(2),
td:nth-last-child(2) {
width: 100% !important;
}
}
.v-table.insights-editable-explorer-table.loading-skeleton tr.v-data-table-rows-no-data > td {
padding: 0;
}
.v-table.insights-editable-explorer-table .v-chip.transaction-tag {
margin-inline-end: 4px;
margin-top: 2px;
margin-bottom: 2px;
}
.v-table.insights-editable-explorer-table .v-chip.transaction-tag > .v-chip__content {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>