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
@@ -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
+84
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
};
}
@@ -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,
@@ -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,