support transaction tag group

This commit is contained in:
MaysWind 2026-01-17 00:47:51 +08:00
parent b556efa510
commit 7d9cfc4ced
59 changed files with 3289 additions and 795 deletions

View file

@ -101,6 +101,14 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagGroup))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag group table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
if err != nil {

View file

@ -418,6 +418,14 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler))
apiV1Route.POST("/transaction/categories/delete.json", bindApi(api.TransactionCategories.CategoryDeleteHandler))
// Transaction Tag Groups
apiV1Route.GET("/transaction/tags/groups/list.json", bindApi(api.TransactionTagGroups.TagGroupListHandler))
apiV1Route.GET("/transaction/tags/groups/get.json", bindApi(api.TransactionTagGroups.TagGroupGetHandler))
apiV1Route.POST("/transaction/tags/groups/add.json", bindApi(api.TransactionTagGroups.TagGroupCreateHandler))
apiV1Route.POST("/transaction/tags/groups/modify.json", bindApi(api.TransactionTagGroups.TagGroupModifyHandler))
apiV1Route.POST("/transaction/tags/groups/move.json", bindApi(api.TransactionTagGroups.TagGroupMoveHandler))
apiV1Route.POST("/transaction/tags/groups/delete.json", bindApi(api.TransactionTagGroups.TagGroupDeleteHandler))
// Transaction Tags
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))

View file

@ -28,6 +28,7 @@ type DataManagementsApi struct {
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
tagGroups *services.TransactionTagGroupService
pictures *services.TransactionPictureService
templates *services.TransactionTemplateService
userCustomExchangeRates *services.UserCustomExchangeRatesService
@ -46,6 +47,7 @@ var (
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
tagGroups: services.TransactionTagGroups,
pictures: services.TransactionPictures,
templates: services.TransactionTemplates,
userCustomExchangeRates: services.UserCustomExchangeRates,
@ -193,6 +195,13 @@ func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tagGroups.DeleteAllTagGroups(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tag groups, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
if err != nil {

View file

@ -0,0 +1,210 @@
package api
import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
)
// TransactionTagGroupsApi represents transaction tag group api
type TransactionTagGroupsApi struct {
tagGroups *services.TransactionTagGroupService
}
// Initialize a transaction tag group api singleton instance
var (
TransactionTagGroups = &TransactionTagGroupsApi{
tagGroups: services.TransactionTagGroups,
}
)
// TagGroupListHandler returns transaction tag group list of current user
func (a *TransactionTagGroupsApi) TagGroupListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tagGroups, err := a.tagGroups.GetAllTagGroupsByUid(c, uid)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupListHandler] failed to get tag groups for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroupResps := make(models.TransactionTagGroupInfoResponseSlice, len(tagGroups))
for i := 0; i < len(tagGroups); i++ {
tagGroupResps[i] = tagGroups[i].ToTransactionTagGroupInfoResponse()
}
sort.Sort(tagGroupResps)
return tagGroupResps, nil
}
// TagGroupGetHandler returns one specific transaction tag group of current user
func (a *TransactionTagGroupsApi) TagGroupGetHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupGetReq models.TransactionTagGroupGetRequest
err := c.ShouldBindQuery(&tagGroupGetReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupGetReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupGetHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupCreateHandler saves a new transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupCreateHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupCreateReq models.TransactionTagGroupCreateRequest
err := c.ShouldBindJSON(&tagGroupCreateReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tagGroups.GetMaxDisplayOrder(c, uid)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagGroup := a.createNewTagGroupModel(uid, &tagGroupCreateReq, maxOrderId+1)
err = a.tagGroups.CreateTagGroup(c, tagGroup)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to create tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroup.TagGroupId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupCreateHandler] user \"uid:%d\" has created a new tag group \"id:%d\" successfully", uid, tagGroup.TagGroupId)
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupModifyHandler saves an existed transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupModifyHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupModifyReq models.TransactionTagGroupModifyRequest
err := c.ShouldBindJSON(&tagGroupModifyReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newTagGroup := &models.TransactionTagGroup{
TagGroupId: tagGroup.TagGroupId,
Uid: uid,
Name: tagGroupModifyReq.Name,
}
if newTagGroup.Name == tagGroup.Name {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tagGroups.ModifyTagGroup(c, newTagGroup)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to update tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupModifyHandler] user \"uid:%d\" has updated tag group \"id:%d\" successfully", uid, tagGroupModifyReq.Id)
tagGroup.Name = newTagGroup.Name
tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse()
return tagGroupResp, nil
}
// TagGroupMoveHandler moves display order of existed transaction tag groups by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupMoveHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupMoveReq models.TransactionTagGroupMoveRequest
err := c.ShouldBindJSON(&tagGroupMoveReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tagGroups := make([]*models.TransactionTagGroup, len(tagGroupMoveReq.NewDisplayOrders))
for i := 0; i < len(tagGroupMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := tagGroupMoveReq.NewDisplayOrders[i]
tagGroup := &models.TransactionTagGroup{
Uid: uid,
TagGroupId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
tagGroups[i] = tagGroup
}
err = a.tagGroups.ModifyTagGroupDisplayOrders(c, uid, tagGroups)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupMoveHandler] failed to move tag groups for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupMoveHandler] user \"uid:%d\" has moved tag groups", uid)
return true, nil
}
// TagGroupDeleteHandler deletes an existed transaction tag group by request parameters for current user
func (a *TransactionTagGroupsApi) TagGroupDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var tagGroupDeleteReq models.TransactionTagGroupDeleteRequest
err := c.ShouldBindJSON(&tagGroupDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_tag_groups.TagGroupDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tagGroups.DeleteTagGroup(c, uid, tagGroupDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tag_groups.TagGroupDeleteHandler] failed to delete tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tag_groups.TagGroupDeleteHandler] user \"uid:%d\" has deleted tag group \"id:%d\"", uid, tagGroupDeleteReq.Id)
return true, nil
}
func (a *TransactionTagGroupsApi) createNewTagGroupModel(uid int64, tagGroupCreateReq *models.TransactionTagGroupCreateRequest, order int32) *models.TransactionTagGroup {
return &models.TransactionTagGroup{
Uid: uid,
Name: tagGroupCreateReq.Name,
DisplayOrder: order,
}
}

View file

@ -78,7 +78,7 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
@ -111,9 +111,16 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
if tagCreateBatchReq.Tags[i].GroupId != tagCreateBatchReq.GroupId {
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the group id \"%d\" of tag#%d is inconsistent with the batch group id \"%d\"", tagCreateBatchReq.Tags[i].GroupId, i, tagCreateBatchReq.GroupId)
return nil, errs.ErrTransactionTagGroupIdInvalid
}
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
@ -161,16 +168,31 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
}
newTag := &models.TransactionTag{
TagId: tag.TagId,
Uid: uid,
Name: tagModifyReq.Name,
TagId: tag.TagId,
Uid: uid,
Name: tagModifyReq.Name,
TagGroupId: tagModifyReq.GroupId,
DisplayOrder: tag.DisplayOrder,
}
if newTag.Name == tag.Name {
tagNameChanged := newTag.Name != tag.Name
if !tagNameChanged && newTag.TagGroupId == tag.TagGroupId {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tags.ModifyTag(c, newTag)
if newTag.TagGroupId != tag.TagGroupId {
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, newTag.TagGroupId)
if err != nil {
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newTag.DisplayOrder = maxOrderId + 1
}
err = a.tags.ModifyTag(c, newTag, tagNameChanged)
if err != nil {
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
@ -180,6 +202,8 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
tag.Name = newTag.Name
tag.TagGroupId = newTag.TagGroupId
tag.DisplayOrder = newTag.DisplayOrder
tagResp := tag.ToTransactionTagInfoResponse()
return tagResp, nil
@ -268,6 +292,7 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T
return &models.TransactionTag{
Uid: uid,
Name: tagCreateReq.Name,
TagGroupId: tagCreateReq.GroupId,
DisplayOrder: order,
}
}
@ -278,6 +303,7 @@ func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *mo
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
tagCreateReq := tagCreateBatchReq.Tags[i]
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
tag.TagGroupId = tagCreateBatchReq.GroupId
tags[i] = tag
}

View file

@ -44,6 +44,7 @@ const (
NormalSubcategoryUserExternalAuth = 16
NormalSubcategoryOAuth2 = 17
NormalSubcategoryInsightsExplorer = 18
NormalSubcategoryTagGroup = 19
)
// Error represents the specific error returned to user

View file

@ -0,0 +1,10 @@
package errs
import "net/http"
// Error codes related to transaction tag groups
var (
ErrTransactionTagGroupIdInvalid = NewNormalError(NormalSubcategoryTagGroup, 0, http.StatusBadRequest, "transaction tag group id is invalid")
ErrTransactionTagGroupNotFound = NewNormalError(NormalSubcategoryTagGroup, 1, http.StatusBadRequest, "transaction tag group not found")
ErrTransactionTagGroupInUseCannotBeDeleted = NewNormalError(NormalSubcategoryTagGroup, 2, http.StatusBadRequest, "transaction tag group is in use and cannot be deleted")
)

View file

@ -3,10 +3,11 @@ package models
// TransactionTag represents transaction tag data stored in database
type TransactionTag struct {
TagId int64 `xorm:"PK"`
Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"`
TagGroupId int64 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"`
Hidden bool `xorm:"NOT NULL"`
CreatedUnixTime int64
UpdatedUnixTime int64
@ -20,19 +21,22 @@ type TransactionTagGetRequest struct {
// TransactionTagCreateRequest represents all parameters of transaction tag creation request
type TransactionTagCreateRequest struct {
Name string `json:"name" binding:"required,notBlank,max=64"`
GroupId int64 `json:"groupId,string"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagCreateBatchRequest represents all parameters of transaction tag batch creation request
type TransactionTagCreateBatchRequest struct {
Tags []*TransactionTagCreateRequest `json:"tags" binding:"required"`
GroupId int64 `json:"groupId,string"`
SkipExists bool `json:"skipExists"`
}
// TransactionTagModifyRequest represents all parameters of transaction tag modification request
type TransactionTagModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Id int64 `json:"id,string" binding:"required,min=1"`
GroupId int64 `json:"groupId,string"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagHideRequest represents all parameters of transaction tag hiding request
@ -61,6 +65,7 @@ type TransactionTagDeleteRequest struct {
type TransactionTagInfoResponse struct {
Id int64 `json:"id,string"`
Name string `json:"name"`
TagGroupId int64 `json:"groupId,string"`
DisplayOrder int32 `json:"displayOrder"`
Hidden bool `json:"hidden"`
}
@ -71,6 +76,7 @@ func (t *TransactionTag) FillFromOtherTag(tag *TransactionTag) {
t.Uid = tag.Uid
t.Deleted = tag.Deleted
t.Name = tag.Name
t.TagGroupId = tag.TagGroupId
t.DisplayOrder = tag.DisplayOrder
t.Hidden = tag.Hidden
t.CreatedUnixTime = tag.CreatedUnixTime
@ -83,6 +89,7 @@ func (t *TransactionTag) ToTransactionTagInfoResponse() *TransactionTagInfoRespo
return &TransactionTagInfoResponse{
Id: t.TagId,
Name: t.Name,
TagGroupId: t.TagGroupId,
DisplayOrder: t.DisplayOrder,
Hidden: t.Hidden,
}
@ -103,5 +110,9 @@ func (s TransactionTagInfoResponseSlice) Swap(i, j int) {
// Less reports whether the first item is less than the second one
func (s TransactionTagInfoResponseSlice) Less(i, j int) bool {
if s[i].TagGroupId != s[j].TagGroupId {
return s[i].TagGroupId < s[j].TagGroupId
}
return s[i].DisplayOrder < s[j].DisplayOrder
}

View file

@ -0,0 +1,79 @@
package models
// TransactionTagGroup represents transaction tag group data stored in database
type TransactionTagGroup struct {
TagGroupId int64 `xorm:"PK"`
Uid int64 `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"`
CreatedUnixTime int64
UpdatedUnixTime int64
DeletedUnixTime int64
}
// TransactionTagGroupGetRequest represents all parameters of transaction tag group getting request
type TransactionTagGroupGetRequest struct {
Id int64 `form:"id,string" binding:"required,min=1"`
}
// TransactionTagGroupCreateRequest represents all parameters of transaction tag group creation request
type TransactionTagGroupCreateRequest struct {
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagGroupModifyRequest represents all parameters of transaction tag group modification request
type TransactionTagGroupModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagGroupMoveRequest represents all parameters of transaction tag group moving request
type TransactionTagGroupMoveRequest struct {
NewDisplayOrders []*TransactionTagGroupNewDisplayOrderRequest `json:"newDisplayOrders" binding:"required,min=1"`
}
// TransactionTagGroupNewDisplayOrderRequest represents a data pair of id and display order
type TransactionTagGroupNewDisplayOrderRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
DisplayOrder int32 `json:"displayOrder"`
}
// TransactionTagGroupDeleteRequest represents all parameters of transaction tag group deleting request
type TransactionTagGroupDeleteRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
}
// TransactionTagGroupInfoResponse represents a view-object of transaction tag group
type TransactionTagGroupInfoResponse struct {
Id int64 `json:"id,string"`
Name string `json:"name"`
DisplayOrder int32 `json:"displayOrder"`
}
// ToTransactionTagGroupInfoResponse returns a view-object according to database model
func (t *TransactionTagGroup) ToTransactionTagGroupInfoResponse() *TransactionTagGroupInfoResponse {
return &TransactionTagGroupInfoResponse{
Id: t.TagGroupId,
Name: t.Name,
DisplayOrder: t.DisplayOrder,
}
}
// TransactionTagGroupInfoResponseSlice represents the slice data structure of TransactionTagGroupInfoResponse
type TransactionTagGroupInfoResponseSlice []*TransactionTagGroupInfoResponse
// Len returns the count of items
func (s TransactionTagGroupInfoResponseSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s TransactionTagGroupInfoResponseSlice) 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 TransactionTagGroupInfoResponseSlice) Less(i, j int) bool {
return s[i].DisplayOrder < s[j].DisplayOrder
}

View file

@ -0,0 +1,30 @@
package models
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTransactionTagGroupInfoResponseSliceLess(t *testing.T) {
var transactionTagGroupRespSlice TransactionTagGroupInfoResponseSlice
transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{
Id: 1,
DisplayOrder: 3,
})
transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{
Id: 2,
DisplayOrder: 1,
})
transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{
Id: 3,
DisplayOrder: 2,
})
sort.Sort(transactionTagGroupRespSlice)
assert.Equal(t, int64(2), transactionTagGroupRespSlice[0].Id)
assert.Equal(t, int64(3), transactionTagGroupRespSlice[1].Id)
assert.Equal(t, int64(1), transactionTagGroupRespSlice[2].Id)
}

View file

@ -11,20 +11,41 @@ func TestTransactionTagInfoResponseSliceLess(t *testing.T) {
var transactionTagRespSlice TransactionTagInfoResponseSlice
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 1,
TagGroupId: 0,
DisplayOrder: 3,
})
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 2,
DisplayOrder: 1,
TagGroupId: 1,
DisplayOrder: 2,
})
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 3,
TagGroupId: 0,
DisplayOrder: 1,
})
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 4,
TagGroupId: 2,
DisplayOrder: 1,
})
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 5,
TagGroupId: 1,
DisplayOrder: 1,
})
transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{
Id: 6,
TagGroupId: 0,
DisplayOrder: 2,
})
sort.Sort(transactionTagRespSlice)
assert.Equal(t, int64(2), transactionTagRespSlice[0].Id)
assert.Equal(t, int64(3), transactionTagRespSlice[1].Id)
assert.Equal(t, int64(3), transactionTagRespSlice[0].Id)
assert.Equal(t, int64(6), transactionTagRespSlice[1].Id)
assert.Equal(t, int64(1), transactionTagRespSlice[2].Id)
assert.Equal(t, int64(5), transactionTagRespSlice[3].Id)
assert.Equal(t, int64(2), transactionTagRespSlice[4].Id)
assert.Equal(t, int64(4), transactionTagRespSlice[5].Id)
}

View file

@ -0,0 +1,220 @@
package services
import (
"time"
"xorm.io/xorm"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)
// TransactionTagGroupService represents transaction tag group service
type TransactionTagGroupService struct {
ServiceUsingDB
ServiceUsingUuid
}
// Initialize a transaction tag group service singleton instance
var (
TransactionTagGroups = &TransactionTagGroupService{
ServiceUsingDB: ServiceUsingDB{
container: datastore.Container,
},
ServiceUsingUuid: ServiceUsingUuid{
container: uuid.Container,
},
}
)
// GetAllTagGroupsByUid returns all transaction tag group models of user
func (s *TransactionTagGroupService) GetAllTagGroupsByUid(c core.Context, uid int64) ([]*models.TransactionTagGroup, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
var tagGroups []*models.TransactionTagGroup
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).Find(&tagGroups)
return tagGroups, err
}
// GetTagGroupByTagGroupId returns a transaction tag group model according to transaction tag group id
func (s *TransactionTagGroupService) GetTagGroupByTagGroupId(c core.Context, uid int64, tagGroupId int64) (*models.TransactionTagGroup, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}
if tagGroupId <= 0 {
return nil, errs.ErrTransactionTagGroupIdInvalid
}
tagGroup := &models.TransactionTagGroup{}
has, err := s.UserDataDB(uid).NewSession(c).ID(tagGroupId).Where("uid=? AND deleted=?", uid, false).Get(tagGroup)
if err != nil {
return nil, err
} else if !has {
return nil, errs.ErrTransactionTagGroupNotFound
}
return tagGroup, nil
}
// GetMaxDisplayOrder returns the max display order
func (s *TransactionTagGroupService) GetMaxDisplayOrder(c core.Context, uid int64) (int32, error) {
if uid <= 0 {
return 0, errs.ErrUserIdInvalid
}
tagGroup := &models.TransactionTagGroup{}
has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=?", uid, false).OrderBy("display_order desc").Limit(1).Get(tagGroup)
if err != nil {
return 0, err
}
if has {
return tagGroup.DisplayOrder, nil
} else {
return 0, nil
}
}
// CreateTagGroup saves a new transaction tag group model to database
func (s *TransactionTagGroupService) CreateTagGroup(c core.Context, tagGroup *models.TransactionTagGroup) error {
if tagGroup.Uid <= 0 {
return errs.ErrUserIdInvalid
}
tagGroup.TagGroupId = s.GenerateUuid(uuid.UUID_TYPE_TAG_GROUP)
if tagGroup.TagGroupId < 1 {
return errs.ErrSystemIsBusy
}
tagGroup.Deleted = false
tagGroup.CreatedUnixTime = time.Now().Unix()
tagGroup.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(tagGroup.Uid).DoTransaction(c, func(sess *xorm.Session) error {
_, err := sess.Insert(tagGroup)
return err
})
}
// ModifyTagGroup saves an existed transaction tag group model to database
func (s *TransactionTagGroupService) ModifyTagGroup(c core.Context, tagGroup *models.TransactionTagGroup) error {
if tagGroup.Uid <= 0 {
return errs.ErrUserIdInvalid
}
tagGroup.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(tagGroup.Uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.ID(tagGroup.TagGroupId).Cols("name", "updated_unix_time").Where("uid=? AND deleted=?", tagGroup.Uid, false).Update(tagGroup)
if err != nil {
return err
} else if updatedRows < 1 {
return errs.ErrTransactionTagGroupNotFound
}
return err
})
}
// ModifyTagGroupDisplayOrders updates display order of given transaction tag groups
func (s *TransactionTagGroupService) ModifyTagGroupDisplayOrders(c core.Context, uid int64, tagGroups []*models.TransactionTagGroup) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
for i := 0; i < len(tagGroups); i++ {
tagGroups[i].UpdatedUnixTime = time.Now().Unix()
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
for i := 0; i < len(tagGroups); i++ {
tagGroup := tagGroups[i]
updatedRows, err := sess.ID(tagGroup.TagGroupId).Cols("display_order", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(tagGroup)
if err != nil {
return err
} else if updatedRows < 1 {
return errs.ErrTransactionTagGroupNotFound
}
}
return nil
})
}
// DeleteTagGroup deletes an existed transaction tag group from database
func (s *TransactionTagGroupService) DeleteTagGroup(c core.Context, uid int64, tagGroupId int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
now := time.Now().Unix()
updateModel := &models.TransactionTagGroup{
Deleted: true,
DeletedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
exists, err := sess.Cols("uid", "deleted").Where("uid=? AND deleted=? AND tag_group_id=?", uid, false, tagGroupId).Limit(1).Exist(&models.TransactionTag{})
if err != nil {
return err
} else if exists {
return errs.ErrTransactionTagGroupInUseCannotBeDeleted
}
deletedRows, err := sess.ID(tagGroupId).Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel)
if err != nil {
return err
} else if deletedRows < 1 {
return errs.ErrTransactionTagGroupNotFound
}
return err
})
}
// DeleteAllTagGroups deletes all existed transaction tag groups from database
func (s *TransactionTagGroupService) DeleteAllTagGroups(c core.Context, uid int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
now := time.Now().Unix()
updateModel := &models.TransactionTagGroup{
Deleted: true,
DeletedUnixTime: now,
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
exists, err := sess.Cols("uid", "deleted").Where("uid=? AND deleted=? AND tag_group_id>?", uid, false, 0).Limit(1).Exist(&models.TransactionTag{})
if err != nil {
return err
} else if exists {
return errs.ErrTransactionTagGroupInUseCannotBeDeleted
}
_, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel)
if err != nil {
return err
}
return nil
})
}

View file

@ -101,13 +101,13 @@ func (s *TransactionTagService) GetTagsByTagIds(c core.Context, uid int64, tagId
}
// GetMaxDisplayOrder returns the max display order
func (s *TransactionTagService) GetMaxDisplayOrder(c core.Context, uid int64) (int32, error) {
func (s *TransactionTagService) GetMaxDisplayOrder(c core.Context, uid int64, tagGroupId int64) (int32, error) {
if uid <= 0 {
return 0, errs.ErrUserIdInvalid
}
tag := &models.TransactionTag{}
has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=?", uid, false).OrderBy("display_order desc").Limit(1).Get(tag)
has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=? AND tag_group_id=?", uid, false, tagGroupId).OrderBy("display_order desc").Limit(1).Get(tag)
if err != nil {
return 0, err
@ -294,23 +294,25 @@ func (s *TransactionTagService) CreateTags(c core.Context, uid int64, tags []*mo
}
// ModifyTag saves an existed transaction tag model to database
func (s *TransactionTagService) ModifyTag(c core.Context, tag *models.TransactionTag) error {
func (s *TransactionTagService) ModifyTag(c core.Context, tag *models.TransactionTag, tagNameChanged bool) error {
if tag.Uid <= 0 {
return errs.ErrUserIdInvalid
}
exists, err := s.ExistsTagName(c, tag.Uid, tag.Name)
if tagNameChanged {
exists, err := s.ExistsTagName(c, tag.Uid, tag.Name)
if err != nil {
return err
} else if exists {
return errs.ErrTransactionTagNameAlreadyExists
if err != nil {
return err
} else if exists {
return errs.ErrTransactionTagNameAlreadyExists
}
}
tag.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(tag.Uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.ID(tag.TagId).Cols("name", "updated_unix_time").Where("uid=? AND deleted=?", tag.Uid, false).Update(tag)
updatedRows, err := sess.ID(tag.TagId).Cols("name", "tag_group_id", "display_order", "updated_unix_time").Where("uid=? AND deleted=?", tag.Uid, false).Update(tag)
if err != nil {
return err

View file

@ -15,4 +15,5 @@ const (
UUID_TYPE_TEMPLATE UuidType = 7
UUID_TYPE_PICTURE UuidType = 8
UUID_TYPE_EXPLORER UuidType = 9
UUID_TYPE_TAG_GROUP UuidType = 10
)

View file

@ -0,0 +1,141 @@
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { values } from '@/core/base.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
export type TransactionTagWithGroupHeader = TransactionTag | {
type: 'subheader';
title: string;
}
export interface CommonTransactionTagSelectionProps {
modelValue: string[];
allowAddNewTag?: boolean;
}
export function useTransactionTagSelectionBase(props: CommonTransactionTagSelectionProps, useClonedModelValue?: boolean) {
const { tt } = useI18n();
const transactionTagsStore = useTransactionTagsStore();
const clonedModelValue = ref<string[]>(useClonedModelValue ? Array.from(props.modelValue) : []);
const tagSearchContent = ref<string>('');
const selectedTagIds = computed<Record<string, boolean>>(() => {
const ret: Record<string, boolean> = {};
if (useClonedModelValue) {
for (const tagId of clonedModelValue.value) {
ret[tagId] = true;
}
} else {
for (const tagId of props.modelValue) {
ret[tagId] = true;
}
}
return ret;
});
const lowerCaseTagSearchContent = computed<string>(() => tagSearchContent.value.toLowerCase());
const allTagsWithGroupHeader = computed<TransactionTagWithGroupHeader[]>(() => getTagsWithGroupHeader(tag => {
if (!tag.hidden) {
return true;
}
if (selectedTagIds.value[tag.id]) {
return true;
}
if (lowerCaseTagSearchContent.value && tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent.value) >= 0 && isAllFilteredTagHidden.value) {
return true;
}
return false;
}));
const filteredTagsWithGroupHeader = computed<TransactionTagWithGroupHeader[]>(() => getTagsWithGroupHeader(tag => {
if (lowerCaseTagSearchContent.value) {
if (tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent.value) >= 0 && (!tag.hidden || isAllFilteredTagHidden.value)) {
return true;
} else {
return false;
}
}
return !tag.hidden || !!selectedTagIds.value[tag.id];
}));
const isAllFilteredTagHidden = computed<boolean>(() => {
const lowerCaseTagSearchContent = tagSearchContent.value.toLowerCase();
let hiddenCount = 0;
for (const tag of values(transactionTagsStore.allTransactionTagsMap)) {
if (!lowerCaseTagSearchContent || tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent) >= 0) {
if (!tag.hidden) {
return false;
}
hiddenCount++;
}
}
return hiddenCount > 0;
});
function getTagsWithGroupHeader(tagFilterFn: (tag: TransactionTag) => boolean): TransactionTagWithGroupHeader[] {
const result: TransactionTagWithGroupHeader[] = [];
const tagsInDefaultGroup = transactionTagsStore.allTransactionTagsByGroupMap[DEFAULT_TAG_GROUP_ID];
if (tagsInDefaultGroup && tagsInDefaultGroup.length > 0) {
const visibleTags = tagsInDefaultGroup.filter(tag => tagFilterFn(tag));
if (visibleTags.length > 0) {
result.push({
type: 'subheader',
title: tt('Default Group')
});
result.push(...visibleTags);
}
}
for (const tagGroup of transactionTagsStore.allTransactionTagGroups) {
const tags = transactionTagsStore.allTransactionTagsByGroupMap[tagGroup.id];
if (!tags || tags.length < 1) {
continue;
}
const visibleTags = tags.filter(tag => tagFilterFn(tag));
if (visibleTags.length > 0) {
result.push({
type: 'subheader',
title: tagGroup.name
});
result.push(...visibleTags);
}
}
return result;
}
return {
// states
clonedModelValue,
tagSearchContent,
// computed states
selectedTagIds,
allTagsWithGroupHeader,
filteredTagsWithGroupHeader
};
}

View file

@ -1,20 +1,20 @@
<template>
<v-dialog max-width="500" :persistent="oldExplorerName !== newExplorerName" v-model="showState">
<v-dialog max-width="500" :persistent="oldName !== newName" v-model="showState">
<v-card class="pa-sm-1 pa-md-2">
<template #title>
<h4 class="text-h4 text-wrap">{{ dialogTitle || tt('Rename Explorer') }}</h4>
<h4 class="text-h4 text-wrap">{{ dialogTitle || defaultTitle }}</h4>
</template>
<v-card-text class="w-100 d-flex justify-center">
<v-text-field persistent-placeholder
:autofocus="true"
:label="tt('Explorer Name')"
:placeholder="tt('Explorer Name')"
v-model="newExplorerName"
:label="label"
:placeholder="placeholder"
v-model="newName"
@keyup.enter="save" />
</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 color="primary" :disabled="!newExplorerName || oldExplorerName === newExplorerName" @click="save">
<v-btn color="primary" :disabled="!newName || oldName === newName" @click="save">
{{ tt('Save') }}
</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">
@ -31,6 +31,12 @@ import { ref } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
defineProps<{
label?: string;
placeholder?: string;
defaultTitle?: string;
}>();
const { tt } = useI18n();
let resolveFunc: ((name: string) => void) | null = null;
@ -38,14 +44,14 @@ let rejectFunc: ((reason?: unknown) => void) | null = null;
const showState = ref<boolean>(false);
const dialogTitle = ref<string | undefined>(undefined);
const oldExplorerName = ref<string>('');
const newExplorerName = ref<string>('');
const oldName = ref<string>('');
const newName = ref<string>('');
function open(currentExplorerName: string, title?: string): Promise<string> {
function open(currentName: string, title?: string): Promise<string> {
showState.value = true;
dialogTitle.value = title;
oldExplorerName.value = currentExplorerName;
newExplorerName.value = currentExplorerName;
oldName.value = currentName;
newName.value = currentName;
return new Promise((resolve, reject) => {
resolveFunc = resolve;
@ -54,11 +60,11 @@ function open(currentExplorerName: string, title?: string): Promise<string> {
}
function save(): void {
if (!newExplorerName.value || oldExplorerName.value === newExplorerName.value) {
if (!newName.value || oldName.value === newName.value) {
return;
}
resolveFunc?.(newExplorerName.value);
resolveFunc?.(newName.value);
showState.value = false;
}

View file

@ -0,0 +1,134 @@
<template>
<v-autocomplete
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
:density="density"
:variant="variant"
:closable-chips="!readonly"
:readonly="readonly"
:disabled="disabled"
:label="showLabel ? tt('Tags') : undefined"
:placeholder="tt('None')"
:items="allTagsWithGroupHeader"
:model-value="modelValue"
v-model:search="tagSearchContent"
@update:modelValue="updateModelValue"
>
<template #chip="{ props, item }">
<v-chip :prepend-icon="mdiPound" :text="item.title" v-bind="props"/>
</template>
<template #subheader="{ props }">
<v-list-subheader>{{ props['title'] }}</v-list-subheader>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="item.raw instanceof TransactionTag && !item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
<v-list-item :disabled="true" v-bind="props" v-else-if="item.raw instanceof TransactionTag && item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
<template #no-data>
<v-list class="py-0">
<v-list-item v-if="tagSearchContent && allowAddNewTag" @click="saveNewTag(tagSearchContent)">{{ tt('format.misc.addNewTag', { tag: tagSearchContent }) }}</v-list-item>
<v-list-item v-else-if="!tagSearchContent || !allowAddNewTag">{{ tt('No available tag') }}</v-list-item>
</v-list>
</template>
</v-autocomplete>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { type CommonTransactionTagSelectionProps, useTransactionTagSelectionBase } from '@/components/base/TransactionTagSelectionBase.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import type { ComponentDensity, InputVariant } from '@/lib/ui/desktop.ts';
import {
mdiPound
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
interface DesktopTransactionTagSelectionProps extends CommonTransactionTagSelectionProps {
density?: ComponentDensity;
variant?: InputVariant;
readonly?: boolean;
disabled?: boolean;
showLabel?: boolean;
}
const props = defineProps<DesktopTransactionTagSelectionProps>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void;
(e: 'tag:saving', state: boolean, tagName: string): void;
}>();
const { tt } = useI18n();
const {
tagSearchContent,
allTagsWithGroupHeader
} = useTransactionTagSelectionBase(props);
const transactionTagsStore = useTransactionTagsStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
function saveNewTag(tagName: string): void {
emit('tag:saving', true, tagName);
transactionTagsStore.saveTag({
tag: TransactionTag.createNewTag(tagName)
}).then(tag => {
emit('tag:saving', false, tagName);
if (tag && tag.id) {
const newValue: string[] = [...props.modelValue];
newValue.push(tag.id);
updateModelValue(newValue);
}
}).catch(error => {
emit('tag:saving', false, tagName);
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function updateModelValue(newValue: string[]) {
emit('update:modelValue', newValue);
}
</script>

View file

@ -8,43 +8,55 @@
<f7-link sheet-close icon-f7="xmark"></f7-link>
</div>
<f7-searchbar ref="searchbar" custom-searchs
:value="filterContent"
:value="tagSearchContent"
:placeholder="tt('Find tag')"
:disable-button="false"
v-if="enableFilter"
@input="filterContent = $event.target.value"
@input="tagSearchContent = $event.target.value"
@focus="onSearchBarFocus">
</f7-searchbar>
<div class="right">
<f7-button round fill icon-f7="checkmark_alt" @click="save"
v-if="allTags && allTags.length && !noAvailableTag"></f7-button>
v-if="filteredTagsWithGroupHeader && filteredTagsWithGroupHeader.length > 0"></f7-button>
<f7-link icon-f7="plus" :class="{'disabled': newTag}" @click="addNewTag"
v-if="!allTags || !allTags.length || noAvailableTag"></f7-link>
v-if="!filteredTagsWithGroupHeader || filteredTagsWithGroupHeader.length < 1"></f7-link>
</div>
</f7-toolbar>
<f7-page-content :class="'margin-top ' + heightClass">
<f7-list class="no-margin-top no-margin-bottom" v-if="(!allTags || !allTags.length || noAvailableTag) && !newTag">
<f7-list class="no-margin-top no-margin-bottom" v-if="(!filteredTagsWithGroupHeader || filteredTagsWithGroupHeader.length < 1) && !newTag">
<f7-list-item :title="tt('No available tag')"></f7-list-item>
</f7-list>
<f7-list dividers class="no-margin-top no-margin-bottom tag-selection-list" v-else-if="(allTags && allTags.length && !noAvailableTag) || newTag">
<f7-list-item checkbox
:class="isChecked(tag.id) ? 'list-item-selected' : ''"
:value="tag.id"
:checked="isChecked(tag.id)"
:key="tag.id"
v-for="tag in allTags"
@change="changeTagSelection">
<template #title>
<f7-block class="no-padding no-margin">
<f7-list dividers class="no-margin-top no-margin-bottom tag-selection-list" v-else-if="(filteredTagsWithGroupHeader && filteredTagsWithGroupHeader.length > 0) || newTag">
<template :key="(tag instanceof TransactionTag) ? tag.id : `${tag.type}-${index}-${tag.title}`"
v-for="(tag, index) in filteredTagsWithGroupHeader">
<f7-list-item group-title v-if="!(tag instanceof TransactionTag)">
<div class="tag-selection-list-item">
{{ tag.title }}
</div>
</f7-list-item>
<f7-list-item checkbox
:class="{ 'list-item-selected': selectedTagIds[tag.id], 'disabled': tag.hidden && !selectedTagIds[tag.id] }"
:value="tag.id"
:checked="selectedTagIds[tag.id]"
:key="tag.id"
v-else-if="tag instanceof TransactionTag"
@change="changeTagSelection">
<template #media>
<f7-icon class="transaction-tag-icon" f7="number">
<f7-badge color="gray" class="right-bottom-icon" v-if="tag.hidden">
<f7-icon f7="eye_slash_fill"></f7-icon>
</f7-badge>
</f7-icon>
</template>
<template #title>
<div class="display-flex">
<f7-icon class="transaction-tag-icon" f7="number"></f7-icon>
<div class="tag-selection-list-item list-item-valign-middle padding-inline-start-half">
{{ tag.name }}
</div>
</div>
</f7-block>
</template>
</f7-list-item>
</template>
</f7-list-item>
</template>
<f7-list-item link="#" no-chevron
:title="tt('Add new tag')"
v-if="allowAddNewTag && !newTag"
@ -85,11 +97,12 @@
</template>
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue';
import { ref, useTemplateRef } from 'vue';
import type { Sheet, Searchbar } from 'framework7/types';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents, showLoading, hideLoading } from '@/lib/ui/mobile.ts';
import { type CommonTransactionTagSelectionProps, useTransactionTagSelectionBase } from '@/components/base/TransactionTagSelectionBase.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
@ -97,12 +110,12 @@ import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { scrollToSelectedItem } from '@/lib/ui/common.ts';
import { type Framework7Dom, scrollSheetToTop } from '@/lib/ui/mobile.ts';
const props = defineProps<{
modelValue: string[];
allowAddNewTag?: boolean;
interface MobileransactionTagSelectionProps extends CommonTransactionTagSelectionProps {
enableFilter?: boolean;
show: boolean;
}>();
}
const props = defineProps<MobileransactionTagSelectionProps>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void;
@ -112,81 +125,49 @@ const emit = defineEmits<{
const { tt } = useI18n();
const { showToast } = useI18nUIComponents();
const {
clonedModelValue,
tagSearchContent,
selectedTagIds,
filteredTagsWithGroupHeader
} = useTransactionTagSelectionBase(props, true);
const transactionTagsStore = useTransactionTagsStore();
const sheet = useTemplateRef<Sheet.Sheet>('sheet');
const searchbar = useTemplateRef<Searchbar.Searchbar>('searchbar');
const filterContent = ref<string>('');
const selectedItemIds = ref<string[]>(Array.from(props.modelValue));
const newTag = ref<TransactionTag | null>(null);
const heightClass = ref<string>(getHeightClass());
const allTags = computed<TransactionTag[]>(() => {
const finalTags: TransactionTag[] = [];
for (const tag of transactionTagsStore.allTransactionTags) {
if (tag.hidden && !isChecked(tag.id)) {
continue;
}
if (!props.enableFilter || !filterContent.value) {
finalTags.push(tag);
continue;
}
if (tag.name.toLowerCase().indexOf(filterContent.value.toLowerCase()) >= 0) {
finalTags.push(tag);
}
}
return finalTags;
});
const noAvailableTag = computed<boolean>(() => {
if (transactionTagsStore.allTransactionTags) {
for (const transactionTag of transactionTagsStore.allTransactionTags) {
if (!transactionTag.hidden) {
return false;
}
}
}
return true;
});
function getHeightClass(): string {
if (transactionTagsStore.allTransactionTags && transactionTagsStore.allVisibleTagsCount > 6) {
if (filteredTagsWithGroupHeader.value.length > 6) {
return 'tag-selection-huge-sheet';
} else if (transactionTagsStore.allTransactionTags && transactionTagsStore.allVisibleTagsCount > 3) {
} else if (filteredTagsWithGroupHeader.value.length > 3) {
return 'tag-selection-large-sheet';
} else {
return 'tag-selection-default-sheet';
}
}
function isChecked(itemId: string): boolean {
return selectedItemIds.value.indexOf(itemId) >= 0;
}
function changeTagSelection(e: Event): void {
const target = e.target as HTMLInputElement;
const tagId = target.value;
const index = selectedItemIds.value.indexOf(tagId);
const index = clonedModelValue.value.indexOf(tagId);
if (target.checked) {
if (index < 0) {
selectedItemIds.value.push(tagId);
clonedModelValue.value.push(tagId);
}
} else {
if (index >= 0) {
selectedItemIds.value.splice(index, 1);
clonedModelValue.value.splice(index, 1);
}
}
}
function save(): void {
emit('update:modelValue', selectedItemIds.value);
emit('update:modelValue', clonedModelValue.value);
emit('update:show', false);
}
@ -208,7 +189,7 @@ function saveNewTag(): void {
newTag.value = null;
if (tag && tag.id) {
selectedItemIds.value.push(tag.id);
clonedModelValue.value.push(tag.id);
}
}).catch(error => {
hideLoading();
@ -228,14 +209,14 @@ function onSearchBarFocus(): void {
}
function onSheetOpen(event: { $el: Framework7Dom }): void {
selectedItemIds.value = Array.from(props.modelValue);
clonedModelValue.value = Array.from(props.modelValue);
newTag.value = null;
scrollToSelectedItem(event.$el[0], '.sheet-modal-inner', '.page-content', 'li.list-item-selected');
}
function onSheetClosed(): void {
emit('update:show', false);
filterContent.value = '';
tagSearchContent.value = '';
searchbar.value?.clear();
}
</script>
@ -262,6 +243,12 @@ function onSheetClosed(): void {
}
}
.tag-selection-list.list.list-dividers li.list-group-title:first-child,
.tag-selection-list.list.list-dividers li.list-group-title.actual-first-child {
margin-top: 10px;
border-radius: inherit;
}
.tag-selection-list.list .item-media + .item-inner {
margin-inline-start: 0;
}

1
src/consts/tag.ts Normal file
View file

@ -0,0 +1 @@
export const DEFAULT_TAG_GROUP_ID: string = '0';

View file

@ -94,6 +94,7 @@ import FiscalYearStartSelect from '@/components/desktop/FiscalYearStartSelect.vu
import ColorSelect from '@/components/desktop/ColorSelect.vue';
import IconSelect from '@/components/desktop/IconSelect.vue';
import TwoColumnSelect from '@/components/desktop/TwoColumnSelect.vue';
import TransactionTagAutoComplete from '@/components/desktop/TransactionTagAutoComplete.vue';
import ScheduleFrequencySelect from '@/components/desktop/ScheduleFrequencySelect.vue';
import StepsBar from '@/components/desktop/StepsBar.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
@ -102,6 +103,7 @@ import PieChartComponent from '@/components/desktop/PieChart.vue';
import RadarChartComponent from '@/components/desktop/RadarChart.vue';
import AxisChart from '@/components/desktop/AxisChart.vue';
import TrendsChart from '@/components/desktop/TrendsChart.vue';
import RenameDialog from '@/components/desktop/RenameDialog.vue';
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
import MonthRangeSelectionDialog from '@/components/desktop/MonthRangeSelectionDialog.vue';
@ -539,6 +541,7 @@ app.component('FiscalYearStartSelect', FiscalYearStartSelect);
app.component('ColorSelect', ColorSelect);
app.component('IconSelect', IconSelect);
app.component('TwoColumnSelect', TwoColumnSelect);
app.component('TransactionTagAutoComplete', TransactionTagAutoComplete);
app.component('ScheduleFrequencySelect', ScheduleFrequencySelect);
app.component('StepsBar', StepsBar);
app.component('ConfirmDialog', ConfirmDialog);
@ -547,6 +550,7 @@ app.component('PieChart', PieChartComponent);
app.component('RadarChart', RadarChartComponent);
app.component('AxisChart', AxisChart);
app.component('TrendsChart', TrendsChart);
app.component('RenameDialog', RenameDialog);
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('MonthSelectionDialog', MonthSelectionDialog);
app.component('MonthRangeSelectionDialog', MonthRangeSelectionDialog);

View file

@ -100,6 +100,13 @@ import type {
TransactionPictureUnusedDeleteRequest,
TransactionPictureInfoBasicResponse
} from '@/models/transaction_picture_info.ts';
import type {
TransactionTagGroupCreateRequest,
TransactionTagGroupModifyRequest,
TransactionTagGroupMoveRequest,
TransactionTagGroupDeleteRequest,
TransactionTagGroupInfoResponse
} from '@/models/transaction_tag_group.ts';
import type {
TransactionTagCreateRequest,
TransactionTagCreateBatchRequest,
@ -705,6 +712,24 @@ export default {
deleteTransactionCategory: (req: TransactionCategoryDeleteRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transaction/categories/delete.json', req);
},
getAllTransactionTagGroups: (): ApiResponsePromise<TransactionTagGroupInfoResponse[]> => {
return axios.get<ApiResponse<TransactionTagInfoResponse[]>>('v1/transaction/tags/groups/list.json');
},
getTransactionTagGroup: ({ id }: { id: string }): ApiResponsePromise<TransactionTagGroupInfoResponse> => {
return axios.get<ApiResponse<TransactionTagInfoResponse>>('v1/transaction/tags/groups/get.json?id=' + id);
},
addTransactionTagGroup: (req: TransactionTagGroupCreateRequest): ApiResponsePromise<TransactionTagGroupInfoResponse> => {
return axios.post<ApiResponse<TransactionTagInfoResponse>>('v1/transaction/tags/groups/add.json', req);
},
modifyTransactionTagGroup: (req: TransactionTagGroupModifyRequest): ApiResponsePromise<TransactionTagGroupInfoResponse> => {
return axios.post<ApiResponse<TransactionTagInfoResponse>>('v1/transaction/tags/groups/modify.json', req);
},
moveTransactionTagGroup: (req: TransactionTagGroupMoveRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transaction/tags/groups/move.json', req);
},
deleteTransactionTagGroup: (req: TransactionTagGroupDeleteRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transaction/tags/groups/delete.json', req);
},
getAllTransactionTags: (): ApiResponsePromise<TransactionTagInfoResponse[]> => {
return axios.get<ApiResponse<TransactionTagInfoResponse[]>>('v1/transaction/tags/list.json');
},

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Abfrageelemente dürfen nicht leer sein",
"query items too much": "Zu viele Abfrageelemente",
"query items have invalid item": "Ungültiges Element in Abfrageelementen",
@ -1485,6 +1488,8 @@
"Version": "Version",
"Edit": "Bearbeiten",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Entfernen",
"Delete": "Löschen",
"Duplicate": "Duplizieren",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Versteckte Transaktionskategorien anzeigen",
"Hide Hidden Transaction Categories": "Versteckte Transaktionskategorien ausblenden",
"Transaction Tags": "Transaktions-Tags",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Tag-Titel",
"No available tag": "Kein verfügbaren Tag",
"Find tag": "Tag finden",
"Unable to retrieve tag list": "Tag-Liste kann nicht abgerufen werden",
"Tag list is up to date": "Tag-Liste ist aktuell",
"Tag list has been updated": "Tag-Liste wurde aktualisiert",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Neuen Tag hinzufügen",
"Unable to add tag": "Tag kann nicht hinzugefügt werden",
"Unable to save tag": "Tag kann nicht gespeichert werden",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "There are no query items",
"query items too much": "There are too many query items",
"query items have invalid item": "There is invalid item in query items",
@ -1485,6 +1488,8 @@
"Version": "Version",
"Edit": "Edit",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Remove",
"Delete": "Delete",
"Duplicate": "Duplicate",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Show Hidden Transaction Categories",
"Hide Hidden Transaction Categories": "Hide Hidden Transaction Categories",
"Transaction Tags": "Transaction Tags",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Tag Title",
"No available tag": "No available tag",
"Find tag": "Find tag",
"Unable to retrieve tag list": "Unable to retrieve tag list",
"Tag list is up to date": "Tag list is up to date",
"Tag list has been updated": "Tag list has been updated",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Add new tag",
"Unable to add tag": "Unable to add tag",
"Unable to save tag": "Unable to save tag",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "There are no query items",
"query items too much": "There are too many query items",
"query items have invalid item": "Hay un elemento no válido en los elementos de consulta",
@ -1485,6 +1488,8 @@
"Version": "Versión",
"Edit": "Editar",
"View": "Ver",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Eliminar",
"Delete": "Borrar",
"Duplicate": "Duplicar",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Mostrar categorías de transacciones ocultas",
"Hide Hidden Transaction Categories": "Ocultar categorías de transacciones ocultas",
"Transaction Tags": "Etiquetas de Transacciones",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Título de la Etiqueta",
"No available tag": "No hay etiquetas disponibles",
"Find tag": "Find tag",
"Unable to retrieve tag list": "No se puede recuperar la lista de etiquetas",
"Tag list is up to date": "La lista de etiquetas está actualizada.",
"Tag list has been updated": "La lista de etiquetas ha sido actualizada.",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Agregar nueva etiqueta",
"Unable to add tag": "No se puede agregar la etiqueta",
"Unable to save tag": "No se puede guardar la etiqueta",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Il n'y a pas d'éléments de requête",
"query items too much": "Il y a trop d'éléments de requête",
"query items have invalid item": "Il y a un élément invalide dans les éléments de requête",
@ -1485,6 +1488,8 @@
"Version": "Version",
"Edit": "Modifier",
"View": "Voir",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Supprimer",
"Delete": "Supprimer",
"Duplicate": "Dupliquer",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Afficher les catégories de transaction masquées",
"Hide Hidden Transaction Categories": "Masquer les catégories de transaction masquées",
"Transaction Tags": "Étiquettes de transaction",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Titre d'étiquette",
"No available tag": "Aucune étiquette disponible",
"Find tag": "Rechercher une étiquette",
"Unable to retrieve tag list": "Impossible de récupérer la liste des étiquettes",
"Tag list is up to date": "La liste des étiquettes est à jour",
"Tag list has been updated": "La liste des étiquettes a été mise à jour",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Ajouter une nouvelle étiquette",
"Unable to add tag": "Impossible d'ajouter l'étiquette",
"Unable to save tag": "Impossible d'enregistrer l'étiquette",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Non ci sono elementi di query",
"query items too much": "Ci sono troppi elementi di query",
"query items have invalid item": "C'è un elemento non valido negli elementi di query",
@ -1485,6 +1488,8 @@
"Version": "Versione",
"Edit": "Modifica",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Rimuovi",
"Delete": "Elimina",
"Duplicate": "Duplica",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Mostra categorie transazione nascoste",
"Hide Hidden Transaction Categories": "Nascondi categorie transazione nascoste",
"Transaction Tags": "Tag transazione",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Titolo tag",
"No available tag": "Nessun tag disponibile",
"Find tag": "Trova tag",
"Unable to retrieve tag list": "Impossibile recuperare l'elenco dei tag",
"Tag list is up to date": "Elenco tag aggiornato",
"Tag list has been updated": "Elenco tag aggiornato",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Aggiungi nuovo tag",
"Unable to add tag": "Impossibile aggiungere il tag",
"Unable to save tag": "Impossibile salvare il tag",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "クエリ項目がありません",
"query items too much": "クエリ項目が多すぎます",
"query items have invalid item": "クエリ項目に無効な項目があります",
@ -1485,6 +1488,8 @@
"Version": "バージョン",
"Edit": "編集",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "削除",
"Delete": "削除",
"Duplicate": "複製",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "非表示取引カテゴリを表示",
"Hide Hidden Transaction Categories": "非表示取引カテゴリを非表示",
"Transaction Tags": "取引タグ",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "タグタイトル",
"No available tag": "利用可能なタグはありません",
"Find tag": "タグを見つける",
"Unable to retrieve tag list": "タグリストを取得できません",
"Tag list is up to date": "タグリストは最新です",
"Tag list has been updated": "タグリストが更新されました",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "新しいタグを追加",
"Unable to add tag": "タグを追加できません",
"Unable to save tag": "タグを保存できません",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "ವಿಚರಣೆ ಐಟಂಗಳಿಲ್ಲ",
"query items too much": "ವಿಚರಣೆ ಐಟಂಗಳ ಸಂಖ್ಯೆ ಹೆಚ್ಚು",
"query items have invalid item": "ವಿಚರಣೆ ಐಟಂಗಳಲ್ಲಿ ಅಮಾನ್ಯ ಐಟಂ ಇದೆ",
@ -1485,6 +1488,8 @@
"Version": "ಆವೃತ್ತಿ",
"Edit": "ತಿದ್ದು",
"View": "ನೋಡು",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "ತೆಗೆದುಹಾಕು",
"Delete": "ಅಳಿಸು",
"Duplicate": "ನಕಲು ಮಾಡು",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "ಮರೆಮಾಡಿದ ವಹಿವಾಟು ವರ್ಗಗಳನ್ನು ತೋರಿಸಿ",
"Hide Hidden Transaction Categories": "ಮರೆಮಾಡಿದ ವಹಿವಾಟು ವರ್ಗಗಳನ್ನು ಮರೆಮಾಡಿ",
"Transaction Tags": "ವಹಿವಾಟು ಟ್ಯಾಗ್‌ಗಳು",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "ಟ್ಯಾಗ್ ಶೀರ್ಷಿಕೆ",
"No available tag": "ಯಾವುದೇ ಟ್ಯಾಗ್ ಲಭ್ಯವಿಲ್ಲ",
"Find tag": "ಟ್ಯಾಗ್ ಹುಡುಕಿ",
"Unable to retrieve tag list": "ಟ್ಯಾಗ್ ಪಟ್ಟಿ ಪಡೆಯಲು ಸಾಧ್ಯವಾಗಿಲ್ಲ",
"Tag list is up to date": "ಟ್ಯಾಗ್ ಪಟ್ಟಿ ನವೀಕೃತವಾಗಿದೆ",
"Tag list has been updated": "ಟ್ಯಾಗ್ ಪಟ್ಟಿ ನವೀಕರಿಸಲಾಗಿದೆ",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "ಹೊಸ ಟ್ಯಾಗ್ ಸೇರಿಸಿ",
"Unable to add tag": "ಟ್ಯಾಗ್ ಸೇರಿಸಲು ಸಾಧ್ಯವಾಗಿಲ್ಲ",
"Unable to save tag": "ಟ್ಯಾಗ್ ಉಳಿಸಲು ಸಾಧ್ಯವಾಗಿಲ್ಲ",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "쿼리 항목이 비어 있을 수 없습니다.",
"query items too much": "쿼리 항목이 너무 많습니다.",
"query items have invalid item": "쿼리 항목에 유효하지 않은 항목이 있습니다.",
@ -1485,6 +1488,8 @@
"Version": "버전",
"Edit": "편집",
"View": "보기",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "제거",
"Delete": "삭제",
"Duplicate": "복제",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "숨겨진 거래 카테고리 표시",
"Hide Hidden Transaction Categories": "숨겨진 거래 카테고리 숨기기",
"Transaction Tags": "거래 태그",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "태그 제목",
"No available tag": "사용 가능한 태그가 없습니다.",
"Find tag": "태그 찾기",
"Unable to retrieve tag list": "태그 목록을 가져올 수 없습니다.",
"Tag list is up to date": "태그 목록이 최신 상태입니다.",
"Tag list has been updated": "태그 목록이 업데이트되었습니다.",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "새 태그 추가",
"Unable to add tag": "태그를 추가할 수 없습니다.",
"Unable to save tag": "태그를 저장할 수 없습니다.",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Geen zoekitems opgegeven",
"query items too much": "Te veel zoekitems",
"query items have invalid item": "Ongeldig item in zoekitems",
@ -1485,6 +1488,8 @@
"Version": "Versie",
"Edit": "Bewerken",
"View": "Bekijken",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Verwijderen",
"Delete": "Verwijderen",
"Duplicate": "Dupliceren",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Verborgen transactiecategorieën tonen",
"Hide Hidden Transaction Categories": "Verborgen transactiecategorieën verbergen",
"Transaction Tags": "Transactietags",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Tagtitel",
"No available tag": "Geen beschikbare tag",
"Find tag": "Tag zoeken",
"Unable to retrieve tag list": "Kan taglijst niet ophalen",
"Tag list is up to date": "Taglijst is up-to-date",
"Tag list has been updated": "Taglijst is bijgewerkt",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Nieuwe tag toevoegen",
"Unable to add tag": "Kan tag niet toevoegen",
"Unable to save tag": "Kan tag niet opslaan",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Não há itens de consulta",
"query items too much": "Há muitos itens de consulta",
"query items have invalid item": "Há item inválido nos itens de consulta",
@ -1485,6 +1488,8 @@
"Version": "Versão",
"Edit": "Editar",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Remover",
"Delete": "Excluir",
"Duplicate": "Duplicar",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Mostrar Categorias de Transações Ocultas",
"Hide Hidden Transaction Categories": "Ocultar Categorias de Transações Ocultas",
"Transaction Tags": "Tags de Transação",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Título da Tag",
"No available tag": "Nenhuma tag disponível",
"Find tag": "Encontrar tag",
"Unable to retrieve tag list": "Não foi possível recuperar a lista de tags",
"Tag list is up to date": "A lista de tags está atualizada",
"Tag list has been updated": "A lista de tags foi atualizada",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Adicionar nova tag",
"Unable to add tag": "Não foi possível adicionar tag",
"Unable to save tag": "Não foi possível salvar tag",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Нет элементов запроса",
"query items too much": "Слишком много элементов запроса",
"query items have invalid item": "В элементах запроса присутствует недопустимый элемент",
@ -1485,6 +1488,8 @@
"Version": "Версия",
"Edit": "Редактировать",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Удалить",
"Delete": "Удалить",
"Duplicate": "Дублировать",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Показать скрытые категории транзакций",
"Hide Hidden Transaction Categories": "Скрыть скрытые категории транзакций",
"Transaction Tags": "Теги транзакций",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Название тега",
"No available tag": "Нет доступных тегов",
"Find tag": "Find tag",
"Unable to retrieve tag list": "Не удалось получить список тегов",
"Tag list is up to date": "Список тегов актуален",
"Tag list has been updated": "Список тегов обновлен",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Добавить новый тег",
"Unable to add tag": "Не удалось добавить тег",
"Unable to save tag": "Не удалось сохранить тег",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Poizvedbeni elementi ne morejo biti prazni",
"query items too much": "Preveč poizvedbenih elementov",
"query items have invalid item": "Med poizvedbenimi elementi je neveljaven element",
@ -1485,6 +1488,8 @@
"Version": "Različica",
"Edit": "Uredi",
"View": "Ogled",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Odstrani",
"Delete": "Izbriši",
"Duplicate": "Podvoji",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Prikaži skrite kategorije transakcij",
"Hide Hidden Transaction Categories": "Skrij skrite kategorije transakcij",
"Transaction Tags": "Oznake transakcij",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Naslov oznake",
"No available tag": "Ni razpoložljivih oznak",
"Find tag": "Poišči oznako",
"Unable to retrieve tag list": "Seznama oznak ni mogoče pridobiti",
"Tag list is up to date": "Seznam oznak je posodobljen",
"Tag list has been updated": "Seznam oznak je bil posodobljen",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Dodaj novo oznako",
"Unable to add tag": "Oznake ni mogoče dodati",
"Unable to save tag": "Oznake ni mogoče shraniti",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "ไม่มีรายการสำหรับค้นหา",
"query items too much": "รายการค้นหามากเกินไป",
"query items have invalid item": "มีรายการไม่ถูกต้องในรายการค้นหา",
@ -1485,6 +1488,8 @@
"Version": "เวอร์ชัน",
"Edit": "แก้ไข",
"View": "ดู",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "เอาออก",
"Delete": "ลบ",
"Duplicate": "ทำสำเนา",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "แสดงหมวดรายการที่ซ่อนอยู่",
"Hide Hidden Transaction Categories": "ซ่อนหมวดรายการที่ซ่อนอยู่",
"Transaction Tags": "แท็กรายการ",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "ชื่อแท็ก",
"No available tag": "ไม่มีแท็กที่ใช้ได้",
"Find tag": "ค้นหาแท็ก",
"Unable to retrieve tag list": "ไม่สามารถดึงรายการแท็กได้",
"Tag list is up to date": "รายการแท็กเป็นปัจจุบันแล้ว",
"Tag list has been updated": "รายการแท็กถูกอัปเดตแล้ว",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "เพิ่มแท็กใหม่",
"Unable to add tag": "ไม่สามารถเพิ่มแท็กได้",
"Unable to save tag": "ไม่สามารถบันทึกแท็กได้",

View file

@ -618,7 +618,7 @@
"ZMW": "Zambiya Kvaçası",
"ZWG": "Zimbabve Altını",
"ZWL": "Zimbabve Doları"
},
},
"unit": {
"Afghani": {
"normal": "Afgani",
@ -1053,7 +1053,7 @@
"amap": "Amap",
"custom": "Kullanıcı Tanımlı"
},
"error": {
"error": {
"system error": "Sistem Hatası",
"api not found": "API isteği başarısız oldu",
"not implemented": "Henüz uygulanmadı",
@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Sorgu öğeleri boş olamaz",
"query items too much": "Çok fazla sorgu öğesi var",
"query items have invalid item": "Sorgu öğelerinde geçersiz öğe var",
@ -1410,7 +1413,7 @@
}
}
},
"sample": {
"sample": {
"importTransactionCustomScript": {
"headerComment": "Örnek betik:",
"functionDescription": "Ayrıştırılan dosya verisinin her satırı için parse fonksiyonu çağrılacaktır, fonksiyon adı 'parse' olmalıdır",
@ -1485,6 +1488,8 @@
"Version": "Sürüm",
"Edit": "Düzenle",
"View": "Görüntüle",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Kaldır",
"Delete": "Sil",
"Duplicate": "Çoğalt",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Gizli İşlem Kategorilerini Göster",
"Hide Hidden Transaction Categories": "Gizli İşlem Kategorilerini Gizle",
"Transaction Tags": "İşlem Etiketleri",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Etiket Başlığı",
"No available tag": "Mevcut etiket yok",
"Find tag": "Etiket bul",
"Unable to retrieve tag list": "Etiket listesi alınamadı",
"Tag list is up to date": "Etiket listesi güncel",
"Tag list has been updated": "Etiket listesi güncellendi",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Yeni etiket ekle",
"Unable to add tag": "Etiket eklenemedi",
"Unable to save tag": "Etiket kaydedilemedi",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Елементи запиту не можуть бути порожніми",
"query items too much": "Занадто багато елементів запиту",
"query items have invalid item": "Запит містить недійсний елемент",
@ -1485,6 +1488,8 @@
"Version": "Версія",
"Edit": "Редагувати",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Видалити",
"Delete": "Видалити",
"Duplicate": "Дублювати",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Показати приховані категорії транзакцій",
"Hide Hidden Transaction Categories": "Приховати приховані категорії транзакцій",
"Transaction Tags": "Теги транзакцій",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Назва тегу",
"No available tag": "Немає доступних тегів",
"Find tag": "Знайти тег",
"Unable to retrieve tag list": "Не вдалося отримати список тегів",
"Tag list is up to date": "Список тегів актуальний",
"Tag list has been updated": "Список тегів оновлено",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Додати новий тег",
"Unable to add tag": "Не вдалося додати тег",
"Unable to save tag": "Не вдалося зберегти тег",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "Explorer ID is invalid",
"explorer not found": "Explorer is not found",
"explorer data is invalid": "Explorer data is invalid",
"transaction tag group id is invalid": "Transaction tag group ID is invalid",
"transaction tag group not found": "Transaction tag group is not found",
"transaction tag group is in use and cannot be deleted": "Transaction tag group is in use and it cannot be deleted",
"query items cannot be blank": "Không có mục truy vấn",
"query items too much": "Có quá nhiều mục truy vấn",
"query items have invalid item": "Có mục không hợp lệ trong các mục truy vấn",
@ -1485,6 +1488,8 @@
"Version": "Phiên bản",
"Edit": "Sửa",
"View": "View",
"Move": "Move",
"Move to...": "Move to...",
"Remove": "Xóa",
"Delete": "Xóa",
"Duplicate": "Nhân đôi",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "Hiển thị danh mục giao dịch ẩn",
"Hide Hidden Transaction Categories": "Ẩn danh mục giao dịch ẩn",
"Transaction Tags": "Thẻ giao dịch",
"Total tags": "Total tags",
"Default Group": "Default Group",
"Tag Title": "Tiêu đề thẻ",
"No available tag": "Không có thẻ nào khả dụng",
"Find tag": "Find tag",
"Unable to retrieve tag list": "Không thể truy xuất danh sách thẻ",
"Tag list is up to date": "Danh sách thẻ đã được cập nhật",
"Tag list has been updated": "Danh sách thẻ đã được cập nhật",
"Add Tag Group": "Add Tag Group",
"Rename Tag Group": "Rename Tag Group",
"Delete Tag Group": "Delete Tag Group",
"Change Group Display Order": "Change Group Display Order",
"Transaction Tag Groups": "Transaction Tag Groups",
"Tag Group Name": "Tag Group Name",
"New Tag Group Name": "New Tag Group Name",
"No available tag group": "No available tag group",
"Unable to retrieve tag group list": "Unable to retrieve tag group list",
"Tag group list has been updated": "Tag group list has been updated",
"Unable to add tag group": "Unable to add tag group",
"Unable to save tag group": "Unable to save tag group",
"Unable to move tag group": "Unable to move tag group",
"Unable to rename this tag group": "Unable to rename this tag group",
"Are you sure you want to delete this tag group?": "Are you sure you want to delete this tag group?",
"Unable to delete this tag group": "Unable to delete this tag group",
"Add new tag": "Add new tag",
"Unable to add tag": "Không thể thêm thẻ",
"Unable to save tag": "Không thể lưu thẻ",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "探索ID无效",
"explorer not found": "探索不存在",
"explorer data is invalid": "探索数据无效",
"transaction tag group id is invalid": "交易标签组ID无效",
"transaction tag group not found": "交易标签组不存在",
"transaction tag group is in use and cannot be deleted": "交易标签组正在被使用,无法删除",
"query items cannot be blank": "请求项目不能为空",
"query items too much": "请求项目过多",
"query items have invalid item": "请求项目中有非法项目",
@ -1485,6 +1488,8 @@
"Version": "版本",
"Edit": "编辑",
"View": "查看",
"Move": "移动",
"Move to...": "移动到...",
"Remove": "移除",
"Delete": "删除",
"Duplicate": "复制",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "显示隐藏的交易分类",
"Hide Hidden Transaction Categories": "不显示隐藏的交易分类",
"Transaction Tags": "交易标签",
"Total tags": "总标签数",
"Default Group": "默认分组",
"Tag Title": "标签标题",
"No available tag": "没有可用的标签",
"Find tag": "查找标签",
"Unable to retrieve tag list": "无法获取标签列表",
"Tag list is up to date": "标签列表已是最新",
"Tag list has been updated": "标签列表已更新",
"Add Tag Group": "添加标签组",
"Rename Tag Group": "重命名标签组",
"Delete Tag Group": "删除标签组",
"Change Group Display Order": "更改分组显示顺序",
"Transaction Tag Groups": "交易标签组",
"Tag Group Name": "标签组名称",
"New Tag Group Name": "新标签组名称",
"No available tag group": "没有可用的标签组",
"Unable to retrieve tag group list": "无法获取标签组列表",
"Tag group list has been updated": "标签组列表已更新",
"Unable to add tag group": "无法添加标签组",
"Unable to save tag group": "无法保存标签组",
"Unable to move tag group": "无法移动标签组",
"Unable to rename this tag group": "无法重命名该标签组",
"Are you sure you want to delete this tag group?": "您确定要删除该标签组?",
"Unable to delete this tag group": "无法删除该标签组",
"Add new tag": "添加新标签",
"Unable to add tag": "无法添加标签",
"Unable to save tag": "无法保存标签",

View file

@ -1278,6 +1278,9 @@
"explorer id is invalid": "探索ID無效",
"explorer not found": "探索不存在",
"explorer data is invalid": "探索資料無效",
"transaction tag group id is invalid": "交易標籤組ID無效",
"transaction tag group not found": "交易標籤組不存在",
"transaction tag group is in use and cannot be deleted": "交易標籤組正在被使用,無法刪除",
"query items cannot be blank": "查詢項目不能為空",
"query items too much": "查詢項目過多",
"query items have invalid item": "查詢項目中有非法項目",
@ -1485,6 +1488,8 @@
"Version": "版本",
"Edit": "編輯",
"View": "檢視",
"Move": "移動",
"Move to...": "移動到...",
"Remove": "移除",
"Delete": "刪除",
"Duplicate": "複製",
@ -2376,12 +2381,30 @@
"Show Hidden Transaction Categories": "顯示隱藏的交易分類",
"Hide Hidden Transaction Categories": "不顯示隱藏的交易分類",
"Transaction Tags": "交易標籤",
"Total tags": "總標籤數",
"Default Group": "預設群組",
"Tag Title": "標籤標題",
"No available tag": "沒有可用的標籤",
"Find tag": "尋找標籤",
"Unable to retrieve tag list": "無法取得標籤清單",
"Tag list is up to date": "標籤清單已是最新",
"Tag list has been updated": "標籤清單已更新",
"Add Tag Group": "新增標籤群組",
"Rename Tag Group": "重新命名標籤群組",
"Delete Tag Group": "刪除標籤群組",
"Change Group Display Order": "變更群組顯示順序",
"Transaction Tag Groups": "交易標籤群組",
"Tag Group Name": "標籤群組名稱",
"New Tag Group Name": "新標籤群組名稱",
"No available tag group": "沒有可用的標籤群組",
"Unable to retrieve tag group list": "無法取得標籤群組清單",
"Tag group list has been updated": "標籤群組清單已更新",
"Unable to add tag group": "無法新增標籤群組",
"Unable to save tag group": "無法儲存標籤群組",
"Unable to move tag group": "無法移動標籤群組",
"Unable to rename this tag group": "無法重新命名此標籤群組",
"Are you sure you want to delete this tag group?": "您確定要刪除此標籤群組?",
"Unable to delete this tag group": "無法刪除此標籤群組",
"Add new tag": "新增標籤",
"Unable to add tag": "無法新增標籤",
"Unable to save tag": "無法儲存標籤",

View file

@ -448,8 +448,8 @@ export class TransactionTagFilter {
this.type = type;
}
public static create(type: TransactionTagFilterType): TransactionTagFilter {
return new TransactionTagFilter([], type);
public static create(tagIds: string[], type: TransactionTagFilterType): TransactionTagFilter {
return new TransactionTagFilter(tagIds, type);
}
public static of(tagId: string): TransactionTagFilter {

View file

@ -1,31 +1,39 @@
export class TransactionTag implements TransactionTagInfoResponse {
public id: string;
public name: string;
public groupId: string;
public displayOrder: number;
public hidden: boolean;
private constructor(id: string, name: string, displayOrder: number, hidden: boolean) {
private constructor(id: string, name: string, groupId: string, displayOrder: number, hidden: boolean) {
this.id = id;
this.name = name;
this.groupId = groupId;
this.displayOrder = displayOrder;
this.hidden = hidden;
}
public toCreateRequest(): TransactionTagCreateRequest {
return {
name: this.name
name: this.name,
groupId: this.groupId
};
}
public toModifyRequest(): TransactionTagModifyRequest {
return {
id: this.id,
groupId: this.groupId,
name: this.name
};
}
public clone(): TransactionTag {
return new TransactionTag(this.id, this.name, this.groupId, this.displayOrder, this.hidden);
}
public static of(tagResponse: TransactionTagInfoResponse): TransactionTag {
return new TransactionTag(tagResponse.id, tagResponse.name, tagResponse.displayOrder, tagResponse.hidden);
return new TransactionTag(tagResponse.id, tagResponse.name, tagResponse.groupId, tagResponse.displayOrder, tagResponse.hidden);
}
public static ofMulti(tagResponses: TransactionTagInfoResponse[]): TransactionTag[] {
@ -38,22 +46,25 @@ export class TransactionTag implements TransactionTagInfoResponse {
return tags;
}
public static createNewTag(name?: string): TransactionTag {
return new TransactionTag('', name || '', 0, false);
public static createNewTag(name?: string, groupId?: string): TransactionTag {
return new TransactionTag('', name || '', groupId || '0', 0, false);
}
}
export interface TransactionTagCreateRequest {
readonly groupId: string;
readonly name: string;
}
export interface TransactionTagCreateBatchRequest {
readonly tags: TransactionTagCreateRequest[];
readonly groupId: string;
readonly skipExists: boolean;
}
export interface TransactionTagModifyRequest {
readonly id: string;
readonly groupId: string;
readonly name: string;
}
@ -78,6 +89,7 @@ export interface TransactionTagDeleteRequest {
export interface TransactionTagInfoResponse {
readonly id: string;
readonly name: string;
readonly groupId: string;
readonly displayOrder: number;
readonly hidden: boolean;
}

View file

@ -0,0 +1,74 @@
export class TransactionTagGroup implements TransactionTagGroupInfoResponse {
public id: string;
public name: string;
public displayOrder: number;
private constructor(id: string, name: string, displayOrder: number) {
this.id = id;
this.name = name;
this.displayOrder = displayOrder;
}
public toCreateRequest(): TransactionTagGroupCreateRequest {
return {
name: this.name
};
}
public toModifyRequest(): TransactionTagGroupModifyRequest {
return {
id: this.id,
name: this.name
};
}
public clone(): TransactionTagGroup {
return new TransactionTagGroup(this.id, this.name, this.displayOrder);
}
public static of(tagGroupResponse: TransactionTagGroupInfoResponse): TransactionTagGroup {
return new TransactionTagGroup(tagGroupResponse.id, tagGroupResponse.name, tagGroupResponse.displayOrder);
}
public static ofMulti(tagGroupResponses: TransactionTagGroupInfoResponse[]): TransactionTagGroup[] {
const tagGroups: TransactionTagGroup[] = [];
for (const tagGroupResponse of tagGroupResponses) {
tagGroups.push(TransactionTagGroup.of(tagGroupResponse));
}
return tagGroups;
}
public static createNewTagGroup(name?: string): TransactionTagGroup {
return new TransactionTagGroup('', name || '', 0);
}
}
export interface TransactionTagGroupCreateRequest {
readonly name: string;
}
export interface TransactionTagGroupModifyRequest {
readonly id: string;
readonly name: string;
}
export interface TransactionTagGroupMoveRequest {
readonly newDisplayOrders: TransactionTagGroupNewDisplayOrderRequest[];
}
export interface TransactionTagGroupNewDisplayOrderRequest {
readonly id: string;
readonly displayOrder: number;
}
export interface TransactionTagGroupDeleteRequest {
readonly id: string;
}
export interface TransactionTagGroupInfoResponse {
readonly id: string;
readonly name: string;
readonly displayOrder: number;
}

View file

@ -1,7 +1,13 @@
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import { type BeforeResolveFunction, itemAndIndex } from '@/core/base.ts';
import { type BeforeResolveFunction, itemAndIndex, values } from '@/core/base.ts';
import {
type TransactionTagGroupInfoResponse,
type TransactionTagGroupNewDisplayOrderRequest,
TransactionTagGroup
} from '@/models/transaction_tag_group.ts';
import {
type TransactionTagCreateBatchRequest,
@ -16,52 +22,176 @@ import logger from '@/lib/logger.ts';
import services, { type ApiResponsePromise } from '@/lib/services.ts';
export const useTransactionTagsStore = defineStore('transactionTags', () => {
const allTransactionTagGroups = ref<TransactionTagGroup[]>([]);
const allTransactionTagGroupsMap = ref<Record<string, TransactionTagGroup>>({});
const allTransactionTags = ref<TransactionTag[]>([]);
const allTransactionTagsMap = ref<Record<string, TransactionTag>>({});
const allTransactionTagsByGroupMap = ref<Record<string, TransactionTag[]>>({});
const transactionTagGroupListStateInvalid = ref<boolean>(true);
const transactionTagListStateInvalid = ref<boolean>(true);
const allVisibleTags = computed<TransactionTag[]>(() => {
const visibleTags: TransactionTag[] = [];
for (const tag of allTransactionTags.value) {
if (!tag.hidden) {
visibleTags.push(tag);
}
}
return visibleTags;
});
const allAvailableTagsCount = computed<number>(() => allTransactionTags.value.length);
const allVisibleTagsCount = computed<number>(() => allVisibleTags.value.length);
function loadTransactionTagGroupList(tagGroups: TransactionTagGroup[]): void {
allTransactionTagGroups.value = tagGroups;
allTransactionTagGroupsMap.value = {};
for (const tagGroup of tagGroups) {
allTransactionTagGroupsMap.value[tagGroup.id] = tagGroup;
}
}
function loadTransactionTagList(tags: TransactionTag[]): void {
allTransactionTags.value = tags;
allTransactionTagsMap.value = {};
allTransactionTagsByGroupMap.value = {};
for (const tag of tags) {
allTransactionTagsMap.value[tag.id] = tag;
}
for (const tag of tags) {
let tagsInGroup = allTransactionTagsByGroupMap.value[tag.groupId];
if (!tagsInGroup) {
tagsInGroup = [];
allTransactionTagsByGroupMap.value[tag.groupId] = tagsInGroup;
}
tagsInGroup.push(tag);
}
}
function addTagGroupToTransactionTagGroupList(tagGroup: TransactionTagGroup): void {
allTransactionTagGroups.value.push(tagGroup);
allTransactionTagGroupsMap.value[tagGroup.id] = tagGroup;
}
function addTagToTransactionTagList(tag: TransactionTag): void {
allTransactionTags.value.push(tag);
allTransactionTagsMap.value[tag.id] = tag;
let tagsInGroup = allTransactionTagsByGroupMap.value[tag.groupId];
if (!tagsInGroup) {
tagsInGroup = [];
allTransactionTagsByGroupMap.value[tag.groupId] = tagsInGroup;
}
tagsInGroup.push(tag);
}
function updateTagInTransactionTagList(currentTag: TransactionTag): void {
for (const [transactionTag, index] of itemAndIndex(allTransactionTags.value)) {
if (transactionTag.id === currentTag.id) {
allTransactionTags.value.splice(index, 1, currentTag);
function updateTagGroupInTransactionTagGroupList(currentTagGroup: TransactionTagGroup): void {
for (const [transactionTagGroup, index] of itemAndIndex(allTransactionTagGroups.value)) {
if (transactionTagGroup.id === currentTagGroup.id) {
allTransactionTagGroups.value.splice(index, 1, currentTagGroup);
break;
}
}
allTransactionTagsMap.value[currentTag.id] = currentTag;
allTransactionTagGroupsMap.value[currentTagGroup.id] = currentTagGroup;
}
function updateTagDisplayOrderInTransactionTagList({ from, to }: { from: number, to: number }): void {
allTransactionTags.value.splice(to, 0, allTransactionTags.value.splice(from, 1)[0] as TransactionTag);
function updateTagInTransactionTagList(currentTag: TransactionTag, oldTagGroupId?: string): void {
// update in the main list
for (const [transactionTag, index] of itemAndIndex(allTransactionTags.value)) {
if (transactionTag.id === currentTag.id) {
if (oldTagGroupId && oldTagGroupId !== currentTag.groupId) {
allTransactionTags.value.splice(index, 1);
} else {
allTransactionTags.value.splice(index, 1, currentTag);
}
break;
}
}
if (oldTagGroupId && oldTagGroupId !== currentTag.groupId) {
let insertIndex = allTransactionTags.value.length;
for (const [tag, index] of itemAndIndex(allTransactionTags.value)) {
if (tag.groupId === currentTag.groupId) {
insertIndex = index;
break;
}
}
allTransactionTags.value.splice(insertIndex, 0, currentTag);
}
// update in the map
allTransactionTagsMap.value[currentTag.id] = currentTag;
// update in the group list
for (const tags of values(allTransactionTagsByGroupMap.value)) {
for (const [transactionTag, index] of itemAndIndex(tags)) {
if (transactionTag.id === currentTag.id) {
if (oldTagGroupId && oldTagGroupId !== currentTag.groupId) {
tags.splice(index, 1);
} else {
tags.splice(index, 1, currentTag);
}
break;
}
}
}
if (oldTagGroupId && oldTagGroupId !== currentTag.groupId) {
let newGroupTags = allTransactionTagsByGroupMap.value[currentTag.groupId];
if (!newGroupTags) {
newGroupTags = [];
allTransactionTagsByGroupMap.value[currentTag.groupId] = newGroupTags;
}
newGroupTags.push(currentTag);
}
}
function updateTagGroupDisplayOrderInTransactionTagList({ from, to }: { from: number, to: number }): void {
allTransactionTagGroups.value.splice(to, 0, allTransactionTagGroups.value.splice(from, 1)[0] as TransactionTagGroup);
}
function updateTagDisplayOrderInTransactionTagList({ groupId, from, to }: { groupId: string, from: number, to: number }): void {
// update in the group list
const tagsInGroup = allTransactionTagsByGroupMap.value[groupId];
if (!tagsInGroup) {
return;
}
const fromTag = tagsInGroup[from];
if (!fromTag) {
return;
}
const toTag = tagsInGroup[to];
if (!toTag) {
return;
}
tagsInGroup.splice(to, 0, tagsInGroup.splice(from, 1)[0] as TransactionTag);
// update in the main list
let mainListFromIndex = -1;
let mainListToIndex = -1;
for (const [tag, index] of itemAndIndex(allTransactionTags.value)) {
if (tag.id === fromTag.id) {
mainListFromIndex = index;
}
if (tag.id === toTag.id) {
mainListToIndex = index;
}
}
if (mainListFromIndex === -1 || mainListToIndex === -1) {
return;
}
allTransactionTags.value.splice(mainListToIndex, 0, allTransactionTags.value.splice(mainListFromIndex, 1)[0] as TransactionTag);
}
function updateTagVisibilityInTransactionTagList({ tag, hidden }: { tag: TransactionTag, hidden: boolean }): void {
@ -70,6 +200,19 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
}
}
function removeTagGroupFromTransactionTagGroupList(currentTagGroup: TransactionTagGroup): void {
for (const [transactionTagGroup, index] of itemAndIndex(allTransactionTagGroups.value)) {
if (transactionTagGroup.id === currentTagGroup.id) {
allTransactionTagGroups.value.splice(index, 1);
break;
}
}
if (allTransactionTagGroupsMap.value[currentTagGroup.id]) {
delete allTransactionTagGroupsMap.value[currentTagGroup.id];
}
}
function removeTagFromTransactionTagList(currentTag: TransactionTag): void {
for (const [transactionTag, index] of itemAndIndex(allTransactionTags.value)) {
if (transactionTag.id === currentTag.id) {
@ -81,6 +224,19 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
if (allTransactionTagsMap.value[currentTag.id]) {
delete allTransactionTagsMap.value[currentTag.id];
}
for (const tags of values(allTransactionTagsByGroupMap.value)) {
for (const [transactionTag, index] of itemAndIndex(tags)) {
if (transactionTag.id === currentTag.id) {
tags.splice(index, 1);
break;
}
}
}
}
function updateTransactionTagGroupListInvalidState(invalidState: boolean): void {
transactionTagGroupListStateInvalid.value = invalidState;
}
function updateTransactionTagListInvalidState(invalidState: boolean): void {
@ -88,20 +244,85 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
}
function resetTransactionTags(): void {
allTransactionTagGroups.value = [];
allTransactionTagGroupsMap.value = {};
allTransactionTags.value = [];
allTransactionTagsMap.value = {};
allTransactionTagsByGroupMap.value = {};
transactionTagGroupListStateInvalid.value = true;
transactionTagListStateInvalid.value = true;
}
function loadAllTagGroups({ force }: { force?: boolean }): Promise<TransactionTagGroup[]> {
if (!force && !transactionTagGroupListStateInvalid.value) {
return new Promise((resolve) => {
resolve(allTransactionTagGroups.value);
});
}
return new Promise((resolve, reject) => {
services.getAllTransactionTagGroups().then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to retrieve tag group list' });
return;
}
if (transactionTagGroupListStateInvalid.value) {
updateTransactionTagGroupListInvalidState(false);
}
const transactionTagGroups = TransactionTagGroup.ofMulti(data.result);
loadTransactionTagGroupList(transactionTagGroups);
resolve(transactionTagGroups);
}).catch(error => {
logger.error('failed to load tag group list', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to retrieve tag group list' });
} else {
reject(error);
}
});
});
}
function loadAllTags({ force }: { force?: boolean }): Promise<TransactionTag[]> {
if (!force && !transactionTagListStateInvalid.value) {
if (!force && !transactionTagGroupListStateInvalid.value && !transactionTagListStateInvalid.value) {
return new Promise((resolve) => {
resolve(allTransactionTags.value);
});
}
return new Promise((resolve, reject) => {
services.getAllTransactionTags().then(response => {
services.getAllTransactionTagGroups().then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to retrieve tag list' });
return;
}
if (transactionTagGroupListStateInvalid.value) {
updateTransactionTagGroupListInvalidState(false);
}
const transactionTagGroups = TransactionTagGroup.ofMulti(data.result);
loadTransactionTagGroupList(transactionTagGroups);
return services.getAllTransactionTags();
}).then(response => {
if (!response) {
reject({ message: 'Unable to retrieve tag list' });
return;
}
const data = response.data;
if (!data || !data.success || !data.result) {
@ -141,7 +362,159 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
});
}
function saveTagGroup({ tagGroup }: { tagGroup: TransactionTagGroup }): Promise<TransactionTagGroup> {
return new Promise((resolve, reject) => {
let promise: ApiResponsePromise<TransactionTagGroupInfoResponse>;
if (!tagGroup.id) {
promise = services.addTransactionTagGroup(tagGroup.toCreateRequest());
} else {
promise = services.modifyTransactionTagGroup(tagGroup.toModifyRequest());
}
promise.then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
if (!tagGroup.id) {
reject({ message: 'Unable to add tag group' });
} else {
reject({ message: 'Unable to save tag group' });
}
return;
}
const transactionTagGroup = TransactionTagGroup.of(data.result);
if (!tagGroup.id) {
addTagGroupToTransactionTagGroupList(transactionTagGroup);
} else {
updateTagGroupInTransactionTagGroupList(transactionTagGroup);
}
resolve(transactionTagGroup);
}).catch(error => {
logger.error('failed to save tag group', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
if (!tagGroup.id) {
reject({ message: 'Unable to add tag group' });
} else {
reject({ message: 'Unable to save tag group' });
}
} else {
reject(error);
}
});
});
}
function changeTagGroupDisplayOrder({ tagGroupId, from, to }: { tagGroupId: string, from: number, to: number }): Promise<void> {
return new Promise((resolve, reject) => {
let currentTagGroup: TransactionTagGroup | null = null;
for (const transactionTagGroup of allTransactionTagGroups.value) {
if (transactionTagGroup.id === tagGroupId) {
currentTagGroup = transactionTagGroup;
break;
}
}
if (!currentTagGroup || !allTransactionTagGroups.value[to]) {
reject({ message: 'Unable to move tag group' });
return;
}
if (!transactionTagGroupListStateInvalid.value) {
updateTransactionTagGroupListInvalidState(true);
}
updateTagGroupDisplayOrderInTransactionTagList({ from, to });
resolve();
});
}
function updateTagGroupDisplayOrders(): Promise<boolean> {
const newDisplayOrders: TransactionTagGroupNewDisplayOrderRequest[] = [];
for (const [transactionTagGroup, index] of itemAndIndex(allTransactionTagGroups.value)) {
newDisplayOrders.push({
id: transactionTagGroup.id,
displayOrder: index + 1
});
}
return new Promise((resolve, reject) => {
services.moveTransactionTagGroup({
newDisplayOrders: newDisplayOrders
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to move tag group' });
return;
}
if (transactionTagGroupListStateInvalid.value) {
updateTransactionTagGroupListInvalidState(false);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to save tag groups display order', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to move tag group' });
} else {
reject(error);
}
});
});
}
function deleteTagGroup({ tagGroup, beforeResolve }: { tagGroup: TransactionTagGroup, beforeResolve?: BeforeResolveFunction }): Promise<boolean> {
return new Promise((resolve, reject) => {
services.deleteTransactionTagGroup({
id: tagGroup.id
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to delete this tag group' });
return;
}
if (beforeResolve) {
beforeResolve(() => {
removeTagGroupFromTransactionTagGroupList(tagGroup);
});
} else {
removeTagGroupFromTransactionTagGroupList(tagGroup);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to delete tag group', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to delete this tag group' });
} else {
reject(error);
}
});
});
}
function saveTag({ tag }: { tag: TransactionTag }): Promise<TransactionTag> {
const oldTagGroupId = allTransactionTagsMap.value[tag.id]?.groupId;
return new Promise((resolve, reject) => {
let promise: ApiResponsePromise<TransactionTagInfoResponse>;
@ -168,7 +541,7 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
if (!tag.id) {
addTagToTransactionTagList(transactionTag);
} else {
updateTagInTransactionTagList(transactionTag);
updateTagInTransactionTagList(transactionTag, oldTagGroupId);
}
resolve(transactionTag);
@ -241,16 +614,26 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
updateTransactionTagListInvalidState(true);
}
updateTagDisplayOrderInTransactionTagList({ from, to });
updateTagDisplayOrderInTransactionTagList({
groupId: currentTag.groupId,
from: from,
to: to
});
resolve();
});
}
function updateTagDisplayOrders(): Promise<boolean> {
function updateTagDisplayOrders(groupId: string): Promise<boolean> {
const tagsInGroup = allTransactionTagsByGroupMap.value[groupId];
if (!tagsInGroup) {
return Promise.reject('Unable to move tag');
}
const newDisplayOrders: TransactionTagNewDisplayOrderRequest[] = [];
for (const [transactionTag, index] of itemAndIndex(allTransactionTags.value)) {
for (const [transactionTag, index] of itemAndIndex(tagsInGroup)) {
newDisplayOrders.push({
id: transactionTag.id,
displayOrder: index + 1
@ -362,17 +745,24 @@ export const useTransactionTagsStore = defineStore('transactionTags', () => {
return {
// states
allTransactionTagGroups,
allTransactionTagGroupsMap,
allTransactionTags,
allTransactionTagsMap,
allTransactionTagsByGroupMap,
transactionTagGroupListStateInvalid,
transactionTagListStateInvalid,
// computed states
allVisibleTags,
allAvailableTagsCount,
allVisibleTagsCount,
// functions
updateTransactionTagListInvalidState,
resetTransactionTags,
loadAllTagGroups,
loadAllTags,
saveTagGroup,
changeTagGroupDisplayOrder,
updateTagGroupDisplayOrders,
deleteTagGroup,
saveTag,
addTags,
changeTagDisplayOrder,

View file

@ -1,15 +1,20 @@
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import { entries, values } from '@/core/base.ts';
import { entries, keys, values } from '@/core/base.ts';
import { TransactionTagFilterType } from '@/core/transaction.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import { TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import type { TransactionTag } from '@/models/transaction_tag.ts';
import { TransactionTagFilter } from '@/models/transaction.ts';
import { objectFieldWithValueToArrayItem } from '@/lib/common.ts';
import { objectFieldToArrayItem } from '@/lib/common.ts';
export enum TransactionTagFilterState {
Default = 0,
@ -17,7 +22,27 @@ export enum TransactionTagFilterState {
Exclude = 2
}
interface TransactionGroupTagFilterTypes {
includeType: number;
excludeType: number;
}
function getEmptyGroupTagFilterTypesMap(allTransactionTagsByGroupMap: Record<string, TransactionTag[]>): Record<string, TransactionGroupTagFilterTypes> {
const ret: Record<string, TransactionGroupTagFilterTypes> = {};
for (const groupId of keys(allTransactionTagsByGroupMap)) {
ret[groupId] = {
includeType: TransactionTagFilterType.HasAny.type,
excludeType: TransactionTagFilterType.NotHasAny.type
};
}
return ret;
}
export function useTransactionTagFilterSettingPageBase(type?: string) {
const { tt } = useI18n();
const transactionTagsStore = useTransactionTagsStore();
const transactionsStore = useTransactionsStore();
const statisticsStore = useStatisticsStore();
@ -25,12 +50,11 @@ export function useTransactionTagFilterSettingPageBase(type?: string) {
const loading = ref<boolean>(true);
const showHidden = ref<boolean>(false);
const filterContent = ref<string>('');
const filterTagIds = ref<Record<string, TransactionTagFilterState>>({});
const includeTagFilterType = ref<number>(TransactionTagFilterType.HasAny.type);
const excludeTagFilterType = ref<number>(TransactionTagFilterType.NotHasAny.type);
const includeTagsCount = computed<number>(() => objectFieldWithValueToArrayItem(filterTagIds.value, TransactionTagFilterState.Include).length);
const excludeTagsCount = computed<number>(() => objectFieldWithValueToArrayItem(filterTagIds.value, TransactionTagFilterState.Exclude).length);
const tagFilterStateMap = ref<Record<string, TransactionTagFilterState>>({});
const groupTagFilterTypesMap = ref<Record<string, TransactionGroupTagFilterTypes>>(getEmptyGroupTagFilterTypesMap(transactionTagsStore.allTransactionTagsByGroupMap));
const lowerCaseFilterContent = computed<string>(() => filterContent.value.toLowerCase());
const title = computed<string>(() => {
return 'Filter Transaction Tags';
@ -40,23 +64,85 @@ export function useTransactionTagFilterSettingPageBase(type?: string) {
return 'Apply';
});
const allVisibleTags = computed<TransactionTag[]>(() => {
const ret: TransactionTag[] = [];
const allTags = showHidden.value ? transactionTagsStore.allTransactionTags : transactionTagsStore.allVisibleTags;
const lowercaseFilterContent = filterContent.value ? filterContent.value.toLowerCase() : '';
const groupTagFilterStateCountMap = computed<Record<string, Record<TransactionTagFilterState, number>>>(() => {
const ret: Record<string, Record<TransactionTagFilterState, number>> = {};
for (const tag of allTags) {
if (lowercaseFilterContent && !tag.name.toLowerCase().includes(lowercaseFilterContent)) {
continue;
for (const [groupId, tags] of entries(transactionTagsStore.allTransactionTagsByGroupMap)) {
const stateCountMap: Record<TransactionTagFilterState, number> = {
[TransactionTagFilterState.Default]: 0,
[TransactionTagFilterState.Include]: 0,
[TransactionTagFilterState.Exclude]: 0
};
for (const tag of tags) {
const state = tagFilterStateMap.value[tag.id] ?? TransactionTagFilterState.Default;
stateCountMap[state] = (stateCountMap[state] || 0) + 1;
}
ret.push(tag);
ret[groupId] = stateCountMap;
}
return ret;
});
const allTagGroupsWithDefault = computed<TransactionTagGroup[]>(() => {
const allGroups: TransactionTagGroup[] = [];
const tagsInDefaultGroup = transactionTagsStore.allTransactionTagsByGroupMap[DEFAULT_TAG_GROUP_ID];
if (tagsInDefaultGroup && tagsInDefaultGroup.length) {
const defaultGroup = TransactionTagGroup.createNewTagGroup(tt('Default Group'));
defaultGroup.id = DEFAULT_TAG_GROUP_ID;
allGroups.push(defaultGroup);
}
for (const tagGroup of transactionTagsStore.allTransactionTagGroups) {
const tagsInGroup = transactionTagsStore.allTransactionTagsByGroupMap[tagGroup.id];
if (tagsInGroup && tagsInGroup.length) {
allGroups.push(tagGroup);
}
}
return allGroups;
});
const allVisibleTags = computed<Record<string, TransactionTag[]>>(() => {
const ret: Record<string, TransactionTag[]> = {};
const allTagGroups = transactionTagsStore.allTransactionTagsByGroupMap;
for (const [groupId, tags] of entries(allTagGroups)) {
const visibleTags: TransactionTag[] = [];
for (const tag of tags) {
if (!showHidden.value && tag.hidden) {
continue;
}
if (lowerCaseFilterContent.value && !tag.name.toLowerCase().includes(lowerCaseFilterContent.value)) {
continue;
}
visibleTags.push(tag);
}
if (visibleTags.length > 0) {
ret[groupId] = visibleTags;
}
}
return ret;
});
const allVisibleTagGroupIds = computed<string[]>(() => objectFieldToArrayItem(allVisibleTags.value));
const hasAnyAvailableTag = computed<boolean>(() => transactionTagsStore.allAvailableTagsCount > 0);
const hasAnyVisibleTag = computed<boolean>(() => allVisibleTags.value.length > 0);
const hasAnyVisibleTag = computed<boolean>(() => {
for (const tags of values(allVisibleTags.value)) {
if (tags.length > 0) {
return true;
}
}
return false;
});
function loadFilterTagIds(): boolean {
let tagFilters: TransactionTagFilter[] = [];
@ -70,6 +156,7 @@ export function useTransactionTagFilterSettingPageBase(type?: string) {
}
const allTagIdsMap: Record<string, TransactionTagFilterState> = {};
const allGroupTagFilterTypesMap: Record<string, TransactionGroupTagFilterTypes> = getEmptyGroupTagFilterTypesMap(transactionTagsStore.allTransactionTagsByGroupMap);
for (const transactionTag of values(transactionTagsStore.allTransactionTagsMap)) {
allTagIdsMap[transactionTag.id] = TransactionTagFilterState.Default;
@ -80,50 +167,69 @@ export function useTransactionTagFilterSettingPageBase(type?: string) {
if (tagFilter.type === TransactionTagFilterType.HasAny || tagFilter.type === TransactionTagFilterType.HasAll) {
state = TransactionTagFilterState.Include;
includeTagFilterType.value = tagFilter.type.type;
} else if (tagFilter.type === TransactionTagFilterType.NotHasAny || tagFilter.type === TransactionTagFilterType.NotHasAll) {
state = TransactionTagFilterState.Exclude;
excludeTagFilterType.value = tagFilter.type.type;
} else {
continue;
}
for (const tagId of tagFilter.tagIds) {
allTagIdsMap[tagId] = state;
const tag = transactionTagsStore.allTransactionTagsMap[tagId];
if (!tag) {
continue;
}
const groupFilterTypes = allGroupTagFilterTypesMap[tag.groupId];
if (groupFilterTypes) {
if (state === TransactionTagFilterState.Include) {
groupFilterTypes.includeType = tagFilter.type.type;
} else if (state === TransactionTagFilterState.Exclude) {
groupFilterTypes.excludeType = tagFilter.type.type;
}
allTagIdsMap[tagId] = state;
}
}
}
filterTagIds.value = allTagIdsMap;
tagFilterStateMap.value = allTagIdsMap;
groupTagFilterTypesMap.value = allGroupTagFilterTypesMap;
return true;
}
function saveFilterTagIds(): boolean {
const includeTagFilter: TransactionTagFilter = TransactionTagFilter.create(TransactionTagFilterType.parse(includeTagFilterType.value) ?? TransactionTagFilterType.HasAny);
const excludeTagFilter: TransactionTagFilter = TransactionTagFilter.create(TransactionTagFilterType.parse(excludeTagFilterType.value) ?? TransactionTagFilterType.NotHasAny);
const tagFilters: TransactionTagFilter[] = [];
let changed = true;
for (const [transactionTagId, state] of entries(filterTagIds.value)) {
const transactionTag = transactionTagsStore.allTransactionTagsMap[transactionTagId];
for (const [groupId, tags] of entries(transactionTagsStore.allTransactionTagsByGroupMap)) {
const groupFilterTypes = groupTagFilterTypesMap.value[groupId];
if (!transactionTag) {
continue;
if (groupFilterTypes && tags && tags.length > 0) {
const includeTagIds: string[] = [];
const excludeTagIds: string[] = [];
for (const tag of tags) {
const state = tagFilterStateMap.value[tag.id] ?? TransactionTagFilterState.Default;
if (state === TransactionTagFilterState.Include) {
includeTagIds.push(tag.id);
} else if (state === TransactionTagFilterState.Exclude) {
excludeTagIds.push(tag.id);
}
}
if (includeTagIds.length > 0) {
const includeTagFilter = TransactionTagFilter.create(includeTagIds, TransactionTagFilterType.parse(groupFilterTypes.includeType) ?? TransactionTagFilterType.HasAny);
tagFilters.push(includeTagFilter);
}
if (excludeTagIds.length > 0) {
const excludeTagFilter = TransactionTagFilter.create(excludeTagIds, TransactionTagFilterType.parse(groupFilterTypes.excludeType) ?? TransactionTagFilterType.NotHasAny);
tagFilters.push(excludeTagFilter);
}
}
if (state === TransactionTagFilterState.Include) {
includeTagFilter.tagIds.push(transactionTag.id);
} else if (state === TransactionTagFilterState.Exclude) {
excludeTagFilter.tagIds.push(transactionTag.id);
}
}
const tagFilters: TransactionTagFilter[] = [];
if (includeTagFilter.tagIds.length > 0) {
tagFilters.push(includeTagFilter);
}
if (excludeTagFilter.tagIds.length > 0) {
tagFilters.push(excludeTagFilter);
}
if (type === 'statisticsCurrent') {
@ -152,15 +258,15 @@ export function useTransactionTagFilterSettingPageBase(type?: string) {
loading,
showHidden,
filterContent,
filterTagIds,
includeTagFilterType,
excludeTagFilterType,
tagFilterStateMap,
groupTagFilterTypesMap,
// computed states
includeTagsCount,
excludeTagsCount,
title,
applyText,
groupTagFilterStateCountMap,
allTagGroupsWithDefault,
allVisibleTags,
allVisibleTagGroupIds,
hasAnyAvailableTag,
hasAnyVisibleTag,
// functions

View file

@ -0,0 +1,84 @@
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import { TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import { isNoAvailableTag } from '@/lib/tag.ts';
export function useTagListPageBase() {
const { tt } = useI18n();
const transactionTagsStore = useTransactionTagsStore();
const activeTagGroupId = ref<string>(DEFAULT_TAG_GROUP_ID);
const newTag = ref<TransactionTag | null>(null);
const editingTag = ref<TransactionTag>(TransactionTag.createNewTag());
const loading = ref<boolean>(true);
const showHidden = ref<boolean>(false);
const displayOrderModified = ref<boolean>(false);
const allTagGroupsWithDefault = computed<TransactionTagGroup[]>(() => {
const allGroups: TransactionTagGroup[] = [];
const defaultGroup = TransactionTagGroup.createNewTagGroup(tt('Default Group'));
defaultGroup.id = DEFAULT_TAG_GROUP_ID;
allGroups.push(defaultGroup);
allGroups.push(...transactionTagsStore.allTransactionTagGroups);
return allGroups;
});
const tags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTagsByGroupMap[activeTagGroupId.value] || []);
const noAvailableTag = computed<boolean>(() => isNoAvailableTag(tags.value, showHidden.value));
const hasEditingTag = computed<boolean>(() => !!(newTag.value || (editingTag.value.id && editingTag.value.id !== '')));
function isTagModified(tag: TransactionTag): boolean {
if (tag.id) {
return editingTag.value.name !== '' && editingTag.value.name !== tag.name;
} else {
return tag.name !== '';
}
}
function switchTagGroup(tagGroupId: string): void {
activeTagGroupId.value = tagGroupId;
if (newTag.value) {
newTag.value.groupId = tagGroupId;
}
}
function add(): void {
newTag.value = TransactionTag.createNewTag('', activeTagGroupId.value);
}
function edit(tag: TransactionTag): void {
editingTag.value.id = tag.id;
editingTag.value.groupId = tag.groupId;
editingTag.value.name = tag.name;
}
return {
// states
activeTagGroupId,
newTag,
editingTag,
loading,
showHidden,
displayOrderModified,
// computed states
allTagGroupsWithDefault,
tags,
noAvailableTag,
hasEditingTag,
// functions
isTagModified,
switchTagGroup,
add,
edit
};
}

View file

@ -109,7 +109,6 @@ export function useTransactionEditPageBase(type: TransactionEditPageType, initMo
const allVisibleCategorizedAccounts = computed<CategorizedAccountWithDisplayBalance[]>(() => getCategorizedAccountsWithDisplayBalance(allVisibleAccounts.value, showAccountBalance.value, customAccountCategoryOrder.value));
const allCategories = computed<Record<number, TransactionCategory[]>>(() => transactionCategoriesStore.allTransactionCategories);
const allCategoriesMap = computed<Record<string, TransactionCategory>>(() => transactionCategoriesStore.allTransactionCategoriesMap);
const allTags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTags);
const allTagsMap = computed<Record<string, TransactionTag>>(() => transactionTagsStore.allTransactionTagsMap);
const firstVisibleAccountId = computed<string | undefined>(() => allVisibleAccounts.value && allVisibleAccounts.value[0] ? allVisibleAccounts.value[0].id : undefined);
@ -452,7 +451,6 @@ export function useTransactionEditPageBase(type: TransactionEditPageType, initMo
allVisibleCategorizedAccounts,
allCategories,
allCategoriesMap,
allTags,
allTagsMap,
firstVisibleAccountId,
hasVisibleExpenseCategories,

View file

@ -15,9 +15,11 @@ import { type TextualYearMonthDay, type Year0BasedMonth, type LocalizedDateRange
import { AccountType } from '@/core/account.ts';
import { TransactionType } from '@/core/transaction.ts';
import { DISPLAY_HIDDEN_AMOUNT, INCOMPLETE_AMOUNT_SUFFIX } from '@/consts/numeral.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import type { Account } from '@/models/account.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
import { TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import type { TransactionTag } from '@/models/transaction_tag.ts';
import { type Transaction, TransactionTagFilter } from '@/models/transaction.ts';
@ -136,8 +138,16 @@ export function useTransactionListPageBase() {
}
return totalCount;
});
const allTransactionTagGroupsWithDefault = computed<TransactionTagGroup[]>(() => {
const allGroups: TransactionTagGroup[] = [];
const defaultGroup = TransactionTagGroup.createNewTagGroup(tt('Default Group'));
defaultGroup.id = DEFAULT_TAG_GROUP_ID;
allGroups.push(defaultGroup);
allGroups.push(...transactionTagsStore.allTransactionTagGroups);
return allGroups;
});
const allTransactionTagsByGroup = computed<Record<string, TransactionTag[]>>(() => transactionTagsStore.allTransactionTagsByGroupMap);
const allTransactionTags = computed<Record<string, TransactionTag>>(() => transactionTagsStore.allTransactionTagsMap);
const allAvailableTagsCount = computed<number>(() => transactionTagsStore.allAvailableTagsCount);
@ -282,6 +292,22 @@ export function useTransactionListPageBase() {
return false;
}
function hasVisibleTagsInTagGroup(tagGroup: TransactionTagGroup): boolean {
const tagsInGroup = allTransactionTagsByGroup.value[tagGroup.id];
if (!tagsInGroup || !tagsInGroup.length) {
return false;
}
for (const tag of tagsInGroup) {
if (!tag.hidden || queryAllFilterTagIds.value[tag.id]) {
return true;
}
}
return false;
}
function isSameAsDefaultTimezoneOffsetMinutes(transaction: Transaction): boolean {
return transaction.utcOffset === getTimezoneOffsetMinutes(transaction.time);
}
@ -391,6 +417,8 @@ export function useTransactionListPageBase() {
allCategories,
allPrimaryCategories,
allAvailableCategoriesCount,
allTransactionTagGroupsWithDefault,
allTransactionTagsByGroup,
allTransactionTags,
allAvailableTagsCount,
displayPageTypeName,
@ -416,6 +444,7 @@ export function useTransactionListPageBase() {
canAddTransaction,
// functions
hasSubCategoryInQuery,
hasVisibleTagsInTagGroup,
isSameAsDefaultTimezoneOffsetMinutes,
getDisplayTime,
getDisplayLongDate,

View file

@ -55,60 +55,69 @@
</v-card-text>
<v-card-text :class="{ 'flex-grow-1 overflow-y-auto': dialogMode }" v-else-if="!loading && hasAnyVisibleTag">
<div class="mb-4" v-if="includeTagsCount > 1">
<v-btn-toggle class="toggle-buttons" density="compact" variant="outlined"
mandatory="force" divided
:model-value="includeTagFilterType"
@update:model-value="updateTransactionTagIncludeType($event)">
<v-btn :value="TransactionTagFilterType.HasAny.type">{{ tt(TransactionTagFilterType.HasAny.name) }}</v-btn>
<v-btn :value="TransactionTagFilterType.HasAll.type">{{ tt(TransactionTagFilterType.HasAll.name) }}</v-btn>
</v-btn-toggle>
</div>
<div class="mb-4" v-if="excludeTagsCount > 1">
<v-btn-toggle class="toggle-buttons" density="compact" variant="outlined"
mandatory="force" divided
:model-value="excludeTagFilterType"
@update:model-value="updateTransactionTagExcludeType($event)">
<v-btn :value="TransactionTagFilterType.NotHasAny.type">{{ tt(TransactionTagFilterType.NotHasAny.name) }}</v-btn>
<v-btn :value="TransactionTagFilterType.NotHasAll.type">{{ tt(TransactionTagFilterType.NotHasAll.name) }}</v-btn>
</v-btn-toggle>
</div>
<v-expansion-panels class="tag-categories" multiple v-model="expandTagCategories">
<v-expansion-panel class="border" key="default" value="default">
<v-expansion-panel-title class="expand-panel-title-with-bg py-0">
<span class="ms-3">{{ tt('Tags') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list rounded density="comfortable" class="pa-0">
<template :key="transactionTag.id"
v-for="transactionTag in allVisibleTags">
<v-list-item>
<template #prepend>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="2" offset-y="2" :icon="mdiEyeOffOutline"
v-if="transactionTag.hidden">
<v-icon size="24" :icon="mdiPound"/>
</v-badge>
<v-icon size="24" :icon="mdiPound" v-else-if="!transactionTag.hidden"/>
<span class="ms-3">{{ transactionTag.name }}</span>
</template>
<template #append>
<v-btn-toggle class="toggle-buttons" density="compact" variant="outlined"
mandatory="force" divided
:model-value="filterTagIds[transactionTag.id]"
@update:model-value="updateTransactionTagState(transactionTag, $event)">
<v-btn :value="TransactionTagFilterState.Include">{{ tt('Included') }}</v-btn>
<v-btn :value="TransactionTagFilterState.Default">{{ tt('Default') }}</v-btn>
<v-btn :value="TransactionTagFilterState.Exclude">{{ tt('Excluded') }}</v-btn>
</v-btn-toggle>
</template>
</v-list-item>
</template>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panels class="tag-categories" multiple v-model="expandTagGroups">
<template :key="tagGroup.id" v-for="tagGroup in allTagGroupsWithDefault">
<v-expansion-panel class="border" :value="tagGroup.id" v-if="allVisibleTags[tagGroup.id] && allVisibleTags[tagGroup.id]!.length > 0">
<v-expansion-panel-title class="expand-panel-title-with-bg py-0">
<span class="ms-3 text-truncate">{{ tagGroup.name }}</span>
<v-spacer/>
<div class="d-flex me-3" v-if="groupTagFilterTypesMap[tagGroup.id] && groupTagFilterStateCountMap[tagGroup.id]">
<v-btn color="secondary" density="compact" variant="outlined"
v-if="groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Include] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Include] > 1">
{{ groupTagFilterTypesMap[tagGroup.id]!.includeType === TransactionTagFilterType.HasAll.type ? tt(TransactionTagFilterType.HasAll.name) : tt(TransactionTagFilterType.HasAny.name) }}
<v-menu activator="parent">
<v-list>
<v-list-item :key="filterType.type" :title="tt(filterType.name)"
:append-icon="groupTagFilterTypesMap[tagGroup.id]!.includeType === filterType.type ? mdiCheck : undefined"
v-for="filterType in [TransactionTagFilterType.HasAny, TransactionTagFilterType.HasAll]"
@click="updateTransactionTagGroupIncludeType(tagGroup, filterType)"></v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
v-if="groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Exclude] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Exclude] > 1">
{{ groupTagFilterTypesMap[tagGroup.id]!.excludeType === TransactionTagFilterType.NotHasAll.type ? tt(TransactionTagFilterType.NotHasAll.name) : tt(TransactionTagFilterType.NotHasAny.name) }}
<v-menu activator="parent">
<v-list>
<v-list-item :key="filterType.type" :title="tt(filterType.name)"
:append-icon="groupTagFilterTypesMap[tagGroup.id]!.excludeType === filterType.type ? mdiCheck : undefined"
v-for="filterType in [TransactionTagFilterType.NotHasAny, TransactionTagFilterType.NotHasAll]"
@click="updateTransactionTagGroupExcludeType(tagGroup, filterType)"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list rounded density="comfortable" class="pa-0">
<template :key="transactionTag.id"
v-for="transactionTag in allVisibleTags[tagGroup.id]">
<v-list-item class="ps-2">
<template #prepend>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="2" offset-y="2" :icon="mdiEyeOffOutline"
v-if="transactionTag.hidden">
<v-icon size="24" :icon="mdiPound"/>
</v-badge>
<v-icon size="24" :icon="mdiPound" v-else-if="!transactionTag.hidden"/>
<span class="ms-3">{{ transactionTag.name }}</span>
</template>
<template #append>
<v-btn-toggle class="toggle-buttons" density="compact" variant="outlined"
mandatory="force" divided
:model-value="tagFilterStateMap[transactionTag.id]"
@update:model-value="updateTransactionTagState(transactionTag, $event)">
<v-btn :value="TransactionTagFilterState.Include">{{ tt('Included') }}</v-btn>
<v-btn :value="TransactionTagFilterState.Default">{{ tt('Default') }}</v-btn>
<v-btn :value="TransactionTagFilterState.Exclude">{{ tt('Excluded') }}</v-btn>
</v-btn-toggle>
</template>
</v-list-item>
</template>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</template>
</v-expansion-panels>
</v-card-text>
@ -136,11 +145,15 @@ import {
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { values } from '@/core/base.ts';
import { TransactionTagFilterType } from '@/core/transaction.ts';
import type { TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import type { TransactionTag } from '@/models/transaction_tag.ts';
import {
mdiMagnify,
mdiCheck,
mdiSelectAll,
mdiEyeOutline,
mdiEyeOffOutline,
@ -166,14 +179,14 @@ const {
loading,
showHidden,
filterContent,
filterTagIds,
includeTagFilterType,
excludeTagFilterType,
includeTagsCount,
excludeTagsCount,
tagFilterStateMap,
groupTagFilterTypesMap,
title,
applyText,
groupTagFilterStateCountMap,
allTagGroupsWithDefault,
allVisibleTags,
allVisibleTagGroupIds,
hasAnyAvailableTag,
hasAnyVisibleTag,
loadFilterTagIds,
@ -184,13 +197,14 @@ const transactionTagsStore = useTransactionTagsStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const expandTagCategories = ref<string[]>([ 'default' ]);
const expandTagGroups = ref<string[]>(allVisibleTagGroupIds.value);
function init(): void {
transactionTagsStore.loadAllTags({
force: false
}).then(() => {
loading.value = false;
expandTagGroups.value = allVisibleTagGroupIds.value;
if (!loadFilterTagIds()) {
snackbar.value?.showError('Parameter Invalid');
@ -205,23 +219,35 @@ function init(): void {
}
function updateTransactionTagState(transactionTag: TransactionTag, value: TransactionTagFilterState): void {
filterTagIds.value[transactionTag.id] = value;
tagFilterStateMap.value[transactionTag.id] = value;
if (props.autoSave) {
save();
}
}
function updateTransactionTagIncludeType(value: number): void {
includeTagFilterType.value = value;
function updateTransactionTagGroupIncludeType(tagGroup: TransactionTagGroup, filterType: TransactionTagFilterType): void {
const tagFilterTypes = groupTagFilterTypesMap.value[tagGroup.id];
if (!tagFilterTypes) {
return;
}
tagFilterTypes.includeType = filterType.type;
if (props.autoSave) {
save();
}
}
function updateTransactionTagExcludeType(value: number): void {
excludeTagFilterType.value = value;
function updateTransactionTagGroupExcludeType(tagGroup: TransactionTagGroup, filterType: TransactionTagFilterType): void {
const tagFilterTypes = groupTagFilterTypesMap.value[tagGroup.id];
if (!tagFilterTypes) {
return;
}
tagFilterTypes.excludeType = filterType.type;
if (props.autoSave) {
save();
@ -229,8 +255,10 @@ function updateTransactionTagExcludeType(value: number): void {
}
function setAllTagsState(value: TransactionTagFilterState): void {
for (const tag of allVisibleTags.value) {
filterTagIds.value[tag.id] = value;
for (const tags of values(allVisibleTags.value)) {
for (const tag of tags) {
tagFilterStateMap.value[tag.id] = value;
}
}
if (props.autoSave) {

View file

@ -171,22 +171,24 @@
@error="onShowDateRangeError" />
<explorer-list-dialog ref="explorerListDialog" />
<explorer-rename-dialog ref="explorerRenameDialog" />
<edit-dialog ref="editDialog" :type="TransactionEditPageType.Transaction" />
<export-dialog ref="exportDialog" />
<rename-dialog ref="renameDialog"
:default-title="tt('Rename Explorer')"
:label="tt('Explorer Name')" :placeholder="tt('Explorer Name')" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import RenameDialog from '@/components/desktop/RenameDialog.vue';
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 ExplorerChartTab from '@/views/desktop/insights/tabs/ExplorerChartTab.vue';
import ExplorerListDialog from '@/views/desktop/insights/dialogs/ExplorerListDialog.vue';
import ExplorerRenameDialog from '@/views/desktop/insights/dialogs/ExplorerRenameDialog.vue';
import EditDialog from '@/views/desktop/transactions/list/dialogs/EditDialog.vue';
import ExportDialog from '@/views/desktop/statistics/transaction/dialogs/ExportDialog.vue';
@ -251,12 +253,12 @@ const props = defineProps<InsightsExplorerProps>();
type ExplorerPageTabType = 'query' | 'table' | 'chart';
type RenameDialogType = InstanceType<typeof RenameDialog>;
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
type ExplorerDataTableTabType = InstanceType<typeof ExplorerDataTableTab>;
type ExplorerChartTabType = InstanceType<typeof ExplorerChartTab>;
type ExplorerListDialogType = InstanceType<typeof ExplorerListDialog>;
type ExplorerRenameDialogType = InstanceType<typeof ExplorerRenameDialog>;
type EditDialogType = InstanceType<typeof EditDialog>;
type ExportDialogType = InstanceType<typeof ExportDialog>;
@ -282,12 +284,12 @@ const timezoneTypeIconMap = {
[TimezoneTypeForStatistics.TransactionTimezone.type]: mdiInvoiceTextClockOutline
};
const renameDialog = useTemplateRef<RenameDialogType>('renameDialog');
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const explorerDataTableTab = useTemplateRef<ExplorerDataTableTabType>('explorerDataTableTab');
const explorerChartTab = useTemplateRef<ExplorerChartTabType>('explorerChartTab');
const explorerListDialog = useTemplateRef<ExplorerListDialogType>('explorerListDialog');
const explorerRenameDialog = useTemplateRef<ExplorerRenameDialogType>('explorerRenameDialog');
const exportDialog = useTemplateRef<ExportDialogType>('exportDialog');
const editDialog = useTemplateRef<EditDialogType>('editDialog');
@ -515,7 +517,7 @@ function showChangeExplorerDisplayOrderDialog(): void {
function saveExplorer(saveAs?: boolean): void {
if (saveAs || !currentExplorer.value.name) {
explorerRenameDialog.value?.open(currentExplorer.value.name || '', tt('Set Explorer Name')).then((newName: string) => {
renameDialog.value?.open(currentExplorer.value.name || '', tt('Set Explorer Name')).then((newName: string) => {
currentExplorer.value.name = newName;
doSaveExplorer(saveAs);
})
@ -571,7 +573,7 @@ function restoreExplorer(): void {
}
function setExplorerName(): void {
explorerRenameDialog.value?.open(currentExplorer.value.name || '').then((newName: string) => {
renameDialog.value?.open(currentExplorer.value.name || '').then((newName: string) => {
currentExplorer.value.name = newName;
});
}

View file

@ -251,56 +251,12 @@
(conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsNotEmpty.value)"
/>
<v-autocomplete
<transaction-tag-auto-complete
density="compact"
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
:items="allTags"
v-model="conditionWithRelation.condition.value"
v-model:search="tagSearchContent"
v-else-if="conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsNotEmpty.value"
>
<template #chip="{ props, item }">
<v-chip :prepend-icon="mdiPound" :text="item.title" 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="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
<v-list-item :disabled="true" v-bind="props"
v-if="item.raw.hidden && item.raw.name.toLowerCase().indexOf(tagSearchContent.toLowerCase()) >= 0 && isAllFilteredTagHidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
<template #no-data>
<v-list class="py-0">
<v-list-item>{{ tt('No available tag') }}</v-list-item>
</v-list>
</template>
</v-autocomplete>
/>
</div>
<v-text-field disabled density="compact"
@ -401,10 +357,6 @@ import {
TransactionExplorerConditionOperator
} from '@/core/explorer.ts';
import {
type TransactionTag
} from '@/models/transaction_tag.ts';
import {
type TransactionExplorerCondition,
TransactionExplorerQuery
@ -427,8 +379,7 @@ import {
mdiContentCopy,
mdiCheck,
mdiClose,
mdiDrag,
mdiPound
mdiDrag
} from '@mdi/js';
interface ExplorerQueryTabProps {
@ -460,7 +411,6 @@ const showExpression = ref<Record<string, boolean>>({});
const showFilterSourceAccountsDialog = ref<boolean>(false);
const showFilterDestinationAccountsDialog = ref<boolean>(false);
const showFilterTransactionCategoriesDialog = ref<boolean>(false);
const tagSearchContent = ref<string>('');
const editingQuery = ref<TransactionExplorerQuery | undefined>(undefined);
const editingQueryName = ref<string>('');
@ -474,27 +424,9 @@ const queries = computed<TransactionExplorerQuery[]>({
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const hasAnyAccount = computed<boolean>(() => accountsStore.allPlainAccounts.length > 0);
const hasAnyTransactionCategory = computed<boolean>(() => !isObjectEmpty(transactionCategoriesStore.allTransactionCategoriesMap));
const allTags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTags);
const allTransactionExplorerConditionFields = computed<NameValue[]>(() => getAllTransactionExplorerConditionFields());
const isAllFilteredTagHidden = computed<boolean>(() => {
const lowerCaseTagSearchContent = tagSearchContent.value.toLowerCase();
let hiddenCount = 0;
for (const tag of allTags.value) {
if (!lowerCaseTagSearchContent || tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent) >= 0) {
if (!tag.hidden) {
return false;
}
hiddenCount++;
}
}
return hiddenCount > 0;
});
function getFilteredAccountsDisplayContent(filterAccountIds?: Record<string, boolean>): string {
if ((props.loading && !hasAnyAccount.value) || !accountsStore.allVisiblePlainAccounts || !accountsStore.allVisiblePlainAccounts.length) {
return '';

View file

@ -2,288 +2,406 @@
<v-row class="match-height">
<v-col cols="12">
<v-card>
<template #title>
<div class="title-and-toolbar d-flex align-center">
<span>{{ tt('Transaction Tags') }}</span>
<v-btn class="ms-3" color="default" variant="outlined"
:disabled="loading || updating || hasEditingTag" @click="add">{{ tt('Add') }}</v-btn>
<v-btn class="ms-3" color="primary" variant="tonal"
:disabled="loading || updating || hasEditingTag" @click="saveSortResult"
v-if="displayOrderModified">{{ tt('Save Display Order') }}</v-btn>
<v-btn density="compact" color="default" variant="text" size="24"
class="ms-2" :icon="true" :disabled="loading || updating || hasEditingTag"
: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>
<v-spacer/>
<v-btn density="comfortable" color="default" variant="text" class="ms-2"
:disabled="loading || updating || hasEditingTag" :icon="true">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="mdiEyeOutline"
:title="tt('Show Hidden Transaction Tags')"
v-if="!showHidden" @click="showHidden = true"></v-list-item>
<v-list-item :prepend-icon="mdiEyeOffOutline"
:title="tt('Hide Hidden Transaction Tags')"
v-if="showHidden" @click="showHidden = false"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<v-table class="transaction-tags-table table-striped" :hover="!loading">
<thead>
<tr>
<th>
<div class="d-flex align-center">
<span>{{ tt('Tag Title') }}</span>
<v-spacer/>
<span>{{ tt('Operation') }}</span>
</div>
</th>
</tr>
</thead>
<tbody v-if="loading && noAvailableTag && !newTag">
<tr :key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]">
<td class="px-0">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
</tbody>
<tbody v-if="!loading && noAvailableTag && !newTag">
<tr>
<td>{{ tt('No available tag') }}</td>
</tr>
</tbody>
<draggable-list tag="tbody"
item-key="id"
handle=".drag-handle"
ghost-class="dragging-item"
:class="{ 'has-bottom-border': newTag }"
:disabled="noAvailableTag"
v-model="tags"
@change="onMove">
<template #item="{ element }">
<tr class="transaction-tags-table-row-tag text-sm" v-if="showHidden || !element.hidden">
<td>
<div class="d-flex align-center">
<div class="d-flex align-center" v-if="editingTag.id !== element.id">
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="mdiEyeOffOutline"
v-if="element.hidden">
<v-icon size="20" start :icon="mdiPound"/>
</v-badge>
<v-icon size="20" start :icon="mdiPound" v-else-if="!element.hidden"/>
<span class="transaction-tag-name">{{ element.name }}</span>
</div>
<v-text-field class="w-100 me-2" type="text"
density="compact" variant="underlined"
:disabled="loading || updating"
:placeholder="tt('Tag Title')"
v-model="editingTag.name"
v-else-if="editingTag.id === element.id"
@keyup.enter="save(editingTag)"
>
<template #prepend>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="mdiEyeOffOutline"
v-if="element.hidden">
<v-icon size="20" start :icon="mdiPound"/>
</v-badge>
<v-icon size="20" start :icon="mdiPound" v-else-if="!element.hidden"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2 ms-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="element.hidden ? mdiEyeOutline : mdiEyeOffOutline"
:loading="tagHiding[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="hide(element, !element.hidden)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ element.hidden ? tt('Show') : tt('Hide') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="mdiPencilOutline"
:loading="tagUpdating[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="edit(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Edit') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="mdiDeleteOutline"
:loading="tagRemoving[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="remove(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Delete') }}
</v-btn>
<v-btn class="px-2"
density="comfortable" variant="text"
:prepend-icon="mdiCheck"
:loading="tagUpdating[element.id]"
:disabled="loading || updating || !isTagModified(element)"
v-if="editingTag.id === element.id" @click="save(editingTag)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="mdiClose"
:disabled="loading || updating"
v-if="editingTag.id === element.id" @click="cancelSave(editingTag)">
{{ tt('Cancel') }}
</v-btn>
<span class="ms-2">
<v-icon :class="!loading && !updating && !hasEditingTag && availableTagCount > 1 ? 'drag-handle' : 'disabled'"
:icon="mdiDrag"/>
<v-tooltip activator="parent" v-if="!loading && !updating && !hasEditingTag && availableTagCount > 1">{{ tt('Drag to Reorder') }}</v-tooltip>
</span>
</div>
</td>
</tr>
</template>
</draggable-list>
<tbody v-if="newTag">
<tr class="text-sm" :class="{ 'even-row': (availableTagCount & 1) === 1}">
<td>
<div class="d-flex align-center">
<v-text-field class="w-100 me-2" type="text" color="primary"
density="compact" variant="underlined"
:disabled="loading || updating" :placeholder="tt('Tag Title')"
v-model="newTag.name" @keyup.enter="save(newTag)">
<template #prepend>
<v-icon size="20" start :icon="mdiPound"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2" density="comfortable" variant="text"
:prepend-icon="mdiCheck"
:loading="tagUpdating['']"
:disabled="loading || updating || !isTagModified(newTag)"
@click="save(newTag)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="mdiClose"
:disabled="loading || updating"
@click="cancelSave(newTag)">
{{ tt('Cancel') }}
</v-btn>
<span class="ms-2">
<v-icon class="disabled" :icon="mdiDrag"/>
<v-layout>
<v-navigation-drawer :permanent="alwaysShowNav" v-model="showNav">
<div class="mx-6 my-4">
<span class="text-subtitle-2">{{ tt('Total tags') }}</span>
<p class="transaction-tags-statistic-item-value mt-1">
<span v-if="!loading || totalAvailableTagsCount > 0">{{ displayTotalAvailableTagsCount }}</span>
<span v-else-if="loading && totalAvailableTagsCount <= 0">
<v-skeleton-loader class="skeleton-no-margin pt-2 pb-1" type="text" :loading="true"></v-skeleton-loader>
</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</p>
</div>
<v-divider />
<v-tabs show-arrows
class="scrollable-vertical-tabs"
style="max-height: calc(100% - 88px)"
direction="vertical"
:prev-icon="mdiMenuUp" :next-icon="mdiMenuDown"
:disabled="loading || updating" v-model="activeTagGroupId">
<v-tab class="tab-text-truncate" :disabled="loading || updating || hasEditingTag"
:key="tagGroup.id" :value="tagGroup.id"
v-for="tagGroup in allTagGroupsWithDefault"
@click="switchTagGroup(tagGroup.id)">
<span class="text-truncate">{{ tagGroup.name }}</span>
</v-tab>
<template v-if="loading && (!allTagGroupsWithDefault || allTagGroupsWithDefault.length < 1)">
<v-skeleton-loader class="skeleton-no-margin mx-5 mt-4 mb-3" type="text"
:key="itemIdx" :loading="true" v-for="itemIdx in [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]"></v-skeleton-loader>
</template>
</v-tabs>
</v-navigation-drawer>
<v-main>
<v-window class="d-flex flex-grow-1 disable-tab-transition w-100-window-container" v-model="activeTab">
<v-window-item value="tagListPage">
<v-card variant="flat" min-height="780">
<template #title>
<div class="title-and-toolbar d-flex align-center">
<v-btn class="me-3 d-md-none" density="compact" color="default" variant="plain"
:ripple="false" :icon="true" @click="showNav = !showNav">
<v-icon :icon="mdiMenu" size="24" />
</v-btn>
<span>{{ tt('Transaction Tags') }}</span>
<v-btn class="ms-3" color="default" variant="outlined"
:disabled="loading || updating || hasEditingTag" @click="add">{{ tt('Add') }}</v-btn>
<v-btn class="ms-3" color="primary" variant="tonal"
:disabled="loading || updating || hasEditingTag" @click="saveSortResult"
v-if="displayOrderModified">{{ tt('Save Display Order') }}</v-btn>
<v-btn density="compact" color="default" variant="text" size="24"
class="ms-2" :icon="true" :disabled="loading || updating || hasEditingTag"
: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>
<v-spacer/>
<v-btn density="comfortable" color="default" variant="text" class="ms-2"
:disabled="loading || updating || hasEditingTag" :icon="true">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="mdiPlus" @click="addTagGroup">
<v-list-item-title>{{ tt('Add Tag Group') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiPencilOutline"
@click="renameTagGroup"
v-if="activeTagGroupId && activeTagGroupId !== DEFAULT_TAG_GROUP_ID">
<v-list-item-title>{{ tt('Rename Tag Group') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiDeleteOutline"
@click="removeTagGroup"
v-if="activeTagGroupId && activeTagGroupId !== DEFAULT_TAG_GROUP_ID">
<v-list-item-title>{{ tt('Delete Tag Group') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiSort"
:disabled="!allTagGroupsWithDefault || allTagGroupsWithDefault.length < 2"
:title="tt('Change Group Display Order')"
@click="showChangeGroupDisplayOrderDialog"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiEyeOutline"
:title="tt('Show Hidden Transaction Tags')"
v-if="!showHidden" @click="showHidden = true"></v-list-item>
<v-list-item :prepend-icon="mdiEyeOffOutline"
:title="tt('Hide Hidden Transaction Tags')"
v-if="showHidden" @click="showHidden = false"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<v-table class="transaction-tags-table table-striped" :hover="!loading">
<thead>
<tr>
<th>
<div class="d-flex align-center">
<span>{{ tt('Tag Title') }}</span>
<v-spacer/>
<span>{{ tt('Operation') }}</span>
</div>
</th>
</tr>
</thead>
<tbody v-if="loading && noAvailableTag && !newTag">
<tr :key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]">
<td class="px-0">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
</tbody>
<tbody v-if="!loading && noAvailableTag && !newTag">
<tr>
<td>{{ tt('No available tag') }}</td>
</tr>
</tbody>
<draggable-list tag="tbody"
item-key="id"
handle=".drag-handle"
ghost-class="dragging-item"
:class="{ 'has-bottom-border': newTag }"
:disabled="noAvailableTag"
v-model="tags"
@change="onMove">
<template #item="{ element }">
<tr class="transaction-tags-table-row-tag text-sm" v-if="showHidden || !element.hidden">
<td>
<div class="d-flex align-center">
<div class="d-flex align-center" v-if="editingTag.id !== element.id">
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="mdiEyeOffOutline"
v-if="element.hidden">
<v-icon size="20" start :icon="mdiPound"/>
</v-badge>
<v-icon size="20" start :icon="mdiPound" v-else-if="!element.hidden"/>
<span class="transaction-tag-name">{{ element.name }}</span>
</div>
<v-text-field class="w-100 me-2" type="text"
density="compact" variant="underlined"
:disabled="loading || updating"
:placeholder="tt('Tag Title')"
v-model="editingTag.name"
v-else-if="editingTag.id === element.id"
@keyup.enter="save(editingTag)"
>
<template #prepend>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-x="8" :icon="mdiEyeOffOutline"
v-if="element.hidden">
<v-icon size="20" start :icon="mdiPound"/>
</v-badge>
<v-icon size="20" start :icon="mdiPound" v-else-if="!element.hidden"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2 ms-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="element.hidden ? mdiEyeOutline : mdiEyeOffOutline"
:loading="tagHiding[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="hide(element, !element.hidden)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ element.hidden ? tt('Show') : tt('Hide') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="mdiFolderMoveOutline"
:loading="tagMoving[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Move') }}
<v-menu activator="parent" max-height="500">
<v-list>
<v-list-subheader :title="tt('Move to...')"/>
<template :key="tagGroup.id" v-for="tagGroup in allTagGroupsWithDefault">
<v-list-item class="text-sm" density="compact"
:value="tagGroup.id" v-if="activeTagGroupId !== tagGroup.id">
<v-list-item-title class="cursor-pointer"
@click="moveTagToGroup(element, tagGroup.id)">
<div class="d-flex align-center">
<span class="text-sm ms-3">{{ tagGroup.name }}</span>
</div>
</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="mdiPencilOutline"
:loading="tagUpdating[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="edit(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Edit') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:class="{ 'd-none': loading, 'hover-display': !loading }"
:prepend-icon="mdiDeleteOutline"
:loading="tagRemoving[element.id]"
:disabled="loading || updating"
v-if="editingTag.id !== element.id"
@click="remove(element)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Delete') }}
</v-btn>
<v-btn class="px-2"
density="comfortable" variant="text"
:prepend-icon="mdiCheck"
:loading="tagUpdating[element.id]"
:disabled="loading || updating || !isTagModified(element)"
v-if="editingTag.id === element.id" @click="save(editingTag)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="mdiClose"
:disabled="loading || updating"
v-if="editingTag.id === element.id" @click="cancelSave(editingTag)">
{{ tt('Cancel') }}
</v-btn>
<span class="ms-2">
<v-icon :class="!loading && !updating && !hasEditingTag && availableTagCount > 1 ? 'drag-handle' : 'disabled'"
:icon="mdiDrag"/>
<v-tooltip activator="parent" v-if="!loading && !updating && !hasEditingTag && availableTagCount > 1">{{ tt('Drag to Reorder') }}</v-tooltip>
</span>
</div>
</td>
</tr>
</template>
</draggable-list>
<tbody v-if="newTag">
<tr class="text-sm" :class="{ 'even-row': (availableTagCount & 1) === 1}">
<td>
<div class="d-flex align-center">
<v-text-field class="w-100 me-2" type="text" color="primary"
density="compact" variant="underlined"
:disabled="loading || updating" :placeholder="tt('Tag Title')"
v-model="newTag.name" @keyup.enter="save(newTag)">
<template #prepend>
<v-icon size="20" start :icon="mdiPound"/>
</template>
</v-text-field>
<v-spacer/>
<v-btn class="px-2" density="comfortable" variant="text"
:prepend-icon="mdiCheck"
:loading="tagUpdating['']"
:disabled="loading || updating || !isTagModified(newTag)"
@click="save(newTag)">
<template #loader>
<v-progress-circular indeterminate size="20" width="2"/>
</template>
{{ tt('Save') }}
</v-btn>
<v-btn class="px-2" color="default"
density="comfortable" variant="text"
:prepend-icon="mdiClose"
:disabled="loading || updating"
@click="cancelSave(newTag)">
{{ tt('Cancel') }}
</v-btn>
<span class="ms-2">
<v-icon class="disabled" :icon="mdiDrag"/>
</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-window-item>
</v-window>
</v-main>
</v-layout>
</v-card>
</v-col>
</v-row>
<tag-group-change-display-order-dialog ref="tagGroupChangeDisplayOrderDialog" />
<rename-dialog ref="renameDialog"
:default-title="tt('Rename Tag Group')"
:label="tt('Tag Group Name')" :placeholder="tt('Tag Group Name')" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import TagGroupChangeDisplayOrderDialog from './dialog/TagGroupChangeDisplayOrderDialog.vue';
import RenameDialog from '@/components/desktop/RenameDialog.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, computed, useTemplateRef } from 'vue';
import { ref, computed, useTemplateRef, watch } from 'vue';
import { useDisplay } from 'vuetify';
import { useI18n } from '@/locales/helpers.ts';
import { useTagListPageBase } from '@/views/base/tags/TagListPageBase.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import { TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import {
isNoAvailableTag,
getAvailableTagCount
} from '@/lib/tag.ts';
import { getAvailableTagCount } from '@/lib/tag.ts';
import {
mdiRefresh,
mdiMenuUp,
mdiMenuDown,
mdiPencilOutline,
mdiCheck,
mdiClose,
mdiEyeOffOutline,
mdiEyeOutline,
mdiSort,
mdiMenu,
mdiPlus,
mdiFolderMoveOutline,
mdiDeleteOutline,
mdiDrag,
mdiDotsVertical,
mdiPound
} from '@mdi/js';
type TagGroupChangeDisplayOrderDialogType = InstanceType<typeof TagGroupChangeDisplayOrderDialog>;
type RenameDialogType = InstanceType<typeof RenameDialog>;
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
const { tt } = useI18n();
const display = useDisplay();
const { tt, formatNumberToLocalizedNumerals } = useI18n();
const {
activeTagGroupId,
newTag,
editingTag,
loading,
showHidden,
displayOrderModified,
allTagGroupsWithDefault,
tags,
noAvailableTag,
hasEditingTag,
isTagModified,
switchTagGroup,
add,
edit
} = useTagListPageBase();
const transactionTagsStore = useTransactionTagsStore();
const tagGroupChangeDisplayOrderDialog = useTemplateRef<TagGroupChangeDisplayOrderDialogType>('tagGroupChangeDisplayOrderDialog');
const renameDialog = useTemplateRef<RenameDialogType>('renameDialog');
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const newTag = ref<TransactionTag | null>(null);
const editingTag = ref<TransactionTag>(TransactionTag.createNewTag());
const loading = ref<boolean>(true);
const updating = ref<boolean>(false);
const activeTab = ref<string>('tagListPage');
const alwaysShowNav = ref<boolean>(display.mdAndUp.value);
const showNav = ref<boolean>(display.mdAndUp.value);
const tagUpdating = ref<Record<string, boolean>>({});
const tagHiding = ref<Record<string, boolean>>({});
const tagMoving = ref<Record<string, boolean>>({});
const tagRemoving = ref<Record<string, boolean>>({});
const displayOrderModified = ref<boolean>(false);
const showHidden = ref<boolean>(false);
const tags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTags);
const noAvailableTag = computed<boolean>(() => isNoAvailableTag(tags.value, showHidden.value));
const totalAvailableTagsCount = computed<number>(() => transactionTagsStore.allAvailableTagsCount);
const displayTotalAvailableTagsCount = computed<string>(() => formatNumberToLocalizedNumerals(transactionTagsStore.allAvailableTagsCount));
const availableTagCount = computed<number>(() => getAvailableTagCount(tags.value, showHidden.value));
const hasEditingTag = computed<boolean>(() => !!(newTag.value || (editingTag.value.id && editingTag.value.id !== '')));
function isTagModified(tag: TransactionTag): boolean {
if (tag.id) {
return editingTag.value.name !== '' && editingTag.value.name !== tag.name;
} else {
return tag.name !== '';
}
}
function reload(): void {
if (hasEditingTag.value) {
@ -312,13 +430,125 @@ function reload(): void {
});
}
function add(): void {
newTag.value = TransactionTag.createNewTag();
function addTagGroup(): void {
renameDialog.value?.open('', tt('New Tag Group Name')).then((newName: string) => {
updating.value = true;
transactionTagsStore.saveTagGroup({
tagGroup: TransactionTagGroup.createNewTagGroup(newName)
}).then(() => {
updating.value = false;
}).catch(error => {
updating.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
});
}
function edit(tag: TransactionTag): void {
editingTag.value.id = tag.id;
editingTag.value.name = tag.name;
function renameTagGroup(): void {
const tagGroup = transactionTagsStore.allTransactionTagGroupsMap[activeTagGroupId.value];
if (!tagGroup) {
snackbar.value?.showMessage('Unable to rename this tag group');
return;
}
renameDialog.value?.open(tagGroup.name || '').then((newName: string) => {
updating.value = true;
const newTagGroup = tagGroup.clone();
newTagGroup.name = newName;
transactionTagsStore.saveTagGroup({
tagGroup: newTagGroup
}).then(() => {
updating.value = false;
}).catch(error => {
updating.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
});
}
function showChangeGroupDisplayOrderDialog(): void {
tagGroupChangeDisplayOrderDialog.value?.open().then(() => {
if (transactionTagsStore.transactionTagGroupListStateInvalid) {
loading.value = true;
transactionTagsStore.loadAllTagGroups({
force: false
}).then(() => {
loading.value = false;
}).catch(() => {
loading.value = false;
});
}
});
}
function removeTagGroup(): void {
const tagGroup = transactionTagsStore.allTransactionTagGroupsMap[activeTagGroupId.value];
if (!tagGroup) {
snackbar.value?.showMessage('Unable to delete this tag group');
return;
}
const currentTagGroupIndex = allTagGroupsWithDefault.value.findIndex(group => group.id === tagGroup.id);
confirmDialog.value?.open('Are you sure you want to delete this tag group?').then(() => {
updating.value = true;
transactionTagsStore.deleteTagGroup({
tagGroup: tagGroup
}).then(() => {
updating.value = false;
if (transactionTagsStore.allTransactionTagGroups[currentTagGroupIndex]) {
const newActiveTagGroup = transactionTagsStore.allTransactionTagGroups[currentTagGroupIndex];
activeTagGroupId.value = newActiveTagGroup ? newActiveTagGroup.id : '';
} else if (transactionTagsStore.allTransactionTagGroups[currentTagGroupIndex - 1]) {
const newActiveTagGroup = transactionTagsStore.allTransactionTagGroups[currentTagGroupIndex - 1];
activeTagGroupId.value = newActiveTagGroup ? newActiveTagGroup.id : '';
} else {
activeTagGroupId.value = '';
}
}).catch(error => {
updating.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
});
}
function moveTagToGroup(tag: TransactionTag, targetTagGroupId: string): void {
updating.value = true;
tagMoving.value[tag.id] = true;
const newTag = tag.clone();
newTag.groupId = targetTagGroupId;
transactionTagsStore.saveTag({
tag: newTag
}).then(() => {
updating.value = false;
tagMoving.value[tag.id] = false;
}).catch(error => {
updating.value = false;
tagMoving.value[tag.id] = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function save(tag: TransactionTag): void {
@ -363,7 +593,7 @@ function saveSortResult(): void {
loading.value = true;
transactionTagsStore.updateTagDisplayOrders().then(() => {
transactionTagsStore.updateTagDisplayOrders(activeTagGroupId.value).then(() => {
loading.value = false;
displayOrderModified.value = false;
}).catch(error => {
@ -450,9 +680,21 @@ transactionTagsStore.loadAllTags({
snackbar.value?.showError(error);
}
});
watch(() => display.mdAndUp.value, (newValue) => {
alwaysShowNav.value = newValue;
if (!showNav.value) {
showNav.value = newValue;
}
});
</script>
<style>
.transaction-tags-statistic-item-value {
font-size: 1rem;
}
.transaction-tags-table tr.transaction-tags-table-row-tag .hover-display {
display: none;
}

View file

@ -0,0 +1,214 @@
<template>
<v-dialog width="800" :persistent="displayOrderModified" v-model="showState">
<v-card class="pa-sm-1 pa-md-2">
<template #title>
<div class="d-flex align-center justify-center">
<div class="d-flex align-center">
<h4 class="text-h4">{{ tt('Transaction Tag Groups') }}</h4>
<v-btn class="ms-3" color="primary" variant="tonal"
:disabled="loading || updating" @click="saveDisplayOrder"
v-if="displayOrderModified">{{ tt('Save Display Order') }}</v-btn>
<v-btn density="compact" color="default" variant="text" size="24"
class="ms-2" :icon="true" :disabled="loading || updating"
: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>
<v-spacer/>
</div>
</template>
<v-card-text class="d-flex flex-column flex-md-row flex-grow-1 overflow-y-auto">
<v-table hover density="comfortable" class="w-100 table-striped">
<tbody v-if="loading && (!allTagGroups || allTagGroups.length < 1)">
<tr :key="itemIdx" v-for="itemIdx in [ 1, 2, 3, 4, 5, 6 ]">
<td class="px-0">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
</tbody>
<tbody v-if="!loading && (!allTagGroups || allTagGroups.length < 1)">
<tr>
<td>{{ tt('No available tag group') }}</td>
</tr>
</tbody>
<draggable-list tag="tbody"
item-key="id"
handle=".drag-handle"
ghost-class="dragging-item"
v-model="allTagGroups"
@change="onMove">
<template #item="{ element }">
<tr class="text-sm">
<td>
<div class="d-flex align-center">
<div class="d-flex align-center">
<span>{{ element.name }}</span>
</div>
<v-spacer/>
<span class="ms-2">
<v-icon :class="!loading && !updating && allTagGroups && allTagGroups.length > 0 ? 'drag-handle' : 'disabled'"
:icon="mdiDrag"/>
<v-tooltip activator="parent" v-if="!loading && !updating && allTagGroups && allTagGroups.length > 0">{{ tt('Drag to Reorder') }}</v-tooltip>
</span>
</div>
</td>
</tr>
</template>
</draggable-list>
</v-table>
</v-card-text>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center flex-wrap mt-sm-1 mt-md-2 gap-4">
<v-btn color="secondary" variant="tonal"
:disabled="loading || updating" @click="close">{{ tt('Close') }}</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 { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { type TransactionTagGroup } from '@/models/transaction_tag_group.ts';
import {
mdiRefresh,
mdiDrag
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
const { tt } = useI18n();
const transactionTagsStore = useTransactionTagsStore();
let resolveFunc: (() => void) | null = null;
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const showState = ref<boolean>(false);
const loading = ref<boolean>(true);
const updating = ref<boolean>(false);
const displayOrderModified = ref<boolean>(false);
const allTagGroups = computed<TransactionTagGroup[]>(() => transactionTagsStore.allTransactionTagGroups);
function open(): Promise<void> {
showState.value = true;
loading.value = true;
transactionTagsStore.loadAllTagGroups({
force: false
}).then(() => {
loading.value = false;
displayOrderModified.value = false;
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
return new Promise<void>((resolve) => {
resolveFunc = resolve;
});
}
function reload(): void {
loading.value = true;
transactionTagsStore.loadAllTagGroups({
force: true
}).then(() => {
loading.value = false;
displayOrderModified.value = false;
snackbar.value?.showMessage('Tag group list has been updated');
}).catch(error => {
loading.value = false;
if (error && error.isUpToDate) {
displayOrderModified.value = false;
}
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function saveDisplayOrder(): void {
if (!displayOrderModified.value) {
return;
}
loading.value = true;
transactionTagsStore.updateTagGroupDisplayOrders().then(() => {
loading.value = false;
displayOrderModified.value = false;
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function close(): void {
if (loading.value || updating.value) {
return;
}
resolveFunc?.();
showState.value = false;
}
function onMove(event: { moved: { element: { id: string }; oldIndex: number; newIndex: number } }): void {
if (!event || !event.moved) {
return;
}
const moveEvent = event.moved;
if (!moveEvent.element || !moveEvent.element.id) {
snackbar.value?.showMessage('Unable to move tag group');
return;
}
transactionTagsStore.changeTagGroupDisplayOrder({
tagGroupId: moveEvent.element.id,
from: moveEvent.oldIndex,
to: moveEvent.newIndex
}).then(() => {
displayOrderModified.value = true;
}).catch(error => {
snackbar.value?.showError(error);
});
}
defineExpose({
open
});
</script>

View file

@ -259,15 +259,17 @@
<template :key="categoryType"
v-for="(categories, categoryType) in allPrimaryCategories">
<v-divider />
<v-list-item density="compact" v-show="categories && categories.length">
<v-list-item-title>
<span class="text-sm">{{ getTransactionTypeName(categoryTypeToTransactionType(parseInt(categoryType)), 'Type') }}</span>
</v-list-item-title>
</v-list-item>
<v-list-group :key="category.id" v-for="category in categories">
<v-list-group :key="category.id" v-for="(category, index) in categories">
<template #activator="{ props }" v-if="!category.hidden || queryAllFilterCategoryIds[category.id] || allCategories[query.categoryIds]?.parentId === category.id || hasSubCategoryInQuery(category)">
<v-divider />
<v-divider v-if="index > 0" />
<v-list-item class="text-sm" density="compact"
:class="getCategoryListItemCheckedClass(category, queryAllFilterCategoryIds)"
v-bind="props">
@ -474,24 +476,33 @@
</v-list-item-title>
</v-list-item>
<v-divider v-if="query.tagFilter && query.tagFilter !== TransactionTagFilter.TransactionNoTagFilterValue" />
<template :key="transactionTagGroup.id"
v-for="transactionTagGroup in allTransactionTagGroupsWithDefault">
<v-divider v-if="allTransactionTagsByGroup[transactionTagGroup.id] && allTransactionTagsByGroup[transactionTagGroup.id]?.length && hasVisibleTagsInTagGroup(transactionTagGroup)" />
<template :key="transactionTag.id"
v-for="transactionTag in allTransactionTags">
<v-divider v-if="!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id])" />
<v-list-item class="text-sm" density="compact"
:value="transactionTag.id"
:class="{ 'list-item-selected': queryAllFilterTagIdsCount === 1 && isDefined(queryAllFilterTagIds[transactionTag.id]), 'item-in-multiple-selection': queryAllFilterTagIdsCount > 1 && isDefined(queryAllFilterTagIds[transactionTag.id]) }"
:append-icon="(queryAllFilterTagIds[transactionTag.id] === true ? mdiCheck : (queryAllFilterTagIds[transactionTag.id] === false ? mdiClose : undefined))"
v-if="!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id])">
<v-list-item-title class="cursor-pointer"
@click="changeTagFilter(TransactionTagFilter.of(transactionTag.id).toTextualTagFilter())">
<div class="d-flex align-center">
<v-icon size="24" :icon="mdiPound"/>
<span class="text-sm ms-3">{{ transactionTag.name }}</span>
</div>
<v-list-item density="compact" v-if="allTransactionTagsByGroup[transactionTagGroup.id] && allTransactionTagsByGroup[transactionTagGroup.id]?.length && hasVisibleTagsInTagGroup(transactionTagGroup)">
<v-list-item-title>
<span class="text-sm">{{ transactionTagGroup.name }}</span>
</v-list-item-title>
</v-list-item>
<template :key="transactionTag.id"
v-for="(transactionTag, index) in (allTransactionTagsByGroup[transactionTagGroup.id] ?? [])">
<v-divider v-if="index > 0 && (!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id]))" />
<v-list-item class="text-sm" density="compact"
:value="transactionTag.id"
:class="{ 'list-item-selected': queryAllFilterTagIdsCount === 1 && isDefined(queryAllFilterTagIds[transactionTag.id]), 'item-in-multiple-selection': queryAllFilterTagIdsCount > 1 && isDefined(queryAllFilterTagIds[transactionTag.id]) }"
:append-icon="(queryAllFilterTagIds[transactionTag.id] === true ? mdiCheck : (queryAllFilterTagIds[transactionTag.id] === false ? mdiClose : undefined))"
v-if="!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id])">
<v-list-item-title class="cursor-pointer"
@click="changeTagFilter(TransactionTagFilter.of(transactionTag.id).toTextualTagFilter())">
<div class="d-flex align-center">
<v-icon size="24" :icon="mdiPound"/>
<span class="text-sm ms-3">{{ transactionTag.name }}</span>
</div>
</v-list-item-title>
</v-list-item>
</template>
</template>
</v-list>
</v-menu>
@ -785,6 +796,8 @@ const {
allCategories,
allPrimaryCategories,
allAvailableCategoriesCount,
allTransactionTagGroupsWithDefault,
allTransactionTagsByGroup,
allTransactionTags,
allAvailableTagsCount,
query,
@ -806,6 +819,7 @@ const {
transactionCalendarMaxDate,
currentMonthTransactionData,
hasSubCategoryInQuery,
hasVisibleTagsInTagGroup,
isSameAsDefaultTimezoneOffsetMinutes,
canAddTransaction,
getDisplayTime,

View file

@ -78,6 +78,7 @@ import { type NameValue, values } from '@/core/base.ts';
import { CategoryType } from '@/core/category.ts';
import { AUTOMATICALLY_CREATED_CATEGORY_ICON_ID } from '@/consts/icon.ts';
import { DEFAULT_CATEGORY_COLOR } from '@/consts/color.ts';
import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts';
import { type TransactionCategoryCreateRequest, type TransactionCategoryCreateWithSubCategories, TransactionCategory } from '@/models/transaction_category.ts';
import { type TransactionTagCreateRequest, TransactionTag } from '@/models/transaction_tag.ts';
@ -284,12 +285,13 @@ function confirm(): void {
const submitTags: TransactionTagCreateRequest[] = [];
for (const item of selectedNames.value) {
const tag: TransactionTag = TransactionTag.createNewTag(item);
const tag: TransactionTag = TransactionTag.createNewTag(item, DEFAULT_TAG_GROUP_ID);
submitTags.push(tag.toCreateRequest());
}
transactionTagsStore.addTags({
tags: submitTags,
groupId: DEFAULT_TAG_GROUP_ID,
skipExists: true
}).then(response => {
transactionTagsStore.loadAllTags({ force: false }).then(() => {

View file

@ -327,57 +327,14 @@
</v-select>
</v-col>
<v-col cols="12" md="12">
<v-autocomplete
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
:closable-chips="mode !== TransactionEditPageMode.View"
<transaction-tag-auto-complete
:readonly="mode === TransactionEditPageMode.View"
:disabled="loading || submitting"
:label="tt('Tags')"
:placeholder="tt('None')"
:items="allTags"
:show-label="true"
:allow-add-new-tag="true"
v-model="transaction.tagIds"
v-model:search="tagSearchContent"
>
<template #chip="{ props, item }">
<v-chip :prepend-icon="mdiPound" :text="item.title" 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="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
<v-list-item :disabled="true" v-bind="props"
v-if="item.raw.hidden && item.raw.name.toLowerCase().indexOf(tagSearchContent.toLowerCase()) >= 0 && isAllFilteredTagHidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
<template #no-data>
<v-list class="py-0">
<v-list-item v-if="tagSearchContent" @click="saveNewTag(tagSearchContent)">{{ tt('format.misc.addNewTag', { tag: tagSearchContent }) }}</v-list-item>
<v-list-item v-else-if="!tagSearchContent">{{ tt('No available tag') }}</v-list-item>
</v-list>
</template>
</v-autocomplete>
@tag:saving="onSavingTag"
/>
</v-col>
<v-col cols="12" md="12">
<v-textarea
@ -535,7 +492,6 @@ import { TemplateType, ScheduledTemplateFrequencyType } from '@/core/template.ts
import { KnownErrorCode } from '@/consts/api.ts';
import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import { TransactionTemplate } from '@/models/transaction_template.ts';
import type { TransactionPictureInfoBasicResponse } from '@/models/transaction_picture_info.ts';
import { Transaction } from '@/models/transaction.ts';
@ -567,7 +523,6 @@ import {
mdiSwapHorizontal,
mdiMapMarkerOutline,
mdiCheck,
mdiPound,
mdiMenuDown,
mdiImagePlusOutline,
mdiTrashCanOutline,
@ -621,7 +576,6 @@ const {
allVisibleCategorizedAccounts,
allCategories,
allCategoriesMap,
allTags,
allTagsMap,
firstVisibleAccountId,
hasVisibleExpenseCategories,
@ -669,7 +623,6 @@ const activeTab = ref<string>('basicInfo');
const originalTransactionEditable = ref<boolean>(false);
const noTransactionDraft = ref<boolean>(false);
const geoMenuState = ref<boolean>(false);
const tagSearchContent = ref<string>('');
const removingPictureId = ref<string>('');
const initAmount = ref<number | undefined>(undefined);
@ -692,22 +645,7 @@ const sourceAmountColor = computed<string | undefined>(() => {
return undefined;
});
const isAllFilteredTagHidden = computed<boolean>(() => {
const lowerCaseTagSearchContent = tagSearchContent.value.toLowerCase();
let hiddenCount = 0;
for (const tag of allTags.value) {
if (!lowerCaseTagSearchContent || tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent) >= 0) {
if (!tag.hidden) {
return false;
}
hiddenCount++;
}
}
return hiddenCount > 0;
});
const isTransactionModified = computed<boolean>(() => {
if (mode.value === TransactionEditPageMode.Add) {
@ -1141,26 +1079,6 @@ function clearGeoLocation(): void {
transaction.value.removeGeoLocation();
}
function saveNewTag(tagName: string): void {
submitting.value = true;
transactionTagsStore.saveTag({
tag: TransactionTag.createNewTag(tagName)
}).then(tag => {
submitting.value = false;
if (tag && tag.id) {
transaction.value.tagIds.push(tag.id);
}
}).catch(error => {
submitting.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function showOpenPictureDialog(): void {
if (!canAddTransactionPicture.value || submitting.value) {
return;
@ -1231,6 +1149,10 @@ function viewOrRemovePicture(pictureInfo: TransactionPictureInfoBasicResponse):
});
}
function onSavingTag(state: boolean): void {
submitting.value = state;
}
function onShowDateTimeError(error: string): void {
snackbar.value?.showError(error);
}

View file

@ -49,53 +49,46 @@
<f7-list-item :title="tt('No available tag')"></f7-list-item>
</f7-list>
<f7-block class="combination-list-wrapper margin-vertical" key="default" v-show="!loading && hasAnyVisibleTag">
<f7-list class="margin-top-half margin-bottom" strong inset dividers v-if="includeTagsCount > 1">
<f7-list-item radio
:title="tt(filterType.name)"
:key="filterType.type"
:value="filterType.type"
:checked="includeTagFilterType === filterType.type"
v-for="filterType in [TransactionTagFilterType.HasAny, TransactionTagFilterType.HasAll]"
@change="includeTagFilterType = filterType.type">
</f7-list-item>
</f7-list>
<f7-list class="margin-top-half margin-bottom" strong inset dividers v-if="excludeTagsCount > 1">
<f7-list-item radio
:title="tt(filterType.name)"
:key="filterType.type"
:value="filterType.type"
:checked="excludeTagFilterType === filterType.type"
v-for="filterType in [TransactionTagFilterType.NotHasAny, TransactionTagFilterType.NotHasAll]"
@change="excludeTagFilterType = filterType.type">
</f7-list-item>
</f7-list>
<f7-accordion-item :opened="collapseStates['default']!.opened"
@accordion:open="collapseStates['default']!.opened = true"
@accordion:close="collapseStates['default']!.opened = false">
<f7-block class="combination-list-wrapper margin-vertical"
:key="tagGroup.id" v-for="tagGroup in allTagGroupsWithDefault"
v-show="!loading && hasAnyVisibleTag">
<f7-accordion-item :opened="collapseStates[tagGroup.id]?.opened ?? true"
@accordion:open="collapseStates[tagGroup.id]!.opened = true"
@accordion:close="collapseStates[tagGroup.id]!.opened = false"
v-if="allVisibleTags[tagGroup.id] && allVisibleTags[tagGroup.id]!.length > 0">
<f7-block-title>
<f7-accordion-toggle>
<f7-list strong inset dividers
class="combination-list-header"
:class="collapseStates['default']!.opened ? 'combination-list-opened' : 'combination-list-closed'">
:class="collapseStates[tagGroup.id]?.opened ? 'combination-list-opened' : 'combination-list-closed'">
<f7-list-item group-title>
<small>{{ tt('Tags') }}</small>
<f7-icon class="combination-list-chevron-icon" :f7="collapseStates['default']!.opened ? 'chevron_up' : 'chevron_down'"></f7-icon>
<small class="tag-group-title">{{ tagGroup.name }}</small>
<f7-icon class="combination-list-chevron-icon" :f7="collapseStates[tagGroup.id]?.opened ? 'chevron_up' : 'chevron_down'"></f7-icon>
</f7-list-item>
</f7-list>
</f7-accordion-toggle>
</f7-block-title>
<f7-accordion-content :style="{ height: collapseStates['default']!.opened ? 'auto' : '' }">
<f7-accordion-content :style="{ height: collapseStates[tagGroup.id]?.opened ? 'auto' : '' }">
<f7-list strong inset dividers accordion-list class="combination-list-content">
<f7-list-item link="#"
popover-open=".tag-filter-include-type-popover-menu"
:title="tt(TransactionTagFilterType.parse(groupTagFilterTypesMap[tagGroup.id]?.includeType as number)?.name as string)"
@click="currentTransactionTagGroupId = tagGroup.id"
v-if="groupTagFilterTypesMap[tagGroup.id] && groupTagFilterStateCountMap[tagGroup.id] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Include] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Include] > 1 && TransactionTagFilterType.parse(groupTagFilterTypesMap[tagGroup.id]?.includeType as number)">
</f7-list-item>
<f7-list-item link="#"
popover-open=".tag-filter-exclude-type-popover-menu"
:title="tt(TransactionTagFilterType.parse(groupTagFilterTypesMap[tagGroup.id]?.excludeType as number)?.name as string)"
@click="currentTransactionTagGroupId = tagGroup.id"
v-if="groupTagFilterTypesMap[tagGroup.id] && groupTagFilterStateCountMap[tagGroup.id] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Exclude] && groupTagFilterStateCountMap[tagGroup.id]![TransactionTagFilterState.Exclude] > 1 && TransactionTagFilterType.parse(groupTagFilterTypesMap[tagGroup.id]?.excludeType as number)">
</f7-list-item>
<f7-list-item link="#"
popover-open=".tag-filter-state-popover-menu"
:title="transactionTag.name"
:value="transactionTag.id"
:key="transactionTag.id"
:after="tt(filterTagIds[transactionTag.id] === TransactionTagFilterState.Include ? 'Included' : filterTagIds[transactionTag.id] === TransactionTagFilterState.Exclude ? 'Excluded' : 'Default')"
v-for="transactionTag in allVisibleTags"
:after="tt(tagFilterStateMap[transactionTag.id] === TransactionTagFilterState.Include ? 'Included' : tagFilterStateMap[transactionTag.id] === TransactionTagFilterState.Exclude ? 'Excluded' : 'Default')"
v-for="transactionTag in allVisibleTags[tagGroup.id]"
v-show="showHidden || !transactionTag.hidden"
@click="currentTransactionTagId = transactionTag.id">
<template #media>
@ -111,11 +104,41 @@
</f7-accordion-item>
</f7-block>
<f7-popover class="tag-filter-include-type-popover-menu">
<f7-list dividers>
<f7-list-item link="#" no-chevron popover-close
:title="tt(filterType.name)"
:class="{ 'list-item-selected': groupTagFilterTypesMap[currentTransactionTagGroupId]?.includeType === filterType.type }"
:key="filterType.type"
v-for="filterType in [TransactionTagFilterType.HasAny, TransactionTagFilterType.HasAll]"
@click="updateTransactionTagGroupIncludeType(filterType)">
<template #after>
<f7-icon class="list-item-checked-icon" f7="checkmark_alt" v-if="groupTagFilterTypesMap[currentTransactionTagGroupId]?.includeType === filterType.type"></f7-icon>
</template>
</f7-list-item>
</f7-list>
</f7-popover>
<f7-popover class="tag-filter-exclude-type-popover-menu">
<f7-list dividers>
<f7-list-item link="#" no-chevron popover-close
:title="tt(filterType.name)"
:class="{ 'list-item-selected': groupTagFilterTypesMap[currentTransactionTagGroupId]?.excludeType === filterType.type }"
:key="filterType.type"
v-for="filterType in [TransactionTagFilterType.NotHasAny, TransactionTagFilterType.NotHasAll]"
@click="updateTransactionTagGroupExcludeType(filterType)">
<template #after>
<f7-icon class="list-item-checked-icon" f7="checkmark_alt" v-if="groupTagFilterTypesMap[currentTransactionTagGroupId]?.excludeType === filterType.type"></f7-icon>
</template>
</f7-list-item>
</f7-list>
</f7-popover>
<f7-popover class="tag-filter-state-popover-menu">
<f7-list dividers>
<f7-list-item link="#" no-chevron popover-close
:title="state.displayName"
:class="{ 'list-item-selected': filterTagIds[currentTransactionTagId] === state.type }"
:class="{ 'list-item-selected': tagFilterStateMap[currentTransactionTagId] === state.type }"
:key="state.type"
v-for="state in [
{ type: TransactionTagFilterState.Include, displayName: tt('Included') },
@ -124,7 +147,7 @@
]"
@click="updateCurrentTransactionTagState(state.type)">
<template #after>
<f7-icon class="list-item-checked-icon" f7="checkmark_alt" v-if="filterTagIds[currentTransactionTagId] === state.type"></f7-icon>
<f7-icon class="list-item-checked-icon" f7="checkmark_alt" v-if="tagFilterStateMap[currentTransactionTagId] === state.type"></f7-icon>
</template>
</f7-list-item>
</f7-list>
@ -160,6 +183,7 @@ import {
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { values } from '@/core/base.ts';
import { TransactionTagFilterType } from '@/core/transaction.ts';
interface CollapseState {
@ -180,13 +204,13 @@ const {
loading,
showHidden,
filterContent,
filterTagIds,
includeTagFilterType,
excludeTagFilterType,
includeTagsCount,
excludeTagsCount,
tagFilterStateMap,
groupTagFilterTypesMap,
title,
groupTagFilterStateCountMap,
allTagGroupsWithDefault,
allVisibleTags,
allVisibleTagGroupIds,
hasAnyAvailableTag,
hasAnyVisibleTag,
loadFilterTagIds,
@ -196,20 +220,30 @@ const {
const transactionTagsStore = useTransactionTagsStore();
const loadingError = ref<unknown | null>(null);
const currentTransactionTagGroupId = ref<string>('');
const currentTransactionTagId = ref<string>('');
const showMoreActionSheet = ref<boolean>(false);
const collapseStates = ref<Record<string, CollapseState>>({
default: {
opened: true
const collapseStates = ref<Record<string, CollapseState>>(getInitCollapseState(allVisibleTagGroupIds.value));
function getInitCollapseState(tagGroupIds: string[]): Record<string, CollapseState> {
const states: Record<string, CollapseState> = {};
for (const tagGroupId of tagGroupIds) {
states[tagGroupId] = {
opened: true
};
}
});
return states;
}
function init(): void {
transactionTagsStore.loadAllTags({
force: false
}).then(() => {
loading.value = false;
collapseStates.value = getInitCollapseState(allVisibleTagGroupIds.value);
if (!loadFilterTagIds()) {
showToast('Parameter Invalid');
@ -225,14 +259,36 @@ function init(): void {
});
}
function updateTransactionTagGroupIncludeType(filterType: TransactionTagFilterType): void {
const tagFilterTypes = groupTagFilterTypesMap.value[currentTransactionTagGroupId.value];
if (!tagFilterTypes) {
return;
}
tagFilterTypes.includeType = filterType.type;
}
function updateTransactionTagGroupExcludeType(filterType: TransactionTagFilterType): void {
const tagFilterTypes = groupTagFilterTypesMap.value[currentTransactionTagGroupId.value];
if (!tagFilterTypes) {
return;
}
tagFilterTypes.excludeType = filterType.type;
}
function updateCurrentTransactionTagState(state: number): void {
filterTagIds.value[currentTransactionTagId.value] = state;
tagFilterStateMap.value[currentTransactionTagId.value] = state;
currentTransactionTagId.value = '';
}
function setAllTagsState(value: TransactionTagFilterState): void {
for (const tag of allVisibleTags.value) {
filterTagIds.value[tag.id] = value;
for (const tags of values(allVisibleTags.value)) {
for (const tag of tags) {
tagFilterStateMap.value[tag.id] = value;
}
}
}
@ -251,3 +307,10 @@ function onPageAfterIn(): void {
init();
</script>
<style>
.tag-group-title {
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -5,7 +5,12 @@
<f7-nav-left v-else-if="sortable">
<f7-link icon-f7="xmark" :class="{ 'disabled': displayOrderSaving }" @click="cancelSort"></f7-link>
</f7-nav-left>
<f7-nav-title :title="tt('Transaction Tags')"></f7-nav-title>
<f7-nav-title>
<f7-link popover-open=".tag-group-popover-menu" :class="{ 'disabled': sortable || hasEditingTag }">
<span style="color: var(--f7-text-color)">{{ displayTagGroupName }}</span>
<f7-icon class="page-title-bar-icon" color="gray" style="opacity: 0.5" f7="chevron_down_circle_fill"></f7-icon>
</f7-link>
</f7-nav-title>
<f7-nav-right class="navbar-compact-icons">
<f7-link icon-f7="ellipsis" :class="{ 'disabled': hasEditingTag || !tags.length || sortable }" @click="showMoreActionSheet = true"></f7-link>
<f7-link icon-f7="plus" :class="{ 'disabled': hasEditingTag }" v-if="!sortable" @click="add"></f7-link>
@ -13,6 +18,22 @@
</f7-nav-right>
</f7-navbar>
<f7-popover class="tag-group-popover-menu"
@popover:open="scrollPopoverToSelectedItem">
<f7-list dividers>
<f7-list-item link="#" no-chevron popover-close
:title="tagGroup.name"
:class="{ 'list-item-selected': activeTagGroupId === tagGroup.id }"
:key="tagGroup.id"
v-for="tagGroup in allTagGroupsWithDefault"
@click="switchTagGroup(tagGroup.id)">
<template #after>
<f7-icon class="list-item-checked-icon" f7="checkmark_alt" v-if="activeTagGroupId === tagGroup.id"></f7-icon>
</template>
</f7-list-item>
</f7-list>
</f7-popover>
<f7-list strong inset dividers class="tag-item-list margin-top skeleton-text" v-if="loading">
<f7-list-item :key="itemIdx" v-for="itemIdx in [ 1, 2, 3 ]">
<template #media>
@ -155,18 +176,17 @@ import { ref, computed } from 'vue';
import type { Router } from 'framework7/types';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents, showLoading, hideLoading, onSwipeoutDeleted } from '@/lib/ui/mobile.ts';
import { type Framework7Dom, useI18nUIComponents, showLoading, hideLoading, onSwipeoutDeleted } from '@/lib/ui/mobile.ts';
import { useTagListPageBase } from '@/views/base/tags/TagListPageBase.ts';
import { useTransactionTagsStore } from '@/stores/transactionTag.ts';
import { TextDirection } from '@/core/text.ts';
import { TransactionTag } from '@/models/transaction_tag.ts';
import {
isNoAvailableTag,
getFirstShowingId,
getLastShowingId
} from '@/lib/tag.ts';
import { scrollToSelectedItem } from '@/lib/ui/common.ts';
import { getFirstShowingId, getLastShowingId } from '@/lib/tag.ts';
const props = defineProps<{
f7router: Router.Router;
@ -175,34 +195,40 @@ const props = defineProps<{
const { tt, getCurrentLanguageTextDirection } = useI18n();
const { showAlert, showToast, routeBackOnError } = useI18nUIComponents();
const {
activeTagGroupId,
newTag,
editingTag,
loading,
showHidden,
displayOrderModified,
allTagGroupsWithDefault,
tags,
noAvailableTag,
hasEditingTag,
isTagModified,
switchTagGroup,
add,
edit
} = useTagListPageBase();
const transactionTagsStore = useTransactionTagsStore();
const newTag = ref<TransactionTag | null>(null);
const editingTag = ref<TransactionTag>(TransactionTag.createNewTag());
const loading = ref<boolean>(true);
const loadingError = ref<unknown | null>(null);
const showHidden = ref<boolean>(false);
const sortable = ref<boolean>(false);
const tagToDelete = ref<TransactionTag | null>(null);
const showMoreActionSheet = ref<boolean>(false);
const showDeleteActionSheet = ref<boolean>(false);
const displayOrderModified = ref<boolean>(false);
const displayOrderSaving = ref<boolean>(false);
const textDirection = computed<TextDirection>(() => getCurrentLanguageTextDirection());
const tags = computed<TransactionTag[]>(() => transactionTagsStore.allTransactionTags);
const firstShowingId = computed<string | null>(() => getFirstShowingId(tags.value, showHidden.value));
const lastShowingId = computed<string | null>(() => getLastShowingId(tags.value, showHidden.value));
const noAvailableTag = computed<boolean>(() => isNoAvailableTag(tags.value, showHidden.value));
const hasEditingTag = computed<boolean>(() => !!(newTag.value || (editingTag.value.id && editingTag.value.id !== '')));
function isTagModified(tag: TransactionTag): boolean {
if (tag.id) {
return editingTag.value.name !== '' && editingTag.value.name !== tag.name;
} else {
return tag.name !== '';
}
}
const displayTagGroupName = computed<string>(() => {
const tagGroup = transactionTagsStore.allTransactionTagGroupsMap[activeTagGroupId.value];
return tagGroup ? tagGroup.name : tt('Default Group');
});
function getTagDomId(tag: TransactionTag): string {
return 'tag_' + tag.id;
@ -258,15 +284,6 @@ function reload(done?: () => void): void {
});
}
function add(): void {
newTag.value = TransactionTag.createNewTag();
}
function edit(tag: TransactionTag): void {
editingTag.value.id = tag.id;
editingTag.value.name = tag.name;
}
function save(tag: TransactionTag): void {
showLoading();
@ -368,7 +385,7 @@ function saveSortResult(): void {
displayOrderSaving.value = true;
showLoading();
transactionTagsStore.updateTagDisplayOrders().then(() => {
transactionTagsStore.updateTagDisplayOrders(activeTagGroupId.value).then(() => {
displayOrderSaving.value = false;
hideLoading();
@ -438,6 +455,10 @@ function onSort(event: { el: { id: string }, from: number, to: number }): void {
});
}
function scrollPopoverToSelectedItem(event: { $el: Framework7Dom }): void {
scrollToSelectedItem(event.$el[0], '.popover-inner', '.popover-inner', 'li.list-item-selected');
}
function onPageAfterIn(): void {
if (transactionTagsStore.transactionTagListStateInvalid && !loading.value) {
reload();
@ -458,4 +479,9 @@ init();
overflow: hidden;
text-overflow: ellipsis;
}
.tag-group-popover-menu .popover-inner {
max-height: 440px;
overflow-y: auto;
}
</style>

View file

@ -367,7 +367,7 @@
<template #footer>
<f7-block class="margin-top-half no-padding no-margin" v-if="transaction.tagIds && transaction.tagIds.length">
<f7-chip media-text-color="var(--f7-chip-text-color)" class="transaction-edit-tag"
:text="getTagName(tagId)"
:text="allTagsMap[tagId]?.name ?? ''"
:key="tagId"
v-for="tagId in transaction.tagIds">
<template #media>
@ -570,7 +570,6 @@ const {
allVisibleCategorizedAccounts,
allCategories,
allCategoriesMap,
allTags,
allTagsMap,
firstVisibleAccountId,
hasVisibleExpenseCategories,
@ -828,16 +827,6 @@ function getFontClassByAmount(amount: number): string {
}
}
function getTagName(tagId: string): string {
for (const tag of allTags.value) {
if (tag.id === tagId) {
return tag.name;
}
}
return '';
}
function init(): void {
if (!pageTypeAndMode) {
showToast('Parameter Invalid');

View file

@ -558,24 +558,31 @@
</template>
</f7-list-item>
<f7-list-item link="#" no-chevron popover-close
:title="transactionTag.name"
:class="{ 'list-item-selected': queryAllFilterTagIdsCount === 1 && isDefined(queryAllFilterTagIds[transactionTag.id]), 'item-in-multiple-selection': queryAllFilterTagIdsCount > 1 && isDefined(queryAllFilterTagIds[transactionTag.id]) }"
:key="transactionTag.id"
v-for="transactionTag in allTransactionTags"
v-show="!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id])"
@click="changeTagFilter(TransactionTagFilter.of(transactionTag.id).toTextualTagFilter())"
>
<template #before-title>
<f7-icon class="transaction-tag-name transaction-tag-icon" f7="number"></f7-icon>
</template>
<template #after>
<f7-icon class="list-item-checked-icon"
:f7="queryAllFilterTagIds[transactionTag.id] === true ? 'checkmark_alt' : (queryAllFilterTagIds[transactionTag.id] === false ? 'multiply' : undefined)"
v-if="isDefined(queryAllFilterTagIds[transactionTag.id])">
</f7-icon>
</template>
</f7-list-item>
<template :key="transactionTagGroup.id"
v-for="transactionTagGroup in allTransactionTagGroupsWithDefault">
<f7-list-item group-title class="transaction-tag-group" v-if="allTransactionTagsByGroup[transactionTagGroup.id] && allTransactionTagsByGroup[transactionTagGroup.id]?.length && hasVisibleTagsInTagGroup(transactionTagGroup)">
<small>{{ transactionTagGroup.name }}</small>
</f7-list-item>
<f7-list-item link="#" no-chevron popover-close
:title="transactionTag.name"
:class="{ 'list-item-selected': queryAllFilterTagIdsCount === 1 && isDefined(queryAllFilterTagIds[transactionTag.id]), 'item-in-multiple-selection': queryAllFilterTagIdsCount > 1 && isDefined(queryAllFilterTagIds[transactionTag.id]) }"
:key="transactionTag.id"
v-for="transactionTag in (allTransactionTagsByGroup[transactionTagGroup.id] ?? [])"
v-show="!transactionTag.hidden || isDefined(queryAllFilterTagIds[transactionTag.id])"
@click="changeTagFilter(TransactionTagFilter.of(transactionTag.id).toTextualTagFilter())"
>
<template #before-title>
<f7-icon class="transaction-tag-name transaction-tag-icon" f7="number"></f7-icon>
</template>
<template #after>
<f7-icon class="list-item-checked-icon"
:f7="queryAllFilterTagIds[transactionTag.id] === true ? 'checkmark_alt' : (queryAllFilterTagIds[transactionTag.id] === false ? 'multiply' : undefined)"
v-if="isDefined(queryAllFilterTagIds[transactionTag.id])">
</f7-icon>
</template>
</f7-list-item>
</template>
</f7-list>
</f7-popover>
@ -686,6 +693,8 @@ const {
allCategories,
allPrimaryCategories,
allAvailableCategoriesCount,
allTransactionTagGroupsWithDefault,
allTransactionTagsByGroup,
allTransactionTags,
allAvailableTagsCount,
displayPageTypeName,
@ -708,6 +717,7 @@ const {
transactionCalendarMaxDate,
currentMonthTransactionData,
hasSubCategoryInQuery,
hasVisibleTagsInTagGroup,
isSameAsDefaultTimezoneOffsetMinutes,
canAddTransaction,
getDisplayTime,
@ -1608,6 +1618,15 @@ html[dir="rtl"] .list.transaction-info-list li.transaction-info .transaction-foo
overflow-y: auto;
}
.more-popover-menu .transaction-tag-group {
background-color: inherit;
> small {
overflow: hidden;
text-overflow: ellipsis;
}
}
.transaction-calendar-container .dp__theme_light,
.transaction-calendar-container .dp__theme_dark {
--dp-background-color: var(--f7-list-strong-bg-color);