migrate category filter page to composition API and typescript

This commit is contained in:
MaysWind
2025-02-03 14:58:34 +08:00
parent 6971eccb22
commit aacde2dfde
4 changed files with 486 additions and 522 deletions
+4 -4
View File
@@ -72,7 +72,7 @@ export function getTransactionSecondaryCategoryName(categoryId: string | null |
return '';
}
export function allTransactionCategoriesWithVisibleCount(allTransactionCategories: Record<number, TransactionCategory[]>, allowCategoryTypes: Record<number, boolean>): Record<number, TransactionCategoriesWithVisibleCount> {
export function allTransactionCategoriesWithVisibleCount(allTransactionCategories: Record<number, TransactionCategory[]>, allowCategoryTypes?: Record<number, boolean>): Record<number, TransactionCategoriesWithVisibleCount> {
const ret: Record<string, TransactionCategoriesWithVisibleCount> = {};
const hasAllowCategoryTypes = allowCategoryTypes
&& (allowCategoryTypes[CategoryType.Income]
@@ -339,7 +339,7 @@ export function getLastShowingId(categories: TransactionCategory[], showHidden:
return null;
}
export function hasAnyAvailableCategory(allTransactionCategories: Record<number, TransactionCategoriesWithVisibleCount>, showHidden: boolean): boolean {
export function containsAnyAvailableCategory(allTransactionCategories: Record<number, TransactionCategoriesWithVisibleCount>, showHidden: boolean): boolean {
for (const type in allTransactionCategories) {
if (!Object.prototype.hasOwnProperty.call(allTransactionCategories, type)) {
continue;
@@ -361,7 +361,7 @@ export function hasAnyAvailableCategory(allTransactionCategories: Record<number,
return false;
}
export function hasAvailableCategory(allTransactionCategories: Record<number, TransactionCategoriesWithVisibleCount>, showHidden: boolean): Record<number, boolean> {
export function containsAvailableCategory(allTransactionCategories: Record<number, TransactionCategoriesWithVisibleCount>, showHidden: boolean): Record<number, boolean> {
const result: Record<number, boolean> = {};
for (const type in allTransactionCategories) {
@@ -381,7 +381,7 @@ export function hasAvailableCategory(allTransactionCategories: Record<number, Tr
return result;
}
export function selectSubCategories(filterCategoryIds: Record<string, boolean>, category: TransactionCategory, value: boolean): void {
export function selectAllSubCategories(filterCategoryIds: Record<string, boolean>, category: TransactionCategory, value: boolean): void {
if (!category || !category.secondaryCategories || !category.secondaryCategories.length) {
return;
}
@@ -0,0 +1,188 @@
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import { CategoryType } from '@/core/category.ts';
import type { TransactionCategory, TransactionCategoriesWithVisibleCount } from '@/models/transaction_category.ts';
import {
copyObjectTo,
arrayItemToObjectField
} from '@/lib/common.ts';
import {
allTransactionCategoriesWithVisibleCount,
containsAnyAvailableCategory,
containsAvailableCategory,
selectAllSubCategories,
isCategoryOrSubCategoriesAllChecked
} from '@/lib/category.ts';
export function useCategoryFilterSettingPageBase(type?: string, allowCategoryTypesStr?: string) {
const { tt } = useI18n();
const settingsStore = useSettingsStore();
const transactionCategoriesStore = useTransactionCategoriesStore();
const transactionsStore = useTransactionsStore();
const statisticsStore = useStatisticsStore();
const allowCategoryTypes: Record<string, boolean> | undefined = allowCategoryTypesStr ? arrayItemToObjectField(allowCategoryTypesStr.split(','), true) : undefined;
const loading = ref<boolean>(true);
const showHidden = ref<boolean>(false);
const filterCategoryIds = ref<Record<string, boolean>>({});
const title = computed<string>(() => {
if (type === 'statisticsDefault') {
return 'Default Transaction Category Filter';
} else {
return 'Filter Transaction Categories';
}
});
const applyText = computed<string>(() => {
if (type === 'statisticsDefault') {
return 'Save';
} else {
return 'Apply';
}
});
const allTransactionCategories = computed<Record<number, TransactionCategoriesWithVisibleCount>>(() => allTransactionCategoriesWithVisibleCount(transactionCategoriesStore.allTransactionCategories, allowCategoryTypes));
const hasAnyAvailableCategory = computed<boolean>(() => containsAnyAvailableCategory(allTransactionCategories.value, true));
const hasAnyVisibleCategory = computed<boolean>(() => containsAnyAvailableCategory(allTransactionCategories.value, showHidden.value));
const hasAvailableCategory = computed<Record<number, boolean>>(() => containsAvailableCategory(allTransactionCategories.value, showHidden.value));
function isCategoryChecked(category: TransactionCategory, filterCategoryIds: Record<string, boolean>): boolean {
return !filterCategoryIds[category.id];
}
function getCategoryTypeName(categoryType: CategoryType): string {
switch (categoryType) {
case CategoryType.Income:
return tt('Income Categories');
case CategoryType.Expense:
return tt('Expense Categories');
case CategoryType.Transfer:
return tt('Transfer Categories');
default:
return tt('Transaction Categories');
}
}
function loadFilterCategoryIds(): boolean {
const allCategoryIds: Record<string, boolean> = {};
for (const categoryId in transactionCategoriesStore.allTransactionCategoriesMap) {
if (!Object.prototype.hasOwnProperty.call(transactionCategoriesStore.allTransactionCategoriesMap, categoryId)) {
continue;
}
const category = transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (allowCategoryTypes && !allowCategoryTypes[category.type]) {
continue;
}
if (type === 'transactionListCurrent' && transactionsStore.allFilterCategoryIdsCount > 0) {
allCategoryIds[category.id] = true;
} else {
allCategoryIds[category.id] = false;
}
}
if (type === 'statisticsDefault') {
filterCategoryIds.value = copyObjectTo(settingsStore.appSettings.statistics.defaultTransactionCategoryFilter, allCategoryIds) as Record<string, boolean>;
return true;
} else if (type === 'statisticsCurrent') {
filterCategoryIds.value = copyObjectTo(statisticsStore.transactionStatisticsFilter.filterCategoryIds, allCategoryIds) as Record<string, boolean>;
return true;
} else if (type === 'transactionListCurrent') {
for (const categoryId in transactionsStore.allFilterCategoryIds) {
if (!Object.prototype.hasOwnProperty.call(transactionsStore.allFilterCategoryIds, categoryId)) {
continue;
}
const category = transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (category && (!category.subCategories || !category.subCategories.length)) {
allCategoryIds[category.id] = false;
} else if (category) {
selectAllSubCategories(allCategoryIds, category, false);
}
}
filterCategoryIds.value = allCategoryIds;
return true;
} else {
return false;
}
}
function saveFilterCategoryIds(): boolean {
const filteredCategoryIds: Record<string, boolean> = {};
let isAllSelected = true;
let finalCategoryIds = '';
let changed = true;
for (const categoryId in filterCategoryIds.value) {
if (!Object.prototype.hasOwnProperty.call(filterCategoryIds.value, categoryId)) {
continue;
}
const category = transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (!isCategoryOrSubCategoriesAllChecked(category, filterCategoryIds.value)) {
filteredCategoryIds[categoryId] = true;
isAllSelected = false;
} else {
if (finalCategoryIds.length > 0) {
finalCategoryIds += ',';
}
finalCategoryIds += categoryId;
}
}
if (type === 'statisticsDefault') {
settingsStore.setStatisticsDefaultTransactionCategoryFilter(filteredCategoryIds);
} else if (type === 'statisticsCurrent') {
changed = statisticsStore.updateTransactionStatisticsFilter({
filterCategoryIds: filteredCategoryIds
});
} else if (type === 'transactionListCurrent') {
changed = transactionsStore.updateTransactionListFilter({
categoryIds: isAllSelected ? '' : finalCategoryIds
});
if (changed) {
transactionsStore.updateTransactionListInvalidState(true);
}
}
return changed;
}
return {
// states
loading,
showHidden,
filterCategoryIds,
// computed states
title,
applyText,
allTransactionCategories,
hasAnyAvailableCategory,
hasAnyVisibleCategory,
hasAvailableCategory,
// functions
isCategoryChecked,
getCategoryTypeName,
loadFilterCategoryIds,
saveFilterCategoryIds
};
}
@@ -3,7 +3,7 @@
<template #title>
<div class="d-flex align-center justify-center" v-if="dialogMode">
<div class="w-100 text-center">
<h4 class="text-h4">{{ $t(title) }}</h4>
<h4 class="text-h4">{{ tt(title) }}</h4>
</div>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:disabled="loading || !hasAnyAvailableCategory" :icon="true">
@@ -11,30 +11,30 @@
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All')"
:title="tt('Select All')"
:disabled="!hasAnyVisibleCategory"
@click="selectAll"></v-list-item>
@click="selectAllCategories"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None')"
:title="tt('Select None')"
:disabled="!hasAnyVisibleCategory"
@click="selectNone"></v-list-item>
@click="selectNoneCategories"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection')"
:title="tt('Invert Selection')"
:disabled="!hasAnyVisibleCategory"
@click="selectInvert"></v-list-item>
@click="selectInvertCategories"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="icons.show"
:title="$t('Show Hidden Transaction Categories')"
:title="tt('Show Hidden Transaction Categories')"
v-if="!showHidden" @click="showHidden = true"></v-list-item>
<v-list-item :prepend-icon="icons.hide"
:title="$t('Hide Hidden Transaction Categories')"
:title="tt('Hide Hidden Transaction Categories')"
v-if="showHidden" @click="showHidden = false"></v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
<div class="d-flex align-center" v-else-if="!dialogMode">
<span>{{ $t(title) }}</span>
<span>{{ tt(title) }}</span>
<v-spacer/>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:disabled="loading" :icon="true">
@@ -42,23 +42,23 @@
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All')"
:title="tt('Select All')"
:disabled="!hasAnyVisibleCategory"
@click="selectAll"></v-list-item>
@click="selectAllCategories"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None')"
:title="tt('Select None')"
:disabled="!hasAnyVisibleCategory"
@click="selectNone"></v-list-item>
@click="selectNoneCategories"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection')"
:title="tt('Invert Selection')"
:disabled="!hasAnyVisibleCategory"
@click="selectInvert"></v-list-item>
@click="selectInvertCategories"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="icons.show"
:title="$t('Show Hidden Transaction Categories')"
:title="tt('Show Hidden Transaction Categories')"
v-if="!showHidden" @click="showHidden = true"></v-list-item>
<v-list-item :prepend-icon="icons.hide"
:title="$t('Hide Hidden Transaction Categories')"
:title="tt('Hide Hidden Transaction Categories')"
v-if="showHidden" @click="showHidden = false"></v-list-item>
</v-list>
</v-menu>
@@ -82,7 +82,7 @@
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list rounded density="comfortable" class="pa-0">
<div class="py-3" v-if="!hasAvailableCategory[transactionType.type]">{{ $t('No available category') }}</div>
<div class="py-3" v-if="!hasAvailableCategory[transactionType.type]">{{ tt('No available category') }}</div>
<template :key="category.id"
v-for="(category, idx) in transactionType.allCategories">
@@ -92,7 +92,7 @@
<template #prepend>
<v-checkbox :model-value="isSubCategoriesAllChecked(category, filterCategoryIds)"
:indeterminate="isSubCategoriesHasButNotAllChecked(category, filterCategoryIds)"
@update:model-value="selectSubCategories(category, $event)">
@update:model-value="updateAllSubCategoriesSelected(category, $event)">
<template #label>
<ItemIcon class="d-flex" icon-type="category" :icon-id="category.icon"
:color="category.color" :hidden-status="category.hidden"></ItemIcon>
@@ -113,7 +113,7 @@
<v-list-item v-if="showHidden || !subCategory.hidden">
<template #prepend>
<v-checkbox :model-value="isCategoryChecked(subCategory, filterCategoryIds)"
@update:model-value="selectCategory(subCategory, $event)">
@update:model-value="updateCategorySelected(subCategory, $event)">
<template #label>
<ItemIcon class="d-flex" icon-type="category" :icon-id="subCategory.icon"
:color="subCategory.color" :hidden-status="subCategory.hidden"></ItemIcon>
@@ -133,8 +133,8 @@
<v-card-text class="overflow-y-visible" v-if="dialogMode">
<div class="w-100 d-flex justify-center mt-2 mt-sm-4 mt-md-6 gap-4">
<v-btn :disabled="!hasAnyVisibleCategory" @click="save">{{ $t(applyText) }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="!hasAnyVisibleCategory" @click="save">{{ tt(applyText) }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
@@ -142,24 +142,24 @@
<snack-bar ref="snackbar" />
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.ts';
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useCategoryFilterSettingPageBase } from '@/views/base/settings/CategoryFilterSettingPageBase.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import { CategoryType } from '@/core/category.ts';
import { copyObjectTo, arrayItemToObjectField } from '@/lib/common.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
import {
allTransactionCategoriesWithVisibleCount,
hasAnyAvailableCategory,
hasAvailableCategory,
selectSubCategories,
selectAllSubCategories,
selectAll,
selectNone,
selectInvert,
isCategoryOrSubCategoriesAllChecked,
isSubCategoriesAllChecked,
isSubCategoriesHasButNotAllChecked
} from '@/lib/category.ts';
@@ -173,242 +173,128 @@ import {
mdiDotsVertical
} from '@mdi/js';
export default {
props: [
'dialogMode',
'categoryTypes',
'type',
'autoSave'
],
emits: [
'settings:change'
],
data: function () {
return {
loading: true,
expandCategoryTypes: [
CategoryType.Income,
CategoryType.Expense,
CategoryType.Transfer
],
filterCategoryIds: {},
showHidden: false,
icons: {
selectAll: mdiSelectAll,
selectNone: mdiSelect,
selectInverse: mdiSelectInverse,
show: mdiEyeOutline,
hide: mdiEyeOffOutline,
more: mdiDotsVertical
}
type SnackBarType = InstanceType<typeof SnackBar>;
const props = defineProps<{
type: string;
dialogMode?: boolean;
autoSave?: boolean;
categoryTypes?: string;
}>();
const emit = defineEmits<{
(e: 'settings:change', changed: boolean): void;
}>();
const { tt } = useI18n();
const {
loading,
showHidden,
filterCategoryIds,
title,
applyText,
allTransactionCategories,
hasAnyAvailableCategory,
hasAnyVisibleCategory,
hasAvailableCategory,
isCategoryChecked,
getCategoryTypeName,
loadFilterCategoryIds,
saveFilterCategoryIds
} = useCategoryFilterSettingPageBase(props.type, props.categoryTypes);
const transactionCategoriesStore = useTransactionCategoriesStore();
const icons = {
selectAll: mdiSelectAll,
selectNone: mdiSelect,
selectInverse: mdiSelectInverse,
show: mdiEyeOutline,
hide: mdiEyeOffOutline,
more: mdiDotsVertical
};
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const expandCategoryTypes = ref<CategoryType[]>([
CategoryType.Income,
CategoryType.Expense,
CategoryType.Transfer
]);
function init(): void {
transactionCategoriesStore.loadAllCategories({
force: false
}).then(() => {
loading.value = false;
if (!loadFilterCategoryIds()) {
snackbar.value?.showError('Parameter Invalid');
}
},
computed: {
...mapStores(useSettingsStore, useTransactionCategoriesStore, useTransactionsStore, useStatisticsStore),
title() {
if (this.type === 'statisticsDefault') {
return 'Default Transaction Category Filter';
} else {
return 'Filter Transaction Categories';
}
},
applyText() {
if (this.type === 'statisticsDefault') {
return 'Save';
} else {
return 'Apply';
}
},
allowCategoryTypes() {
return this.categoryTypes ? arrayItemToObjectField(this.categoryTypes.split(','), true) : null;
},
allTransactionCategories() {
return allTransactionCategoriesWithVisibleCount(this.transactionCategoriesStore.allTransactionCategories, this.allowCategoryTypes);
},
hasAnyAvailableCategory() {
return hasAnyAvailableCategory(this.allTransactionCategories, true);
},
hasAnyVisibleCategory() {
return hasAnyAvailableCategory(this.allTransactionCategories, this.showHidden);
},
hasAvailableCategory() {
return hasAvailableCategory(this.allTransactionCategories, this.showHidden);
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
},
created() {
const self = this;
});
}
self.transactionCategoriesStore.loadAllCategories({
force: false
}).then(() => {
self.loading = false;
function updateCategorySelected(category: TransactionCategory, value: boolean | null): void {
if (!category) {
return;
}
const allCategoryIds = {};
filterCategoryIds.value[category.id] = !value;
for (const categoryId in self.transactionCategoriesStore.allTransactionCategoriesMap) {
if (!Object.prototype.hasOwnProperty.call(self.transactionCategoriesStore.allTransactionCategoriesMap, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (self.allowCategoryTypes && !self.allowCategoryTypes[category.type]) {
continue;
}
if (self.type === 'transactionListCurrent' && self.transactionsStore.allFilterCategoryIdsCount > 0) {
allCategoryIds[category.id] = true;
} else {
allCategoryIds[category.id] = false;
}
}
if (self.type === 'statisticsDefault') {
self.filterCategoryIds = copyObjectTo(self.settingsStore.appSettings.statistics.defaultTransactionCategoryFilter, allCategoryIds);
} else if (self.type === 'statisticsCurrent') {
self.filterCategoryIds = copyObjectTo(self.statisticsStore.transactionStatisticsFilter.filterCategoryIds, allCategoryIds);
} else if (self.type === 'transactionListCurrent') {
for (const categoryId in self.transactionsStore.allFilterCategoryIds) {
if (!Object.prototype.hasOwnProperty.call(self.transactionsStore.allFilterCategoryIds, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (category && (!category.subCategories || !category.subCategories.length)) {
allCategoryIds[category.id] = false;
} else if (category) {
selectSubCategories(allCategoryIds, category, false);
}
}
self.filterCategoryIds = allCategoryIds;
} else {
self.$refs.snackbar.showError('Parameter Invalid');
}
}).catch(error => {
self.loading = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
methods: {
save() {
const self = this;
const filteredCategoryIds = {};
let isAllSelected = true;
let finalCategoryIds = '';
let changed = true;
for (const categoryId in self.filterCategoryIds) {
if (!Object.prototype.hasOwnProperty.call(self.filterCategoryIds, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (!isCategoryOrSubCategoriesAllChecked(category, self.filterCategoryIds)) {
filteredCategoryIds[categoryId] = true;
isAllSelected = false;
} else {
if (finalCategoryIds.length > 0) {
finalCategoryIds += ',';
}
finalCategoryIds += categoryId;
}
}
if (this.type === 'statisticsDefault') {
self.settingsStore.setStatisticsDefaultTransactionCategoryFilter(filteredCategoryIds);
} else if (this.type === 'statisticsCurrent') {
changed = self.statisticsStore.updateTransactionStatisticsFilter({
filterCategoryIds: filteredCategoryIds
});
if (changed) {
self.statisticsStore.updateTransactionStatisticsInvalidState(true);
}
} else if (this.type === 'transactionListCurrent') {
changed = self.transactionsStore.updateTransactionListFilter({
categoryIds: isAllSelected ? '' : finalCategoryIds
});
if (changed) {
self.transactionsStore.updateTransactionListInvalidState(true);
}
}
self.$emit('settings:change', changed);
},
cancel() {
this.$emit('settings:change', false);
},
selectCategory(category, value) {
if (!category) {
return;
}
this.filterCategoryIds[category.id] = !value;
if (this.autoSave) {
this.save();
}
},
selectSubCategories(category, value) {
selectSubCategories(this.filterCategoryIds, category, !value);
if (this.autoSave) {
this.save();
}
},
selectAll() {
selectAll(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
if (this.autoSave) {
this.save();
}
},
selectNone() {
selectNone(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
if (this.autoSave) {
this.save();
}
},
selectInvert() {
selectInvert(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
if (this.autoSave) {
this.save();
}
},
getCategoryTypeName(categoryType) {
switch (categoryType) {
case CategoryType.Income:
return this.$t('Income Categories');
case CategoryType.Expense:
return this.$t('Expense Categories');
case CategoryType.Transfer:
return this.$t('Transfer Categories');
default:
return this.$t('Transaction Categories');
}
},
isCategoryChecked(category, filterCategoryIds) {
return !filterCategoryIds[category.id];
},
isSubCategoriesAllChecked(category, filterCategoryIds) {
return isSubCategoriesAllChecked(category, filterCategoryIds);
},
isSubCategoriesHasButNotAllChecked(category, filterCategoryIds) {
return isSubCategoriesHasButNotAllChecked(category, filterCategoryIds);
}
if (props.autoSave) {
save();
}
}
function updateAllSubCategoriesSelected(category: TransactionCategory, value: boolean | null): void {
selectAllSubCategories(filterCategoryIds.value, category, !value);
if (props.autoSave) {
save();
}
}
function selectAllCategories(): void {
selectAll(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
if (props.autoSave) {
save();
}
}
function selectNoneCategories(): void {
selectNone(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
if (props.autoSave) {
save();
}
}
function selectInvertCategories(): void {
selectInvert(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
if (props.autoSave) {
save();
}
}
function save(): void {
const changed = saveFilterCategoryIds();
emit('settings:change', changed);
}
function cancel(): void {
emit('settings:change', false);
}
init();
</script>
<style>
@@ -1,11 +1,11 @@
<template>
<f7-page @page:afterin="onPageAfterIn">
<f7-navbar>
<f7-nav-left :back-link="$t('Back')"></f7-nav-left>
<f7-nav-title :title="$t(title)"></f7-nav-title>
<f7-nav-left :back-link="tt('Back')"></f7-nav-left>
<f7-nav-title :title="tt(title)"></f7-nav-title>
<f7-nav-right>
<f7-link icon-f7="ellipsis" :class="{ 'disabled': !hasAnyAvailableCategory }" @click="showMoreActionSheet = true"></f7-link>
<f7-link :text="$t(applyText)" :class="{ 'disabled': !hasAnyVisibleCategory }" @click="save"></f7-link>
<f7-link :text="tt(applyText)" :class="{ 'disabled': !hasAnyVisibleCategory }" @click="save"></f7-link>
</f7-nav-right>
</f7-navbar>
@@ -51,41 +51,41 @@
</div>
<f7-block class="combination-list-wrapper margin-vertical"
:key="transactionType.type"
v-for="transactionType in allTransactionCategories"
:key="categoryType.type"
v-for="categoryType in allTransactionCategories"
v-else-if="!loading">
<f7-accordion-item :opened="collapseStates[transactionType.type].opened"
@accordion:open="collapseStates[transactionType.type].opened = true"
@accordion:close="collapseStates[transactionType.type].opened = false">
<f7-accordion-item :opened="collapseStates[categoryType.type].opened"
@accordion:open="collapseStates[categoryType.type].opened = true"
@accordion:close="collapseStates[categoryType.type].opened = false">
<f7-block-title>
<f7-accordion-toggle>
<f7-list strong inset dividers media-list
class="combination-list-header"
:class="collapseStates[transactionType.type].opened ? 'combination-list-opened' : 'combination-list-closed'">
:class="collapseStates[categoryType.type].opened ? 'combination-list-opened' : 'combination-list-closed'">
<f7-list-item>
<template #title>
<span>{{ getCategoryTypeName(transactionType.type) }}</span>
<f7-icon class="combination-list-chevron-icon" :f7="collapseStates[transactionType.type].opened ? 'chevron_up' : 'chevron_down'"></f7-icon>
<span>{{ getCategoryTypeName(categoryType.type) }}</span>
<f7-icon class="combination-list-chevron-icon" :f7="collapseStates[categoryType.type].opened ? 'chevron_up' : 'chevron_down'"></f7-icon>
</template>
</f7-list-item>
</f7-list>
</f7-accordion-toggle>
</f7-block-title>
<f7-accordion-content :style="{ height: collapseStates[transactionType.type].opened ? 'auto' : '' }">
<f7-list strong inset dividers accordion-list class="combination-list-content" v-if="!hasAvailableCategory[transactionType.type]">
<f7-list-item :title="$t('No available category')"></f7-list-item>
<f7-accordion-content :style="{ height: collapseStates[categoryType.type].opened ? 'auto' : '' }">
<f7-list strong inset dividers accordion-list class="combination-list-content" v-if="!hasAvailableCategory[categoryType.type]">
<f7-list-item :title="tt('No available category')"></f7-list-item>
</f7-list>
<f7-list strong inset dividers accordion-list class="combination-list-content" v-else-if="hasAvailableCategory[transactionType.type]">
<f7-list strong inset dividers accordion-list class="combination-list-content" v-else-if="hasAvailableCategory[categoryType.type]">
<f7-list-item checkbox
:class="{ 'has-child-list-item': (showHidden && transactionType.allSubCategories[category.id]) || transactionType.allVisibleSubCategoryCounts[category.id] }"
:class="{ 'has-child-list-item': (showHidden && categoryType.allSubCategories[category.id]) || categoryType.allVisibleSubCategoryCounts[category.id] }"
:title="category.name"
:value="category.id"
:checked="isSubCategoriesAllChecked(category, filterCategoryIds)"
:indeterminate="isSubCategoriesHasButNotAllChecked(category, filterCategoryIds)"
:key="category.id"
v-for="category in transactionType.allCategories"
v-for="category in categoryType.allCategories"
v-show="showHidden || !category.hidden"
@change="selectSubCategories">
@change="updateAllSubCategoriesSelected">
<template #media>
<ItemIcon icon-type="category" :icon-id="category.icon" :color="category.color">
<f7-badge color="gray" class="right-bottom-icon" v-if="category.hidden">
@@ -96,15 +96,15 @@
<template #root>
<ul class="padding-left"
v-if="(showHidden && transactionType.allSubCategories[category.id]) || transactionType.allVisibleSubCategoryCounts[category.id]">
v-if="(showHidden && categoryType.allSubCategories[category.id]) || categoryType.allVisibleSubCategoryCounts[category.id]">
<f7-list-item checkbox
:title="subCategory.name"
:value="subCategory.id"
:checked="isCategoryChecked(subCategory, filterCategoryIds)"
:key="subCategory.id"
v-for="subCategory in transactionType.allSubCategories[category.id]"
v-for="subCategory in categoryType.allSubCategories[category.id]"
v-show="showHidden || !subCategory.hidden"
@change="selectCategory">
@change="updateCategorySelected">
<template #media>
<ItemIcon icon-type="category" :icon-id="subCategory.icon" :color="subCategory.color">
<f7-badge color="gray" class="right-bottom-icon" v-if="subCategory.hidden">
@@ -123,259 +123,149 @@
<f7-actions close-by-outside-click close-on-escape :opened="showMoreActionSheet" @actions:closed="showMoreActionSheet = false">
<f7-actions-group>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectAll">{{ $t('Select All') }}</f7-actions-button>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectNone">{{ $t('Select None') }}</f7-actions-button>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectInvert">{{ $t('Invert Selection') }}</f7-actions-button>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectAllCategories">{{ tt('Select All') }}</f7-actions-button>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectNoneCategories">{{ tt('Select None') }}</f7-actions-button>
<f7-actions-button :class="{ 'disabled': !hasAnyVisibleCategory }" @click="selectInvertCategories">{{ tt('Invert Selection') }}</f7-actions-button>
</f7-actions-group>
<f7-actions-group>
<f7-actions-button v-if="!showHidden" @click="showHidden = true">{{ $t('Show Hidden Transaction Categories') }}</f7-actions-button>
<f7-actions-button v-if="showHidden" @click="showHidden = false">{{ $t('Hide Hidden Transaction Categories') }}</f7-actions-button>
<f7-actions-button v-if="!showHidden" @click="showHidden = true">{{ tt('Show Hidden Transaction Categories') }}</f7-actions-button>
<f7-actions-button v-if="showHidden" @click="showHidden = false">{{ tt('Hide Hidden Transaction Categories') }}</f7-actions-button>
</f7-actions-group>
<f7-actions-group>
<f7-actions-button bold close>{{ $t('Cancel') }}</f7-actions-button>
<f7-actions-button bold close>{{ tt('Cancel') }}</f7-actions-button>
</f7-actions-group>
</f7-actions>
</f7-page>
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.ts';
<script setup lang="ts">
import { ref } from 'vue';
import type { Router } from 'framework7/types';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents } from '@/lib/ui/mobile.ts';
import { useCategoryFilterSettingPageBase } from '@/views/base/settings/CategoryFilterSettingPageBase.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import { CategoryType } from '@/core/category.ts';
import { copyObjectTo, arrayItemToObjectField } from '@/lib/common.ts';
import {
allTransactionCategoriesWithVisibleCount,
hasAnyAvailableCategory,
hasAvailableCategory,
selectSubCategories,
selectAllSubCategories,
selectAll,
selectNone,
selectInvert,
isCategoryOrSubCategoriesAllChecked,
isSubCategoriesAllChecked,
isSubCategoriesHasButNotAllChecked
} from '@/lib/category.ts';
export default {
props: [
'f7route',
'f7router'
],
data: function () {
return {
loading: true,
loadingError: null,
type: null,
allowCategoryTypes: null,
filterCategoryIds: {},
showHidden: false,
collapseStates: {
[CategoryType.Income]: {
opened: true
},
[CategoryType.Expense]: {
opened: true
},
[CategoryType.Transfer]: {
opened: true
}
},
showMoreActionSheet: false
}
},
computed: {
...mapStores(useSettingsStore, useTransactionCategoriesStore, useTransactionsStore, useStatisticsStore),
title() {
if (this.type === 'statisticsDefault') {
return 'Default Transaction Category Filter';
} else {
return 'Filter Transaction Categories';
}
},
applyText() {
if (this.type === 'statisticsDefault') {
return 'Save';
} else {
return 'Apply';
}
},
allTransactionCategories() {
return allTransactionCategoriesWithVisibleCount(this.transactionCategoriesStore.allTransactionCategories, this.allowCategoryTypes);
},
hasAnyAvailableCategory() {
return hasAnyAvailableCategory(this.allTransactionCategories, true);
},
hasAnyVisibleCategory() {
return hasAnyAvailableCategory(this.allTransactionCategories, this.showHidden);
},
hasAvailableCategory() {
return hasAvailableCategory(this.allTransactionCategories, this.showHidden);
}
},
created() {
const self = this;
const query = self.f7route.query;
self.type = query.type;
self.allowCategoryTypes = query.allowCategoryTypes ? arrayItemToObjectField(query.allowCategoryTypes.split(','), true) : null;
self.transactionCategoriesStore.loadAllCategories({
force: false
}).then(() => {
self.loading = false;
const allCategoryIds = {};
for (const categoryId in self.transactionCategoriesStore.allTransactionCategoriesMap) {
if (!Object.prototype.hasOwnProperty.call(self.transactionCategoriesStore.allTransactionCategoriesMap, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (self.allowCategoryTypes && !self.allowCategoryTypes[category.type]) {
continue;
}
if (self.type === 'transactionListCurrent' && self.transactionsStore.allFilterCategoryIdsCount > 0) {
allCategoryIds[category.id] = true;
} else {
allCategoryIds[category.id] = false;
}
}
if (self.type === 'statisticsDefault') {
self.filterCategoryIds = copyObjectTo(self.settingsStore.appSettings.statistics.defaultTransactionCategoryFilter, allCategoryIds);
} else if (self.type === 'statisticsCurrent') {
self.filterCategoryIds = copyObjectTo(self.statisticsStore.transactionStatisticsFilter.filterCategoryIds, allCategoryIds);
} else if (self.type === 'transactionListCurrent') {
for (const categoryId in self.transactionsStore.allFilterCategoryIds) {
if (!Object.prototype.hasOwnProperty.call(self.transactionsStore.allFilterCategoryIds, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (category && (!category.subCategories || !category.subCategories.length)) {
allCategoryIds[category.id] = false;
} else if (category) {
selectSubCategories(allCategoryIds, category, false);
}
}
self.filterCategoryIds = allCategoryIds;
} else {
self.$toast('Parameter Invalid');
self.loadingError = 'Parameter Invalid';
}
}).catch(error => {
if (error.processed) {
self.loading = false;
} else {
self.loadingError = error;
self.$toast(error.message || error);
}
});
},
methods: {
onPageAfterIn() {
this.$routeBackOnError(this.f7router, 'loadingError');
},
save() {
const self = this;
const router = self.f7router;
const filteredCategoryIds = {};
let isAllSelected = true;
let finalCategoryIds = '';
for (const categoryId in self.filterCategoryIds) {
if (!Object.prototype.hasOwnProperty.call(self.filterCategoryIds, categoryId)) {
continue;
}
const category = self.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (!isCategoryOrSubCategoriesAllChecked(category, self.filterCategoryIds)) {
filteredCategoryIds[categoryId] = true;
isAllSelected = false;
} else {
if (finalCategoryIds.length > 0) {
finalCategoryIds += ',';
}
finalCategoryIds += categoryId;
}
}
if (this.type === 'statisticsDefault') {
self.settingsStore.setStatisticsDefaultTransactionCategoryFilter(filteredCategoryIds);
} else if (this.type === 'statisticsCurrent') {
self.statisticsStore.updateTransactionStatisticsFilter({
filterCategoryIds: filteredCategoryIds
});
} else if (this.type === 'transactionListCurrent') {
const changed = self.transactionsStore.updateTransactionListFilter({
categoryIds: isAllSelected ? '' : finalCategoryIds
});
if (changed) {
self.transactionsStore.updateTransactionListInvalidState(true);
}
}
router.back();
},
selectCategory(e) {
const categoryId = e.target.value;
const category = this.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (!category) {
return;
}
this.filterCategoryIds[category.id] = !e.target.checked;
},
selectSubCategories(e) {
const categoryId = e.target.value;
const category = this.transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
selectSubCategories(this.filterCategoryIds, category, !e.target.checked);
},
selectAll() {
selectAll(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
},
selectNone() {
selectNone(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
},
selectInvert() {
selectInvert(this.filterCategoryIds, this.transactionCategoriesStore.allTransactionCategoriesMap);
},
getCategoryTypeName(categoryType) {
switch (categoryType) {
case CategoryType.Income:
return this.$t('Income Categories');
case CategoryType.Expense:
return this.$t('Expense Categories');
case CategoryType.Transfer:
return this.$t('Transfer Categories');
default:
return this.$t('Transaction Categories');
}
},
isCategoryChecked(category, filterCategoryIds) {
return !filterCategoryIds[category.id];
},
isSubCategoriesAllChecked(category, filterCategoryIds) {
return isSubCategoriesAllChecked(category, filterCategoryIds);
},
isSubCategoriesHasButNotAllChecked(category, filterCategoryIds) {
return isSubCategoriesHasButNotAllChecked(category, filterCategoryIds);
}
}
interface CollapseState {
opened: boolean;
}
const props = defineProps<{
f7route: Router.Route;
f7router: Router.Router;
}>();
const query = props.f7route.query;
const { tt } = useI18n();
const { showToast, routeBackOnError } = useI18nUIComponents();
const {
loading,
showHidden,
filterCategoryIds,
title,
applyText,
allTransactionCategories,
hasAnyAvailableCategory,
hasAnyVisibleCategory,
hasAvailableCategory,
isCategoryChecked,
getCategoryTypeName,
loadFilterCategoryIds,
saveFilterCategoryIds
} = useCategoryFilterSettingPageBase(query['type'], query['allowCategoryTypes']);
const transactionCategoriesStore = useTransactionCategoriesStore();
const loadingError = ref<unknown | null>(null);
const showMoreActionSheet = ref<boolean>(false);
const collapseStates = ref<Record<number, CollapseState>>({
[CategoryType.Income]: {
opened: true
},
[CategoryType.Expense]: {
opened: true
},
[CategoryType.Transfer]: {
opened: true
}
});
function init(): void {
transactionCategoriesStore.loadAllCategories({
force: false
}).then(() => {
loading.value = false;
if (!loadFilterCategoryIds()) {
showToast('Parameter Invalid');
loadingError.value = 'Parameter Invalid';
}
}).catch(error => {
if (error.processed) {
loading.value = false;
} else {
loadingError.value = error;
showToast(error.message || error);
}
});
}
function updateCategorySelected(e: Event): void {
const target = e.target as HTMLInputElement;
const categoryId = target.value;
const category = transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
if (!category) {
return;
}
filterCategoryIds.value[category.id] = !target.checked;
}
function updateAllSubCategoriesSelected(e: Event): void {
const target = e.target as HTMLInputElement;
const categoryId = target.value;
const category = transactionCategoriesStore.allTransactionCategoriesMap[categoryId];
selectAllSubCategories(filterCategoryIds.value, category, !target.checked);
}
function selectAllCategories(): void {
selectAll(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
}
function selectNoneCategories(): void {
selectNone(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
}
function selectInvertCategories(): void {
selectInvert(filterCategoryIds.value, transactionCategoriesStore.allTransactionCategoriesMap);
}
function save(): void {
saveFilterCategoryIds();
props.f7router.back();
}
function onPageAfterIn(): void {
routeBackOnError(props.f7router, loadingError);
}
init();
</script>